@charset "utf-8";

/* ==========================================================================
   dilliengutknecht.ch, Gestaltungssystem
   --------------------------------------------------------------------------
   Eine einzige Datei fuer alle vier Seiten. Die Reihenfolge der Baender
   entspricht CONTRACT.md, damit man eine Klasse findet, ohne zu suchen.

   Zwei Regeln, die hier ueberall durchgehalten werden und die man kennen muss,
   bevor man etwas ergaenzt:

   1. Farben kommen ausschliesslich aus den Tokens in :root. Ausnahme sind
      rgba()-Flaechen ueber Fotos, weil dort ein Alphawert gebraucht wird, der
      keine Markenfarbe ist. Grund: eine dunkle Variante soll spaeter ein
      reiner Tokentausch bleiben.
   2. Nichts darf den Koerper waagrecht scrollen lassen. Breite Inhalte
      (Filterleiste, Miniaturbilder der Lightbox) bekommen einen eigenen
      Scrollcontainer.
   ========================================================================== */


/* ==========================================================================
   1  Tokens
   ========================================================================== */

:root {
  /* Farben, identisch mit gutknechtmedia.ch/src/app/globals.css. Die beiden
     Seiten gehoeren derselben Person, also muessen sie dieselbe Palette
     tragen. Abweichungen hier fallen beim Wechsel zwischen den Seiten auf. */
  --paper: #ffffff;
  --paper-sunk: #fafbfb;
  --ink: #0a0a0a;
  --ink-soft: #4b5259;
  --hairline: #e6e8e9;
  --accent: #00acac;
  --accent-ink: #ffffff;
  --accent-soft: #e3f6f6;

  /* Hoehe des klebenden Kopfes. Wird an drei Stellen gelesen: der Kopf setzt
     sich darauf, der Hero zieht sie von der Bildschirmhoehe ab, und das
     mobile Menue haengt sich darunter. Eine Zahl, damit die drei nie
     auseinanderlaufen. */
  --header-h: 4.75rem;

  --gutter: clamp(1.25rem, 4.5vw, 3.5rem);
  --maxw: 82rem;
  --radius: 1rem;
  --radius-lg: 1.25rem;

  /* Eine Kurve und eine Dauer fuer die ganze Seite. Wer beides frei waehlen
     darf, erzeugt zwoelf verschiedene Geschwindigkeiten auf einer Seite. */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --dur: 0.45s;

  --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
  --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}


/* ==========================================================================
   2  Reset, Grundtypografie, Geruest
   ========================================================================== */

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

* {
  margin: 0;
}

html {
  -webkit-text-size-adjust: 100%;
  /* Sprungmarken landen sonst unter dem klebenden Kopf. */
  scroll-padding-top: calc(var(--header-h) + 1rem);
}

body {
  min-height: 100svh;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 1rem;
  line-height: 1.7;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* clip statt hidden: hidden erzeugt einen Scrollcontainer und zerstoert
     damit jedes position:sticky weiter unten (Kopf und Prozessszene). clip
     schneidet nur ab und laesst sticky in Ruhe. */
  overflow-x: clip;
}

img,
video,
svg {
  display: block;
  max-width: 100%;
}

/* <picture> traegt nur die WebP-Quellen und soll im Layout nicht mitzaehlen.
   display:contents nimmt die Huelle heraus, damit jede Regel, die auf ein Bild
   in einer Kachel zielt (Hoehe 100 Prozent, object-fit), weiter greift, als
   haenge das <img> unveraendert direkt im Elternelement. */
picture {
  display: contents;
}

/* Fast jeder Baustein hier setzt display selbst, und display schlaegt das
   hidden-Attribut. Ohne diese Zeile bleibt eine ausgefilterte Kachel sichtbar,
   obwohl das Skript sie ausgeblendet hat. */
[hidden] {
  display: none !important;
}

img,
video {
  height: auto;
}

input,
textarea,
button,
select {
  font: inherit;
  color: inherit;
}

button {
  background: none;
  border: 0;
  cursor: pointer;
}

a {
  color: inherit;
  text-decoration: none;
}

ul,
ol {
  list-style: none;
  padding: 0;
}

h1,
h2,
h3,
h4 {
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

p {
  text-wrap: pretty;
}

strong {
  font-weight: 600;
}

/* Fokusring fuer alles, was man mit der Tastatur erreicht. Die alte Fassung
   hatte gar keinen, damit war die Seite per Tastatur unbedienbar. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

::selection {
  background: var(--accent);
  color: var(--accent-ink);
}

/* Container. Jede Zeile Text der Seite laeuft durch diesen. */
.shell {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* Vertikaler Rhythmus. Alle Abschnitte teilen sich denselben Wert, damit die
   Seite beim Scrollen einen Takt hat. */
.section {
  padding-block: clamp(4.5rem, 11vw, 9rem);
}

.section--sunk {
  background: var(--paper-sunk);
  border-block: 1px solid var(--hairline);
}

.section--mint {
  background: var(--accent-soft);
}

/* Erster Tabulator der Seite. Sichtbar erst im Fokus, sonst ueber den oberen
   Rand geschoben. */
.skip-link {
  position: fixed;
  top: 0.75rem;
  left: 0.75rem;
  z-index: 200;
  padding: 0.65rem 1.1rem;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  transform: translateY(-200%);
  transition: transform 0.2s var(--ease);
}

.skip-link:focus-visible {
  transform: translateY(0);
  outline-offset: 2px;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* ==========================================================================
   3  Typografische Skala
   ========================================================================== */

.title-xl {
  font-size: clamp(2.75rem, 8.5vw, 6.5rem);
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 0.98;
}

.title-lg {
  font-size: clamp(1.9rem, 4.4vw, 3.25rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.08;
}

.title-md {
  font-size: clamp(1.35rem, 2.4vw, 1.85rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

.lead {
  font-size: clamp(1rem, 1.6vw, 1.175rem);
  font-weight: 400;
  line-height: 1.65;
  color: var(--ink-soft);
  max-width: 46rem;
}

/* Etikett ueber jedem Abschnitt. inline-flex, weil die Haarlinie davor auf
   der Grundlinie sitzen muss und nicht darunter rutschen darf. */
.label {
  display: inline-flex;
  align-items: center;
  gap: 0.85rem;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--ink-soft);
}

.label__rule {
  display: block;
  flex: 0 0 auto;
  width: 2rem;
  height: 1px;
  background: var(--accent);
}

/* Auf dunklem Foto. Die Linie bleibt in Akzentfarbe, weil sie dort gegen
   Schwarz genug Kontrast hat.

   Voll deckendes Weiss, keine 82 Prozent: das Etikett ist mit 0.6875rem der
   kleinste Text der Seite und sitzt im oberen Drittel des Kapitelkopfes, wo
   der Abdunkelungsverlauf am schwaechsten ist. Auf einer hellen Bildstelle
   fiel es gedaempft unter die geforderten 4.5:1. */
.label--on-dark {
  color: var(--paper);
}

/* Gruppiert Etikett, Titel, Lead und optional eine Aktion rechts. Ab 800px
   steht die Aktion auf derselben Zeile wie der Titel. */
.section-head {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin-bottom: clamp(2.5rem, 5vw, 4rem);
}

.section-head .title-lg,
.section-head .title-md {
  max-width: 24ch;
}

.section-head__action {
  margin-top: 0.5rem;
}

@media (min-width: 800px) {
  .section-head {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    column-gap: 2rem;
  }

  .section-head > .label,
  .section-head > .title-lg,
  .section-head > .title-md,
  .section-head > .lead {
    grid-column: 1;
  }

  /* Die Aktion steht rechts neben dem Titelblock und sitzt auf dessen
     Grundlinie, egal wie viele Zeilen links stehen. */
  .section-head__action {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: end;
    justify-self: end;
    margin-top: 0;
  }
}


/* ==========================================================================
   4  Bewegungsschicht
   --------------------------------------------------------------------------
   Zwei Klassen auf <html>, gesetzt von js/motion.js:

     .motion-on      die Schicht lebt. Bleibt fuer den ganzen Besuch stehen
                     und schaltet die reine CSS-Grundlage weiter unten ab, weil
                     eine CSS-Animation die Inline-Styles von GSAP ueberstimmen
                     wuerde.
     .motion-arming  die Ziele sind noch nicht uebernommen. Wird vor dem ersten
                     Anstrich gesetzt und faellt weg, sobald die Tweens stehen.
                     Ein Timeout von 2500ms raeumt sie in jedem Fall ab, damit
                     ein Abbruch mitten im Aufbau nie Inhalt unsichtbar laesst.

   Ohne JavaScript und bei reduzierter Bewegung wird keine der beiden gesetzt,
   die Seite steht dann vollstaendig im Ruhezustand.
   ========================================================================== */

html.motion-arming [data-motion-text],
html.motion-arming [data-reveal],
html.motion-arming [data-reveal-group],
html.motion-arming [data-image-reveal],
html.motion-arming [data-hero-eyebrow],
html.motion-arming [data-hero-line],
html.motion-arming [data-hero-sub],
html.motion-arming [data-hero-cta],
html.motion-arming [data-page-hero-eyebrow],
html.motion-arming [data-page-hero-title],
html.motion-arming [data-page-hero-sub] {
  visibility: hidden;
}

/* Das Hero-Foto wird nie versteckt: es ist das LCP-Element. Nur der Text
   darueber wartet. Der Vorhang darauf laeuft ueber clip-path, das kostet kein
   Layout und keinen CLS-Punkt. */

/* Die maskierten Zeilen von SplitText. overflow:hidden auf der Maske ist das,
   was eine Zeile von unterhalb ihrer eigenen Grundlinie hochfahren laesst. */
.motion-line {
  display: block;
  will-change: transform, opacity;
}

[data-image-reveal] {
  overflow: hidden;
}

/* will-change nur dort, wo wirklich jeden Frame etwas gerechnet wird. Lenis
   haelt das Scrollen auf dem Hauptthread, deshalb brauchen diese Elemente eine
   eigene Compositor-Ebene. Flaechendeckend gesetzt kostet es Speicher und
   bringt nichts.

   Bei reduzierter Bewegung steigt js/motion.js vollstaendig aus, dann bewegt
   sich garantiert nichts. Die Ebenen kosten dort nur Speicher, und betroffen
   waeren ausgerechnet die groessten Bilder der Seite. Deshalb die ganze Regel
   in die Gegenabfrage. */
@media (prefers-reduced-motion: no-preference) {
  [data-parallax-image],
  [data-hero-image],
  [data-hero-copy],
  [data-page-hero-image],
  [data-page-hero-copy],
  [data-marquee] .marquee__track {
    will-change: transform;
  }
}

/*
  Laeuft die Bewegungsschicht, schreibt GSAP dieselbe Eigenschaft jeden Frame
  neu, auf die hier eine transition liegt. Jede Setzung startete dann eine
  neue Uebergangszeit, und der Balken haengt rund eine Sekunde hinter seinem
  Tween her. Die transition ist nur fuer den Fall ohne JavaScript gedacht
  (--skill-scale, siehe Abschnitt 10), dort bleibt sie stehen.
*/
html.motion-on .skill__fill,
html.motion-on .lightbox {
  transition: none;
}

/* --------------------------------------------------------------------------
   Die Grundlage ohne JavaScript.

   view() bindet die Animation an den Weg des Elements durch das Fenster, ganz
   ohne Skript. Alles steckt in @supports, ein Browser ohne scroll-getriebene
   Animationen zeigt einfach den Ruhezustand. Sichtbarkeit ist der Ruhezustand,
   die Zugabe kann also nie etwas verbergen.
   -------------------------------------------------------------------------- */

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

@keyframes progress-grow {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    html:not(.motion-on) [data-reveal],
    html:not(.motion-on) [data-reveal-item],
    html:not(.motion-on) [data-motion-text] {
      animation: reveal-rise linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 55%;
    }
  }
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    html:not(.motion-on) .progress {
      animation: progress-grow linear both;
      animation-timeline: scroll(root block);
    }
  }
}

/* --------------------------------------------------------------------------
   Reduzierte Bewegung, WCAG 2.3.3.

   Praktisch alles aus. Farbwechsel und Deckkraft bleiben, damit ein Knopf
   weiterhin sichtbar auf den Zeiger antwortet. Jede Verschiebung faellt weg.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  .btn:hover,
  .card:hover .card__img,
  .tile:hover .tile__media img,
  .tile:hover .tile__img,
  .tile--video:hover .tile__media::before,
  .feature__video[data-video-card]:hover::before,
  .link-arrow:hover::after,
  .social__link:hover {
    transform: none !important;
  }
}


/* ==========================================================================
   5  Lesefortschritt, Kopf, Navigation
   ========================================================================== */

/* Der Balken haengt ueber dem Kopf und wird von motion.js in der Breite
   gezogen. Ruhezustand ist Breite null, das Element ist aria-hidden und
   traegt keine Information, die sonst fehlen wuerde. */
.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 60;
  height: 2px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  height: var(--header-h);
  border-bottom: 1px solid var(--hairline);
  /* Halbdurchsichtig, damit man beim Scrollen sieht, dass darunter etwas
     durchlaeuft. color-mix statt rgba, weil die Grundfarbe ein Token bleiben
     muss. */
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  backdrop-filter: saturate(1.4) blur(14px);
  -webkit-backdrop-filter: saturate(1.4) blur(14px);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  height: 100%;
}

.wordmark {
  font-size: clamp(0.95rem, 1.6vw, 1.0625rem);
  font-weight: 600;
  letter-spacing: -0.015em;
  white-space: nowrap;
}

.wordmark span {
  color: var(--accent);
}

/* Die Hauptnavigation erscheint erst, wenn vier Punkte plus Knopf nebeneinander
   Platz haben. Darunter uebernimmt das mobile Menue. */
.nav {
  display: none;
}

@media (min-width: 900px) {
  .nav {
    display: flex;
    align-items: center;
    gap: clamp(1.25rem, 2.4vw, 2.25rem);
  }
}

.nav a {
  position: relative;
  padding-block: 0.35rem;
  font-size: 0.9375rem;
  color: var(--ink-soft);
  transition: color var(--dur) var(--ease);
}

.nav a::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 1px;
  background: var(--accent);
  /* Waechst von links. transform-origin ist der ganze Effekt, deshalb steht er
     hier und nicht in einer Hover-Regel. */
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--dur) var(--ease);
}

.nav a:hover,
.nav a:focus-visible,
.nav a[aria-current="page"] {
  color: var(--ink);
}

.nav a:hover::after,
.nav a:focus-visible::after,
.nav a[aria-current="page"]::after {
  transform: scaleX(1);
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

/* Der Anfrage-Knopf im Kopf braucht auf sehr schmalen Geraeten den Platz
   nicht wegzunehmen, das mobile Menue traegt dieselben Angaben im Fuss. */
@media (max-width: 419px) {
  .header-actions .btn {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Burger. Drei Linien, die bei aria-expanded="true" zum Kreuz werden. Der
   Zustand haengt am ARIA-Attribut, nicht an einer zweiten Klasse: so kann das
   Markup gar nicht erst auseinanderlaufen.
   -------------------------------------------------------------------------- */

.burger {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0 0.6rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  transition: border-color var(--dur) var(--ease);
}

.burger:hover {
  border-color: var(--accent);
}

.burger span {
  display: block;
  height: 1.5px;
  width: 100%;
  background: var(--ink);
  border-radius: 2px;
  transition: transform 0.35s var(--ease), opacity 0.2s linear;
}

.burger[aria-expanded="true"] span:nth-child(1) {
  transform: translateY(6.5px) rotate(45deg);
}

.burger[aria-expanded="true"] span:nth-child(2) {
  opacity: 0;
}

.burger[aria-expanded="true"] span:nth-child(3) {
  transform: translateY(-6.5px) rotate(-45deg);
}

@media (min-width: 900px) {
  .burger {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Mobiles Menue. Vollflaeche unter dem Kopf.

   Der Zustand laeuft ueber visibility und opacity, nicht ueber display:none.
   Grund: GSAP kann eine Deckkraft animieren, ein display-Wechsel schneidet
   jede Animation ab. visibility nimmt das Panel zusaetzlich aus der
   Tastaturreihenfolge, das inert-Attribut im Markup deckt den Rest ab.
   -------------------------------------------------------------------------- */

.mobile-nav {
  position: fixed;
  inset-block-start: var(--header-h);
  inset-inline: 0;
  inset-block-end: 0;
  z-index: 45;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 2rem;
  padding: clamp(1.75rem, 7vw, 3rem) var(--gutter) calc(2rem + env(safe-area-inset-bottom));
  background: var(--paper);
  overflow-y: auto;
  overscroll-behavior: contain;
  visibility: hidden;
  opacity: 0;
  transition: opacity 0.3s var(--ease), visibility 0s linear 0.3s;
}

.mobile-nav[data-open="true"] {
  visibility: visible;
  opacity: 1;
  transition: opacity 0.3s var(--ease), visibility 0s linear 0s;
}

.mobile-nav ul {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.mobile-nav ul a {
  display: block;
  padding-block: 0.7rem;
  border-bottom: 1px solid var(--hairline);
  font-size: clamp(1.5rem, 7vw, 2.25rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  transition: color var(--dur) var(--ease);
}

.mobile-nav ul a:hover {
  color: var(--accent);
}

/* Die laufende Seite bekommt neben der Farbe eine Kante. In .nav uebernimmt
   das die Haarlinie darunter, hier fehlte sie: Farbe allein als einziges
   Merkmal ist zu wenig, und der Akzent liegt auf grossem Text knapp unter
   den geforderten 3:1. */
.mobile-nav ul a[aria-current="page"] {
  color: var(--accent);
  border-inline-start: 2px solid var(--accent);
  padding-inline-start: 0.75rem;
}

.mobile-nav__foot {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: 0.9375rem;
  color: var(--ink-soft);
}

.mobile-nav__foot a:hover {
  color: var(--accent);
}

/* Ab der Breite, ab der die richtige Navigation steht, darf das Panel nicht
   mehr aufgehen, auch wenn beim Umschalten der Zustand offen stehen blieb. */
@media (min-width: 900px) {
  .mobile-nav {
    display: none;
  }
}


/* ==========================================================================
   6  Knoepfe und Textlinks
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  padding: 0.95rem 2rem;
  border-radius: 999px;
  border: 1px solid transparent;
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: transform 0.3s var(--ease), background-color var(--dur) var(--ease),
    border-color var(--dur) var(--ease), color var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease);
}

.btn--primary {
  background: var(--accent);
  color: var(--accent-ink);
}

.btn--primary:hover {
  transform: translateY(-2px);
  box-shadow: 0 12px 28px -12px color-mix(in srgb, var(--accent) 65%, transparent);
}

/* Der Druck sitzt sofort, das Loslassen bleibt weich. */
.btn--primary:active {
  transform: translateY(0);
  transition-duration: 75ms;
}

.btn--ghost {
  background: transparent;
  border-color: var(--hairline);
  color: var(--ink);
}

.btn--ghost:hover {
  border-color: var(--accent);
  color: var(--accent);
  transform: translateY(-2px);
}

.btn--sm {
  padding: 0.7rem 1.35rem;
  font-size: 0.75rem;
  letter-spacing: 0.09em;
}

/* Textlink mit Pfeil. Der Pfeil wandert, der Text bleibt stehen, damit die
   Zeile beim Ueberfahren nicht springt. */
.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--ink);
  transition: color var(--dur) var(--ease);
}

.link-arrow::after {
  content: "";
  width: 1.15rem;
  height: 1px;
  background: currentColor;
  transition: transform 0.3s var(--ease);
}

.link-arrow:hover,
.link-arrow:focus-visible {
  color: var(--accent);
}

.link-arrow:hover::after,
.link-arrow:focus-visible::after {
  transform: translateX(4px);
}


/* ==========================================================================
   7  Projektkachel und Zeigerschein
   ========================================================================== */

.card {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  border-radius: var(--radius-lg);
}

.card__media {
  position: relative;
  /* Die Form kommt aus der Datei, gesetzt als --ratio am Element oder an
     seiner Reihe (siehe .work-grid). Ohne Angabe bleibt 4/5, wie bei
     gutknechtmedia.ch fuer Uploads ohne bekannte Groesse. */
  aspect-ratio: var(--ratio, 4 / 5);
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--paper-sunk);
}

.card__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.7s var(--ease);
}

.card:hover .card__img,
.card:focus-visible .card__img {
  transform: scale(1.04);
}

.card__body {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding-inline: 0.15rem;
}

.card__title {
  font-size: 1.125rem;
  font-weight: 600;
  letter-spacing: -0.015em;
  transition: color var(--dur) var(--ease);
}

.card:hover .card__title {
  color: var(--accent);
}

.card__meta {
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* Der Schein folgt dem Zeiger ueber zwei Eigenschaften, die motion.js
   schreibt. Alles daran ist Farbe, es kostet also nichts, solange der Zeiger
   woanders ist. Nur fuer echte Zeigergeraete: auf einem Touchscreen bliebe der
   Schein nach dem Tippen kleben. */
@media (hover: hover) and (pointer: fine) {
  [data-spotlight] {
    --spot-x: 50%;
    --spot-y: 50%;
    position: relative;
    isolation: isolate;
  }

  [data-spotlight]::after {
    content: "";
    position: absolute;
    inset: -1.5rem;
    z-index: -1;
    pointer-events: none;
    opacity: 0;
    border-radius: inherit;
    transition: opacity 0.45s ease;
    background: radial-gradient(
      16rem circle at var(--spot-x) var(--spot-y),
      color-mix(in oklab, var(--accent) 16%, transparent),
      transparent 70%
    );
  }

  [data-spotlight]:hover::after,
  [data-spotlight]:focus-visible::after {
    opacity: 1;
  }
}


/* ==========================================================================
   8  Startseite
   ========================================================================== */

/* --------------------------------------------------------------------------
   Hero.

   .hero traegt die Containerbreite selbst, damit die Seite keinen zusaetzlichen
   Wrapper braucht. Wer trotzdem .shell danebensetzt, aendert nichts: die Werte
   sind dieselben und Padding addiert sich nicht.

   svh statt vh, weil vh auf Mobilgeraeten die Adressleiste mitrechnet und der
   Hero dann beim ersten Scrollen springt.
   -------------------------------------------------------------------------- */

.hero {
  display: grid;
  grid-template-areas:
    "copy"
    "frame";
  gap: clamp(2rem, 5vw, 3rem);
  align-content: center;
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
  padding-block: clamp(2.5rem, 6vw, 4.5rem);
  min-height: calc(100svh - var(--header-h));
}

.hero__copy {
  grid-area: copy;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1.25rem;
}

.hero__eyebrow {
  margin-bottom: 0.25rem;
}

.hero__title {
  /* Der Titel darf ruhig ueber den Textblock hinauslaufen, er ist das
     groesste Element der Seite. */
  max-width: 14ch;
}

.hero__sub {
  max-width: 38ch;
}

.hero__cta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 0.75rem;
}

.hero__frame {
  grid-area: frame;
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-lg);
  aspect-ratio: 4 / 5;
  background: var(--paper-sunk);
}

.hero__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* Die Fusszeile des Heros (Scrollhinweis links, Standort rechts) und ihr
   pulsender Strich sind seit dem 18.09.2026 weg, auf Wunsch des Betreibers.
   Der Hero besteht nur noch aus Text und Bild. */

@media (min-width: 900px) {
  .hero {
    grid-template-areas: "copy frame";
    grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
    column-gap: clamp(2rem, 5vw, 4.5rem);
    align-items: center;
  }

  .hero__frame {
    /* Auf breiten Schirmen traegt das Bild die halbe Buehne, deshalb hoeher
       als das mobile 4/5. Abgezogen wird nur noch die eigene Polsterung des
       Heros (zweimal bis 4.5rem); die frueheren 12rem rechneten die
       Fusszeile mit ein, die es nicht mehr gibt. */
    aspect-ratio: 4 / 5;
    max-height: calc(100svh - var(--header-h) - 9rem);
  }
}

/* --------------------------------------------------------------------------
   Laufband.

   Die Maske an den Raendern laesst das Band auslaufen, statt es hart
   abzuschneiden. Masken lesen nur den Alphakanal, die Farbe darin ist also
   beliebig und darf ein Token sein.

   Ohne JavaScript steht das Band still. Eine CSS-Ersatzanimation gibt es
   bewusst nicht: sie muesste voraussetzen, dass das Markup seine Eintraege
   doppelt enthaelt, und bei einfacher Liste liefe die Haelfte der Zeit ins
   Leere.
   -------------------------------------------------------------------------- */

.marquee {
  overflow: hidden;
  padding-block: clamp(1rem, 2.5vw, 1.5rem);
  border-block: 1px solid var(--hairline);
  mask-image: linear-gradient(
    to right,
    transparent,
    var(--ink) 7%,
    var(--ink) 93%,
    transparent
  );
  -webkit-mask-image: linear-gradient(
    to right,
    transparent,
    var(--ink) 7%,
    var(--ink) 93%,
    transparent
  );
}

.marquee__track {
  display: flex;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);
  width: max-content;
}

.marquee__item {
  display: inline-flex;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);
  font-size: clamp(1.15rem, 3vw, 2rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--ink-soft);
  white-space: nowrap;
}

.marquee__item::after {
  content: "";
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: var(--accent);
}

/* --------------------------------------------------------------------------
   Arbeitsraster der Startseite. Uebernommen von gutknechtmedia.ch
   (components/portfolio-grid.tsx, Variante "featured", 18.09.2026).

   Vier Projekte, das neueste zuerst. Welche Anordnung gilt, entscheidet die
   Form des neuesten:

     quer:  .work-grid--lead   das neueste in voller Breite, die drei davor
                               in einer Reihe darunter (auf dem Telefon
                               gestapelt, zu dritt nebeneinander waeren sie
                               dort nur noch ein Streifen)
     hoch:  .work-grid--row    alle vier nebeneinander, zwei je Reihe auf dem
                               Telefon

   Die Kachelform folgt der Datei statt einer festen Vorgabe. Die Leitkachel
   bekommt exakt das Seitenverhaeltnis ihrer Datei, ein 16:9-Video bleibt
   16:9. Eine Reihe teilt sich dagegen EIN Verhaeltnis, damit ihre Kacheln
   gleich hoch sind: den Median der Formate in der Reihe. Der Ausreisser wird
   per object-cover mittig eingepasst. Median statt Mittelwert, damit einer
   nicht die Mehrheit mitverzerrt.

   Bei gutknechtmedia.ch rechnet das der Server aus der Datenbank. Hier steht
   es im Markup: --ratio an der Leitkachel (aus width und height ihres Bilds)
   und --ratio an der Reihe (der Median). Wer ein Projekt tauscht, rechnet
   den Median neu. Kein JavaScript dafuer, weil ein spaeter gesetztes
   Verhaeltnis die Seite nach dem ersten Anstrich springen liesse.
   -------------------------------------------------------------------------- */

.work-grid {
  display: flex;
  flex-direction: column;
  gap: clamp(1.5rem, 3vw, 2.5rem);
}

/* Die Leitkachel braucht keine eigene Breite: in der Flex-Spalte streckt sie
   sich von selbst ueber die ganze Zeile. Die Klasse steht trotzdem, damit im
   Markup lesbar bleibt, welche Kachel fuehrt, und --ratio dort seinen Platz
   hat. */
.work-grid__lead {
  width: 100%;
}

.work-grid__row {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1.5rem, 3vw, 2.5rem);
}

/* Reihe unter einer queren Leitkachel: drei nebeneinander ab 640px. */
@media (min-width: 640px) {
  .work-grid--lead .work-grid__row {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

/* Hochformatige Leitkachel: alle vier in einer Reihe, zwei je Zeile auf dem
   Telefon, vier ab 768px. Das --ratio steht dann an .work-grid__row selbst,
   und die Leitkachel gehoert mit in die Reihe. */
.work-grid--row .work-grid__row {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

@media (min-width: 768px) {
  .work-grid--row .work-grid__row {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

/* --------------------------------------------------------------------------
   Sektion Koennen, "Was ich beherrsche". Seit 18.09.2026 zwischen Hero und
   Projekten.

   Vorbild ist der Abschnitt "Animate anything" auf gsap.com: eine Buehne
   haengt am oberen Rand fest, und der Scroll schiebt eine breite Textbahn
   nach links. Ein Satz in Riesenschrift, einzelne Woerter in schraeg
   gestellten Farbkaesten, dazwischen acht kleine Grafiken, die anspringen,
   sobald sie ins Bild fahren. Das Schieben und Starten erledigt js/skills.js,
   hier steht nur, wie die Bahn aussieht. Fuer Screenreader liegt der Satz
   als .sr-only-Absatz daneben, die Bahn selbst ist aria-hidden.

   Die Bahn laeuft nur ab 900px waagrecht, und nur ohne reduzierte Bewegung.
   Darunter ist die Schrift zu gross fuer einen Satz in einer Zeile: bei
   375px Breite ist allein die Gruppe "fliege die", Drohne, "Drohne," bei
   2.5rem rund 410px breit, die Zeile hat 335px. Und ein festgehaltener
   Abschnitt fuehlt sich auf einem Telefon wie ein haengender Bildschirm an,
   man wischt und die Seite steht. Deshalb steht der Satz dort als normaler
   Absatz, der umbricht, mit den Grafiken als Woerter darin. Bewegungslos ist
   er seit dem 23.09.2026 trotzdem nicht: js/skills.js (buildParagraph) laesst
   jede Gruppe auftreten, sobald ihre Zeile ins Bild steigt - dieselbe
   Choreografie wie oben, nur ohne Pin und ohne Fahrt. Das CSS aendert sich
   dafuer nicht, die Auftritte sind Inline-Stile von GSAP. Bei reduzierter
   Bewegung gilt die wirklich stille Fassung auf jeder Breite, js/skills.js
   steigt dort ganz oben aus, und beide Schichten haengen an derselben
   Abfrage.

   Zusaetzlich zur Abfrage haengt die Zeile am Attribut data-skills-active,
   das nur js/skills.js setzt (in build(), weg beim Aufraeumen). Grund: die
   Zeile ist rund sechs Bildschirme breit und der Abschnitt beschneidet sie.
   Wuerde sie allein an der Abfrage haengen, staende sie auch ohne
   JavaScript, ohne GSAP oder nach einem Fehler im Skript da, und niemand
   schoebe sie: ab dem dritten Wort waere der Satz fuer Sehende unerreichbar,
   in einem bildschirmhohen leeren Band. Mit dem Attribut zeigt jede Fassung
   ohne laufendes Skript den Absatz. Gleiches Muster wie html.motion-on in
   js/motion.js.

   .skills-scene statt .skills: "Ueber mich" nutzt .skills fuer das Raster
   der Faehigkeitsgruppen (Abschnitt 10, display: grid). Ein eigener Name
   haelt beide Bausteine auseinander, ohne Spezifitaetstrick und ohne dass
   Rasterregeln in die Sektion kaskadieren.
   -------------------------------------------------------------------------- */

.skills-scene {
  /* Eine Schriftgroesse fuer Woerter, Grafiken und Abstaende. Die Grafiken
     messen sich in em, die Abstaende in dieser Variable, damit alles mit
     derselben Zahl waechst. */
  --skills-size: clamp(2.5rem, 7vw, 6rem);
  padding-block: clamp(4.5rem, 11vw, 9rem);
  /* Die Bahn ist ein Vielfaches der Seite breit. Ohne Beschnitt liesse sie
     den Koerper waagrecht scrollen, siehe Regel 2 im Kopf dieser Datei. */
  overflow: hidden;
}

/* Grundfassung: ein Absatz. Die Bahn ist ein Block, jede Gruppe laeuft als
   Inline-Text darin weiter, die Leerzeichen im Markup sind die Wortabstaende.
   Erst ab 900px und mit laufendem Skript wird daraus eine Zeile, siehe unten.
   Die Buehne hat hier keine eigene Hoehe, sie ist so hoch wie der Absatz. */
.skills-scene__track {
  display: block;
  width: auto;
  white-space: normal;
  /* Buendig mit dem Kopf darueber: .shell rueckt ab 82rem in die Mitte, die
     Bahn haengt ohne .shell direkt im Abschnitt und rechnet denselben
     Versatz nach. Darunter bleibt es der Rinnstein. */
  padding-inline: max(var(--gutter), calc((100% - var(--maxw)) / 2 + var(--gutter)));
}

.skills-scene__group {
  display: inline;
  font-size: var(--skills-size);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.05;
  color: var(--ink);
}

/* Die Grafik ist ein Wort im Satz und misst sich in em: sie waechst und
   schrumpft mit der Schrift, auf 375px wie auf 1600px, ohne eigene Abfrage.
   Bleibt ein Kasten einmal ohne <svg>, haelt er den Platz in der Zeile. */
.skills-scene__icon {
  display: inline-block;
  flex: 0 0 auto;
  width: 1.1em;
  height: 1.1em;
  vertical-align: middle;
  color: var(--ink);
}

.skills-scene__icon svg {
  width: 100%;
  height: 100%;
}

/* Das Gegenstueck zu den schraegen Farbkaesten auf gsap.com, in unserer
   Palette: Tinte auf Mintband, kein Farbwechsel. Der Kasten soll wie ein
   aufgeklebter Zettel wirken, ein Effekt darauf wuerde mit den Grafiken
   daneben konkurrieren.

   Die Schraege (-2deg, jeder zweite Kasten 1.5deg) stand bis 19.09.2026
   hier als transform. Seither faellt jeder Kasten mit einem Auftritt herein
   (js/skills.js dreht, kippt und skaliert ihn), und GSAP schreibt dafuer
   ein eigenes transform ans Element. Zwei Schichten an derselben
   Eigenschaft streiten sich: die letzte gewinnt, und das waere jedes Mal
   GSAP, der Kasten stuende nach dem Auftritt gerade. Deshalb setzt
   js/skills.js die Schraege als Endwert des Auftritts, und hier steht kein
   transform mehr. Ohne JavaScript und unter 900px steht der Kasten gerade,
   das ist in Ordnung. */
.skills-scene__mark {
  display: inline-block;
  padding: 0.05em 0.35em;
  border-radius: 0.18em;
  background: var(--accent-soft);
  color: var(--ink);
}

/* Woerter und Buchstaben sind Bloecke, weil transform nur auf Bloecken
   greift: yPercent und rotate an einem inline span blieben wirkungslos. Im
   Absatz unter 900px aendert inline-block nichts Sichtbares, die
   Leerzeichen im Markup bleiben die Wortabstaende. */
.skills-scene__word,
.skills-scene__letter {
  display: inline-block;
}

/* Ein Wort bricht nie in sich. Fuer ein Wort aus Text gilt das ohnehin,
   fuer die Buchstabenwoerter ("Farben,", "animiere.") nicht: Chrome bricht
   zwischen benachbarten inline-blocks auch ohne Leerraum um, sobald der
   Platz fehlt, und ab rund 160 Prozent Textvergroesserung stuende
   "animiere." im Absatz bei 375px zweizeilig im Mintkasten. nowrap am Host
   macht aus den Buchstaben wieder ein Wort, das wie jedes andere als
   Ganzes in die naechste Zeile rutscht. Der Kasten ist mit dabei, weil
   auch "animiere." in einem Kasten steht. */
.skills-scene__word,
.skills-scene__mark {
  white-space: nowrap;
}

/* Tiefe fuer die drei raeumlichen Auftritte: die Buchstaben von "Farben"
   kippen um die x-Achse, die Grafik "curves" ebenso, der Kasten "gestalte"
   klappt vom Boden auf. Ohne perspective sieht eine Drehung um x nur wie
   ein Stauchen aus. perspective wirkt auf die Kinder des Elements, das sie
   traegt: am Wort fuer seine Buchstaben, am Grafik-Wrapper fuer das <svg>. */
.skills-scene__word--3d,
.skills-scene__icon {
  perspective: 600px;
}

/* Der Kasten "gestalte" dreht sich selbst, eine perspective an ihm erreichte
   seine eigene Drehung nicht; sie sitzt deshalb an seiner Gruppe, und die
   Zusatzklasse am Kasten sagt, welche Gruppe das ist. Eigene Regel, nicht in
   der Liste oben: ein Browser ohne :has() verwirft eine ganze Selektorliste,
   sobald ein Selektor darin ungueltig ist, und die Buchstaben von "Farben"
   und die Grafik "curves" verloeren ihre Tiefe gleich mit. So zeigt er nur
   das Aufklappen flach, es bleibt vollstaendig. */
.skills-scene__group:has(> .skills-scene__mark--flip) {
  perspective: 600px;
}

.skills-scene__word--3d .skills-scene__letter {
  transform-style: preserve-3d;
}

/* Die bewegte Fassung: Abfrage UND Attribut, siehe Kopf dieses Abschnitts.
   Die Abfrage bleibt, weil js/skills.js dieselbe in gsap.matchMedia fuehrt
   und das Attribut beim Kippen der Abfrage entfernt; faellt eine der beiden
   Bedingungen, steht im selben Moment der Absatz. */
@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  /* Die festgehaltene Buehne: genau ein Bildschirm hoch, die Bahn in der
     Mitte. Der Kopf klebt oben und deckt die obersten 4.75rem der Buehne,
     die Polsterung nimmt diesen Streifen aus der Rechnung, sonst saesse
     der Satz um die halbe Kopfhoehe zu tief im sichtbaren Rest. */
  .skills-scene[data-skills-active] .skills-scene__stage {
    display: flex;
    align-items: center;
    height: 100svh;
    padding-block-start: var(--header-h);
  }

  /* Eine einzige Zeile, so breit wie ihr Inhalt. js/skills.js misst diese
     Breite und schiebt die Bahn um genau so viel nach links, wie sie ueber
     die Buehne hinausragt. Der Abstand zwischen den Gruppen ist derselbe
     wie der zwischen Wort und Grafik in der Gruppe, damit sich die Zeile
     als ein Satz liest. Ein rem-Wert waere bei 6rem Schrift nur ein Spalt. */
  .skills-scene[data-skills-active] .skills-scene__track {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    gap: calc(var(--skills-size) * 0.35);
    width: max-content;
    white-space: nowrap;
    /* Die Bahn wird bei jedem Bild verschoben und braucht ihre eigene Ebene,
       wie die anderen Ziele in Abschnitt 4. */
    will-change: transform;
  }

  .skills-scene[data-skills-active] .skills-scene__group {
    display: inline-flex;
    align-items: center;
    gap: 0.35em;
    /* Versatz, siehe unten. Der Wert kommt aus den nth-child-Regeln, die
       Rueckfallnull haelt Gruppen ohne Zuweisung auf der Grundlinie. */
    transform: translateY(var(--lift, 0));
  }

  /* Die Buchstaben werden einzeln bewegt (Kippen, Federn), also je eine
     eigene Ebene, aber nur solange die bewegte Fassung steht: im Absatz
     hielte will-change 16 Ebenen fuer nichts im Speicher. */
  .skills-scene[data-skills-active] .skills-scene__letter {
    will-change: transform;
  }

  /* Platzierung: der Satz liegt nicht auf einer Linie. Gruppen abwechselnd
     leicht ueber und unter der Grundlinie, die Grafiken zusaetzlich gegen
     ihr Wort versetzt (die Drohne fliegt ueber dem Text, die Tonspur haengt
     darunter). Nur in der bewegten Fassung, im Absatz waere ein Versatz
     ein Satzfehler.

     Arbeitsteilung mit js/skills.js, verbindlich: GSAP schreibt transform
     ans Element, das es bewegt, und eine CSS-transform an demselben
     Element wuerde dabei ueberschrieben (es gibt nur eine transform-
     Eigenschaft). Deshalb liegt jeder CSS-Versatz an einem Wrapper, den
     GSAP nie anfasst: CSS versetzt .skills-scene__group, GSAP bewegt darin
     __word, __mark, __letter und das <svg>; CSS versetzt den Grafik-
     Wrapper .skills-scene__icon, GSAP bewegt das <svg> darin. Nie beides
     an einem Element. Wer hier eine transform an __word, __mark, __letter
     oder svg schreibt, sieht sie nach dem ersten Auftritt nicht mehr. */
  .skills-scene[data-skills-active] .skills-scene__group:nth-child(odd) {
    --lift: -0.18em;
  }

  .skills-scene[data-skills-active] .skills-scene__group:nth-child(even) {
    --lift: 0.22em;
  }

  .skills-scene[data-skills-active] .skills-scene__icon {
    transform: translateY(var(--icon-lift, 0));
  }

  .skills-scene[data-skills-active] [data-skills-icon="aperture"],
  .skills-scene[data-skills-active] [data-skills-icon="bezier"] {
    --icon-lift: -0.35em;
  }

  .skills-scene[data-skills-active] [data-skills-icon="drone"] {
    --icon-lift: -0.6em;
  }

  /* Die Tonspur haengt ganz unter der Zeile, nicht nur ein Stueck (der
     Brief nannte 0.3em): sie faehrt waehrend des Pins schneller als die
     Bahn (js/skills.js, xPercent -260) und zieht dabei unter "den" und
     "mische" durch. In der Zeile ueberfuehre sie diese Woerter, bei 0.3em
     stand sie nach der halben Strecke auf dem "on" von "Ton,". 1.15em,
     weil die Grafik 1.1em hoch mittig auf der Zeile sitzt und ihre
     Oberkante unter die Unterlaengen der Schrift muss (etwa 0.25em unter
     der Grundlinie); der Wrapper ist ein Layoutmass, die Verschiebung
     aendert seine Kanten nicht, js/skills.js misst also weiter an ihm.
     Der negative Rand nimmt den Platz weg, den der Wrapper in der Zeile
     haelt: mit einer haengenden Grafik klaffte dort sonst ein Loch von
     1.8em zwischen "den" und "Ton,". Uebrig bleibt der doppelte
     Wortabstand, eine kleine Pause vor dem Wort, das die Grafik zeigt. */
  .skills-scene[data-skills-active] [data-skills-icon="waveform"] {
    --icon-lift: 1.15em;
    margin-inline: -0.55em;
  }

  .skills-scene[data-skills-active] [data-skills-icon="keyframes"] {
    --icon-lift: 0.25em;
  }
}

/* --------------------------------------------------------------------------
   Hier lagen bis 18.09.2026 das Zahlenband (.stats) und die gehaltene Szene
   "So arbeite ich" (.process, data-process-*). Beide Abschnitte sind auf
   Wunsch des Betreibers von der Startseite geflogen, und mit ihnen die
   Regeln: eine Regel ohne Markup ist nur ein Versprechen, das niemand
   einloest. Wer sie zurueckholt, nimmt gutknechtmedia.ch (globals.css,
   [data-process-scene]) als Vorlage.
   -------------------------------------------------------------------------- */


/* ==========================================================================
   9  Impressionen
   ========================================================================== */

/* --------------------------------------------------------------------------
   Kapitelkopf der Unterseiten. Text steht auf dem Foto, also weiss, und
   darunter liegt ein Verlauf, damit die Schrift auf jedem Bild lesbar bleibt.
   Der Verlauf ist rgba(): ein Alphawert ueber einem Foto ist keine
   Markenfarbe und gehoert deshalb nicht in die Tokens.
   -------------------------------------------------------------------------- */

.page-hero {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: flex-end;
  min-height: 62svh;
  overflow: hidden;
  background: var(--ink);
}

.page-hero__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.page-hero__bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to top,
    rgba(0, 0, 0, 0.78) 0%,
    rgba(0, 0, 0, 0.42) 45%,
    rgba(0, 0, 0, 0.15) 100%
  );
}

.page-hero__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.page-hero__copy {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding-block: clamp(2.5rem, 7vw, 4.5rem);
  color: var(--paper);
}

.page-hero__copy .lead {
  color: color-mix(in srgb, var(--paper) 80%, transparent);
  max-width: 42rem;
}

/* --------------------------------------------------------------------------
   Ausgewaehltes Video.

   Das <video> traegt preload="none" und bekommt seine Quelle erst beim Klick.
   Bis dahin steht hier ein Standbild. Grund steht in CONTRACT.md Abschnitt 6:
   die alte Startseite lud 269 MB pro Besuch.
   -------------------------------------------------------------------------- */

.feature {
  display: grid;
  gap: clamp(1.75rem, 4vw, 3rem);
}

@media (min-width: 900px) {
  .feature {
    grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(2rem, 4vw, 4rem);
  }
}

.feature__video {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--ink);
}

/*
  Alle Schichten des Kastens liegen uebereinander, nicht untereinander: das
  Standbild deckt den Spieler zu, bis jemand klickt. Ohne position:absolute
  stuenden Vorschaubild und Rahmen als zwei Bloecke hintereinander und der
  Kasten waere doppelt so hoch wie sein Seitenverhaeltnis erlaubt.
*/
.feature__video > video,
.feature__video > img,
.feature__video > iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  object-fit: cover;
}

/* Das Standbild liegt ueber dem Rahmen, solange der Film nicht laeuft. */
.feature__video > img {
  z-index: 1;
}

.feature__meta {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  align-items: flex-start;
}

.feature__meta p {
  color: var(--ink-soft);
  max-width: 42ch;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.tag {
  display: inline-flex;
  align-items: center;
  padding: 0.4rem 0.85rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   Filterleiste. Auf schmalen Geraeten passen die drei Knoepfe nicht immer
   nebeneinander, deshalb scrollt die Leiste in sich selbst. Der Koerper bleibt
   dabei unberuehrt.
   -------------------------------------------------------------------------- */

.filters {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* Der Fokusring der Knoepfe ragt aus der Zeile heraus und wuerde sonst am
     Rand des Scrollcontainers abgeschnitten. */
  padding: 0.3rem 0.3rem 0.85rem;
  margin-inline: -0.3rem;
  scroll-padding-inline: 0.3rem;
}

.filters::-webkit-scrollbar {
  display: none;
}

.filter-btn {
  flex: 0 0 auto;
  padding: 0.6rem 1.2rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: var(--paper);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--ink-soft);
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease),
    background-color var(--dur) var(--ease);
}

.filter-btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* Der aktive Zustand haengt an aria-pressed. Screenreader und Auge lesen
   damit denselben Zustand, und es gibt keine zweite Wahrheit im Markup. */
.filter-btn[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* --------------------------------------------------------------------------
   Galerie.
   -------------------------------------------------------------------------- */

.grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1.25rem, 3vw, 2rem);
}

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

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

.tile {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  text-align: left;
  width: 100%;
}

.tile__media {
  position: relative;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--paper-sunk);
}

/* .tile__img ist die benannte Fassung derselben Regel. Beide gelten, damit ein
   Bild ohne eigene Klasse in der Kachel trotzdem richtig sitzt. */
.tile__media img,
.tile__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.7s var(--ease);
}

.tile:hover .tile__media img,
.tile:focus-visible .tile__media img {
  transform: scale(1.04);
}

/* Abzeichen oben links, im Markup "3 Bilder" oder "Video laden, 258 MB".
   Halbtransparentes Schwarz, weil es ueber einem Foto liegt und dort jedes
   Token zu wenig Kontrast haette. */
.tile__badge {
  position: absolute;
  inset-block-start: 0.85rem;
  inset-inline-start: 0.85rem;
  z-index: 1;
  padding: 0.3rem 0.7rem;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--paper);
  font-family: var(--font-mono);
  font-size: 0.625rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.tile__body {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding-inline: 0.15rem;
}

.tile__title {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.015em;
  transition: color var(--dur) var(--ease);
}

.tile:hover .tile__title {
  color: var(--accent);
}

.tile__cat {
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   Videokachel ohne Vorschaubild.

   Drei der vier Filme haben kein Standbild auf dem Server. Statt ein leeres
   graues Rechteck zu zeigen, traegt hier eine gestaltete Flaeche: dunkler
   Verlauf, feines Raster, Abspielabzeichen in der Mitte. Liegt doch ein Bild
   im Markup (poster_ipa.jpg), deckt es die Flaeche zu und alles Weitere
   bleibt gleich.
   -------------------------------------------------------------------------- */

.tile--video .tile__media {
  background:
    radial-gradient(120% 80% at 50% 0%, color-mix(in oklab, var(--accent) 22%, transparent), transparent 65%),
    linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 82%, var(--accent)));
}

/* Abspielabzeichen. Ring plus Dreieck, beides aus CSS, damit kein Icon
   nachgeladen werden muss. Der ausgewaehlte Film oben auf der Seite traegt
   dasselbe Abzeichen: er wird von js/site.js ueber denselben Weg geladen und
   soll deshalb auch gleich aussehen. */
.tile--video .tile__media::before,
.feature__video[data-video-card]::before {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  z-index: 2;
  width: 4.25rem;
  height: 4.25rem;
  margin: -2.125rem 0 0 -2.125rem;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--paper) 55%, transparent);
  background: rgba(0, 0, 0, 0.35);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  transition: transform 0.45s var(--ease), background-color var(--dur) var(--ease);
}

.tile--video .tile__media::after,
.feature__video[data-video-card]::after {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  z-index: 3;
  width: 0.95rem;
  height: 1.05rem;
  margin: -0.525rem 0 0 -0.35rem;
  background: var(--paper);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}

.tile--video:hover .tile__media::before,
.tile--video:focus-visible .tile__media::before,
.feature__video[data-video-card]:hover::before,
.feature__video[data-video-card]:focus-visible::before {
  transform: scale(1.08);
  background: color-mix(in srgb, var(--accent) 70%, transparent);
}

/*
  Laeuft der Film, stoert das Abzeichen. Der Name des Attributs muss derselbe
  sein, den js/site.js setzt: hier stand einmal data-playing, gesetzt wird aber
  data-video-active, und deshalb lagen Ring und Dreieck dauerhaft mitten auf
  dem laufenden Bild. Ein Name, an einer Stelle vergeben.
*/
.tile--video[data-video-active="true"] .tile__media::before,
.tile--video[data-video-active="true"] .tile__media::after,
.feature__video[data-video-active="true"]::before,
.feature__video[data-video-active="true"]::after {
  opacity: 0;
  pointer-events: none;
}

.tile--video video {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

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

   Das Geruest baut js/site.js beim ersten Klick und haengt es an den body.
   Diese Regeln beschreiben genau das, was dort entsteht:

     <div class="lightbox" data-lightbox data-open="false" role="dialog" aria-modal="true">
       <div class="lightbox__backdrop" data-lightbox-backdrop></div>
       <div class="lightbox__frame">
         <button class="lightbox__close">…</button>
         <button class="lightbox__nav lightbox__nav--prev">…</button>
         <button class="lightbox__nav lightbox__nav--next">…</button>
         <div class="lightbox__stage"><img class="lightbox__img" …></div>
         <div class="lightbox__foot" aria-live="polite">
           <p class="lightbox__caption">…</p>
           <p class="lightbox__count">…</p>
         </div>
         <div class="lightbox__thumbs">
           <button class="lightbox__thumb" aria-current="true"><img …></button>
         </div>
       </div>
     </div>

   Der Zustand haengt an data-open, damit motion.js ein- und ausblenden kann,
   ohne dass ein display-Wechsel die Animation abschneidet.
   -------------------------------------------------------------------------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 120;
  display: grid;
  /* Die Zeile MUSS bemessen sein, nicht auto. Sonst haengt die ganze
     Hoehenrechnung darunter in der Luft: eine auto-Zeile waechst mit ihrem
     Inhalt, .lightbox__frame mit height: 100% misst sich an dieser gewachsenen
     Zeile, das minmax(0, 1fr) der Buehne loest sich am Inhalt auf, und
     max-height: 100% am Bild findet keinen bestimmten Bezug und wird
     verworfen. Das Bild stand dann in seiner natuerlichen Groesse da: bei
     lager1 (1280x1920) auf einem 1440x900-Schirm 1232x1848, also gut das
     Doppelte der Fensterhoehe, mit Bildunterschrift und Miniaturleiste
     unterhalb des Randes. Auf dem Telefon fiel es nicht auf, weil
     max-width: 100% das Bild dort schon auf rund 263px zieht und die Hoehe
     damit ohnehin unter die Fensterhoehe faellt. Gemeldet am 23.09.2026.
     minmax(0, 1fr) statt 100%: identisch in der Hoehe, aber die Null als
     Minimum erlaubt dem Inhalt auch zu schrumpfen. */
  grid-template-rows: minmax(0, 1fr);
  place-items: stretch;
  visibility: hidden;
  opacity: 0;
  overscroll-behavior: contain;
  transition: opacity 0.3s var(--ease), visibility 0s linear 0.3s;
}

.lightbox[data-open="true"] {
  visibility: visible;
  opacity: 1;
  transition: opacity 0.3s var(--ease), visibility 0s linear 0s;
}

.lightbox__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.9);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

.lightbox__frame {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto auto;
  /* Als Raster-Kind steht min-height implizit auf auto: der Rahmen duerfte
     nicht unter die Groesse seines Inhalts schrumpfen, und die Zeile oben
     waere umsonst bemessen. */
  min-height: 0;
  gap: 0.75rem;
  width: 100%;
  height: 100%;
  padding: clamp(1rem, 3vw, 2rem);
  padding-block-start: clamp(3.5rem, 8vw, 4.5rem);
  color: var(--paper);
}

/* Die seitliche Luft haelt das Bild von den beiden Blaetterknoepfen frei. Ohne
   sie liegt auf einem 375px breiten Schirm der halbe Knopf auf dem Foto. */
.lightbox__stage {
  display: grid;
  /* Beide Achsen bemessen, aus demselben Grund wie oben: eine auto-Spur
     waechst mit dem Bild, und dann laeuft das max-width/max-height am Bild
     gegen seine eigene Groesse. Mit minmax(0, 1fr) ist die Flaeche das
     Fenster minus Kopfpolster, Bildunterschrift und Miniaturleiste, und
     object-fit: contain legt das Bild hinein. */
  grid-template-rows: minmax(0, 1fr);
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  min-height: 0;
  padding-inline: 3.5rem;
}

@media (min-width: 800px) {
  .lightbox__stage {
    padding-inline: 4.5rem;
  }
}

.lightbox__img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--radius);
}

.lightbox__close {
  position: absolute;
  inset-block-start: clamp(0.75rem, 2.5vw, 1.5rem);
  inset-inline-end: clamp(0.75rem, 2.5vw, 1.5rem);
  z-index: 3;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--paper) 30%, transparent);
  color: var(--paper);
  font-size: 1.1rem;
  line-height: 1;
  transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.lightbox__close:hover {
  background: var(--accent);
  border-color: var(--accent);
}

.lightbox__nav {
  position: absolute;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--paper) 30%, transparent);
  color: var(--paper);
  font-size: 1.1rem;
  line-height: 1;
  transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.lightbox__nav:hover {
  background: var(--accent);
  border-color: var(--accent);
}

/* Beide Knoepfe stehen auf halber Hoehe am Rand, auf jeder Breite. Unten in
   den Ecken lagen sie auf der Miniaturleiste, und die eigene Bahn der Leiste
   ist wichtiger als die Naehe zum Daumen. */
.lightbox__nav {
  inset-block-start: 50%;
  margin-block-start: -1.5rem;
}

.lightbox__nav--prev {
  inset-inline-start: clamp(0.5rem, 2.5vw, 1.5rem);
}

.lightbox__nav--next {
  inset-inline-end: clamp(0.5rem, 2.5vw, 1.5rem);
}

.lightbox__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  padding-top: 0.75rem;
  border-top: 1px solid color-mix(in srgb, var(--paper) 18%, transparent);
}

.lightbox__caption {
  font-size: 0.9375rem;
  color: color-mix(in srgb, var(--paper) 85%, transparent);
  max-width: 52ch;
}

.lightbox__count {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  letter-spacing: 0.12em;
  color: color-mix(in srgb, var(--paper) 65%, transparent);
  font-variant-numeric: tabular-nums;
}

/* Miniaturleiste. Scrollt waagrecht in sich, damit auch zwanzig Bilder keine
   zweite Zeile und keinen Koerperscroll erzeugen. */
.lightbox__thumbs {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  padding-block: 0.35rem;
  padding-bottom: calc(0.35rem + env(safe-area-inset-bottom));
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--paper) 35%, transparent) transparent;
}

.lightbox__thumb {
  flex: 0 0 auto;
  width: 4.5rem;
  height: 3.25rem;
  overflow: hidden;
  border-radius: 0.5rem;
  border: 1px solid transparent;
  opacity: 0.5;
  transition: opacity var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.lightbox__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.lightbox__thumb:hover {
  opacity: 0.85;
}

.lightbox__thumb[aria-current="true"] {
  opacity: 1;
  border-color: var(--accent);
}

/* Die Sperre des Seitenscrolls setzt js/site.js selbst auf body.style.overflow.
   Eine Regel darauf gaebe es hier zweimal, und motion.js beobachtet ohnehin
   genau dieses style-Attribut, um Lenis anzuhalten. */


/* ==========================================================================
   10  Ueber mich
   ========================================================================== */

.bio {
  display: grid;
  gap: clamp(2rem, 5vw, 3.5rem);
}

@media (min-width: 900px) {
  .bio {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    align-items: start;
    gap: clamp(2.5rem, 6vw, 5rem);
  }
}

.bio__portrait {
  position: relative;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--paper-sunk);
}

.bio__portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.bio__text {
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}

.bio__text p {
  color: var(--ink-soft);
  max-width: 62ch;
}

.bio__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1rem;
}

/* --------------------------------------------------------------------------
   Werdegang. Eine senkrechte Haarlinie als Schiene, je Station ein
   Akzentpunkt darauf. Zeitraum in Mono wie die anderen Meta-Angaben der
   Seite. Seit 18.09.2026, weil die Seite eine Bewerbung ist und eine
   Personalabteilung diesen Abschnitt zuerst sucht.

   <ol> statt <ul>: die Stationen haben eine Reihenfolge, und eine
   Sprachausgabe nennt bei einer geordneten Liste die Position mit.
   -------------------------------------------------------------------------- */

.timeline {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: clamp(2rem, 4vw, 3rem);
  margin-block: clamp(2rem, 4vw, 3rem) clamp(2rem, 4vw, 2.75rem);
  padding-left: 1.75rem;
  max-width: 44rem;
}

/* Die Schiene. Ein Pseudoelement statt border-left, damit sie unabhaengig
   von der Polsterung genau unter den Punkten sitzt. */
.timeline::before {
  content: "";
  position: absolute;
  left: 0.3125rem;
  top: 0.5rem;
  bottom: 0.5rem;
  width: 1px;
  background: var(--hairline);
}

.timeline__item {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.timeline__item::before {
  content: "";
  position: absolute;
  left: -1.75rem;
  top: 0.45rem;
  width: 0.6875rem;
  height: 0.6875rem;
  border-radius: 999px;
  background: var(--accent);
  /* Ein papierfarbener Ring, damit der Punkt die Schiene sauber unterbricht,
     statt dass die Linie durch ihn hindurchlaeuft. */
  box-shadow: 0 0 0 3px var(--paper);
}

.timeline__when {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}

.timeline__what {
  margin: 0;
}

.timeline__item p:not(.timeline__when) {
  color: var(--ink-soft);
  max-width: 56ch;
}

/* --------------------------------------------------------------------------
   Faehigkeiten. auto-fit statt fester Spaltenzahl: vier Gruppen ergeben so
   von selbst 1, 2 und 4 Spalten, ohne drei Abfragen dafuer.
   -------------------------------------------------------------------------- */

.skills {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: clamp(2rem, 4vw, 3rem);
}

.skill-group {
  display: flex;
  flex-direction: column;
  gap: 1.35rem;
}

.skill-group__title {
  padding-bottom: 0.85rem;
  border-bottom: 1px solid var(--hairline);
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.skill {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

.skill__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}

.skill__name {
  font-size: 0.9375rem;
}

.skill__value {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}

/* Die Schiene ist eine Haarlinie wie jede andere Linie auf der Seite. */
.skill__bar {
  position: relative;
  height: 1px;
  background: var(--hairline);
}

/*
  Der Balken liegt 1px hoeher und ist 3px dick, sonst verschwindet die
  Akzentfarbe optisch in der Haarlinie darunter.

  Zwei Wege, und immer nur einer davon ist aktiv:

  Laeuft die Bewegungsschicht, zieht motion.js scaleX auf und die transition
  ist weiter oben (html.motion-on) abgeschaltet, sonst liefen Tween und
  Uebergang gegeneinander.

  Laeuft sie nicht (kein GSAP oder reduzierte Bewegung), setzt site.js
  --skill-scale direkt aus data-skill-value, und die transition hier traegt das
  Fuellen. Ohne JavaScript ueberhaupt bleibt der Balken leer, die Prozentzahl
  in .skill__value steht dann trotzdem da.
*/
.skill__fill {
  position: absolute;
  inset-block-start: -1px;
  inset-inline-start: 0;
  width: 100%;
  height: 3px;
  border-radius: 999px;
  background: var(--accent);
  transform: scaleX(var(--skill-scale, 0));
  transform-origin: 0 50%;
  transition: transform 1.1s var(--ease);
}

/* --------------------------------------------------------------------------
   Werkzeuge. Reine Typo-Chips mit Haarlinienrand. Die alte Fassung setzte je
   ein Emoji davor, was auf jedem Betriebssystem anders aussah und in keinem
   Fall zur Seite passte.
   -------------------------------------------------------------------------- */

.tools {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
}

.tool {
  display: inline-flex;
  align-items: center;
  padding: 0.55rem 1.05rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.tool:hover {
  border-color: var(--accent);
  color: var(--accent);
}


/* ==========================================================================
   11  Kontakt
   ========================================================================== */

.contact {
  display: grid;
  gap: clamp(2.5rem, 6vw, 4rem);
}

@media (min-width: 900px) {
  .contact {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    align-items: start;
    gap: clamp(3rem, 7vw, 6rem);
  }
}

.contact__info {
  display: flex;
  flex-direction: column;
  gap: 1.75rem;
}

.contact__detail {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  padding-top: 1rem;
  border-top: 1px solid var(--hairline);
}

.contact__detail .label {
  margin-bottom: 0.15rem;
}

/* Nur die direkten Kinder: die Sozial-Links stehen in einer Liste in derselben
   Kachel und tragen ihre eigene Farbe aus .social__link. Ohne das Zeichen fuer
   Kindschaft standen sie hier dunkler als dieselben Icons im Fuss. */
.contact__detail > a,
.contact__detail > span {
  font-size: 1.0625rem;
  color: var(--ink);
  transition: color var(--dur) var(--ease);
}

.contact__detail > a:hover {
  color: var(--accent);
}

.contact__form {
  display: grid;
  gap: 1.75rem;
}

@media (min-width: 620px) {
  .contact__form {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /* Nachricht, Knopf und Hinweis laufen ueber die volle Breite. */
  .contact__form > .field--wide,
  .contact__form > .form-note,
  .contact__form > .btn,
  .contact__form > .contact__submit {
    grid-column: 1 / -1;
  }
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.field label {
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.field input,
.field textarea {
  width: 100%;
  padding: 0.65rem 0 0.7rem;
  border: 0;
  border-bottom: 1px solid var(--hairline);
  border-radius: 0;
  background: transparent;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--ink);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}

.field textarea {
  min-height: 9rem;
  resize: vertical;
}

/* 75 statt 55 Prozent: der Platzhalter ist Text im Sinne von WCAG 1.4.3 und
   kam gedaempfter nur auf 2.6:1 gegen Papierweiss. Vom eingegebenen Text bleibt
   er trotzdem unterscheidbar, der steht in --ink. */
.field input::placeholder,
.field textarea::placeholder {
  color: color-mix(in srgb, var(--ink-soft) 75%, transparent);
}

/*
  Der Fokus faerbt die Linie und verdoppelt sie ueber einen inneren Schatten.
  Der uebliche Ring wird hier abgeschaltet, weil er um ein randloses Feld
  herum einen Kasten zeichnet, den es sonst nirgends gibt. Die Linie wechselt
  dabei Farbe und Dicke, der Fokus bleibt also deutlich sichtbar.
*/
.field input:focus-visible,
.field textarea:focus-visible {
  outline: none;
  border-bottom-color: var(--accent);
  box-shadow: inset 0 -1px 0 0 var(--accent);
}

/* Die Palette kennt keinen Fehlerton, und einer wuerde hier auch nur einmal
   gebraucht. Eine kraeftige dunkle Linie reicht als Hinweis, den Rest sagt die
   Meldung darunter. :user-invalid statt :invalid, damit ein leeres Feld nicht
   schon beim Laden auffaellt.

   Die Linie wird zusaetzlich dicker: waere nur die Farbe anders, truege die
   Unterscheidung allein die Farbe, und genau das verlangt WCAG 1.4.1 nicht. */
.field input:user-invalid,
.field textarea:user-invalid {
  border-bottom-color: var(--ink);
  box-shadow: inset 0 -2px 0 0 var(--ink);
}

/* Die Meldung, die js/site.js an das invalid-Ereignis haengt. Kleiner als der
   Eingabetext und im Gewicht abgesetzt: ohne eigene Regel las sie sich wie ein
   weiterer Wert im Feld darueber, sie stand in derselben Groesse und Farbe. */
.field-error {
  margin-top: 0.15rem;
  font-size: 0.8125rem;
  font-weight: 500;
  line-height: 1.45;
  color: var(--ink);
}

.contact__submit {
  justify-self: start;
}

.form-note {
  font-size: 0.8125rem;
  color: var(--ink-soft);
  max-width: 52ch;
}


/* ==========================================================================
   12  Abschlussband
   ========================================================================== */

.closing {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: var(--ink);
}

/* Die driftende Ebene ist in jede Richtung ein Zehntel groesser als der
   Abschnitt. Damit kann der Parallaxweg nie eine nackte Kante ins Bild
   ziehen. */
.closing__bg {
  position: absolute;
  inset: -10%;
  z-index: -2;
}

.closing__bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.closing::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: rgba(0, 0, 0, 0.6);
}

.closing__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  padding-block: clamp(5.5rem, 13vw, 9rem);
  text-align: center;
  color: var(--paper);
}

.closing__inner .title-lg {
  max-width: 18ch;
}

.closing__inner .lead {
  color: color-mix(in srgb, var(--paper) 80%, transparent);
  max-width: 44ch;
}

.closing__inner .btn--primary {
  margin-top: 1rem;
}

/* Zwei Knoepfe im Abschlussband (Kontakt und Lebenslauf), seit 20.09.2026.
   Der Geist-Knopf steht auf dunklem Foto, deshalb weisser Rand und weisse
   Schrift statt Haarlinie und Tinte. */
.closing__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem;
  margin-top: 1rem;
}

.closing__actions .btn--primary {
  margin-top: 0;
}

.closing__actions .btn--ghost {
  color: var(--paper);
  border-color: color-mix(in srgb, var(--paper) 55%, transparent);
}

.closing__actions .btn--ghost:hover {
  border-color: var(--paper);
}


/* ==========================================================================
   13  Fuss
   ========================================================================== */

.site-footer {
  border-top: 1px solid var(--hairline);
  padding-block: clamp(2.5rem, 6vw, 3.5rem);
}

.footer__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  text-align: center;
}

@media (min-width: 720px) {
  .footer__inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    text-align: left;
  }
}

.footer__brand {
  font-size: 1rem;
}

.footer__copy {
  font-size: 0.8125rem;
  color: var(--ink-soft);
}

.social {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.social__link {
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  color: var(--ink-soft);
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease),
    transform 0.3s var(--ease);
}

.social__link:hover {
  color: var(--accent);
  border-color: var(--accent);
  transform: translateY(-2px);
}


/* ==========================================================================
   14  Letzte Absicherung gegen waagrechtes Scrollen
   --------------------------------------------------------------------------
   Bei 375px Breite ist ein einzelnes zu breites Kind genug, um die ganze
   Seite seitlich verschiebbar zu machen. Die drei Regeln hier fangen die
   ueblichen Verursacher ab: lange Wortketten in Ueberschriften, ein Bild ohne
   max-width und eine Tabelle im Fliesstext.
   ========================================================================== */

.title-xl,
.title-lg,
.title-md {
  overflow-wrap: break-word;
}

.mobile-nav__foot a,
.contact__detail a {
  overflow-wrap: anywhere;
}
