Skip to content
All templates

Templates/Application Microsite

For anyone who applies with their own work: designers, developers, photographers, or a studio pitching for a role. The page is the work sample, so it has to hold up on its own. White and one very light grey carry everything, near black is the type, and a single fresh yellow green marks the current step and the things you can act on. The scroll is paced: an immersive stage, then a calm reading block, then the next one. Every pinned scene falls back to an ordinary stacked list on phones and under reduced motion, and the career line turns upright there instead of running sideways.

Portfolio & PersonalFull page

The finished page to scroll through, switchable between desktop, tablet and mobile.

Source code

The complete page as a single file. Copy it into ./components and add the component to your route.

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import {
  Blend,
  Briefcase,
  Building2,
  Check,
  ChevronDown,
  Clock,
  Code,
  Compass,
  Feather,
  Gauge,
  Glasses,
  GraduationCap,
  LayoutGrid,
  LineChart,
  MessageSquare,
  Palette,
  PenLine,
  Rocket,
  ShoppingBag,
  Sparkles,
  Stethoscope,
  Target,
  Trophy,
  User,
  type LucideIcon,
} from "lucide-react";

/* Braucht GSAP: npm i gsap

   Eine Bewerbungs-Microsite statt eines PDF. Die Seite ist selbst die
   Arbeitsprobe: wer sie bis unten liest, hat gesehen, was die Person kann,
   ohne dass ein Satz es behaupten muss.

   Erfundene Person, erfundene Firma, erfundene Projekte. Keine echten Namen,
   Adressen, Zertifikate oder Auszeichnungen, und keine Links nach aussen.

   Der Aufbau folgt dem Gespraech, das man sonst fuehren wuerde: wer bin ich,
   was kann ich in einer Person vereinen, was habe ich gebaut, warum bei
   euch, womit arbeite ich, wo komme ich her, wer bin ich ausserhalb. Zum
   Schluss eine Frage und zwei Wege.

   Die drei Teile, die sich ein Unternehmen ansieht, stehen oben: die Rollen
   als Akkordeon, die Arbeiten als Bahnen, die vier Beobachtungen. Der
   Werdegang kommt spaeter, weil er erklaert und nicht ueberzeugt.

   Was hier bewusst fehlt: ein Ton. Eine Seite, die von selbst Klang macht,
   traegt ein Geraeusch in jedes fremde Projekt, in dem diese Vorlage landet.
   Und ein eigenes weiches Scrollen: das gehoert der Seite, nicht einem
   Abschnitt darin. Zwei Bibliotheken, die gleichzeitig am Scrollrad drehen,
   ruckeln gegeneinander.

   Alle Stile bringt die Vorlage portabel selbst mit, gescopt auf
   .application-microsite. Bilder liegen unter /public/templates/
   application-microsite, Portraits unter /public/portraits. */

const BILD = "/templates/application-microsite/";

const scopedStyles = `
.application-microsite {
  --font-display: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
  --font-sans: "Figtree", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  --radius: 1rem;
  --paper: oklch(0.993 0.0025 95);
  --mist: oklch(0.957 0.004 95);
  --silver: oklch(0.8 0.005 95);
  --ink: oklch(0.21 0.005 270);
  --soft-ink: oklch(0.505 0.008 265);
  --background: var(--paper);
  --foreground: var(--ink);
  --primary: var(--ink);
  --primary-foreground: var(--paper);
  --muted: var(--mist);
  --muted-foreground: var(--soft-ink);
  --border: var(--silver);
  /* One spacing scale for the whole page. */
  --shell: min(calc(100% - 48px), 1240px);
  --space-section: 140px;
  --space-block: 60px;
  --space-tight: 26px;
  --gutter: 64px;
  --radius-media: 12px;
  --footer-h: min(86svh, 700px);
  --accent: oklch(0.894 0.182 123.3);
  --rule: 1px solid color-mix(in oklab, var(--silver) 45%, transparent);
}
.application-microsite, .application-microsite * {
  box-sizing: border-box;
}
.application-microsite {
  scroll-behavior: auto;
}
.application-microsite {
  margin: 0;
  /* clip und nicht hidden: hidden macht aus diesem Kasten einen
     Scroll-Container, und dann haengen alle sticky-Buehnen darin fest statt
     am Fenster. clip schneidet nur ab. */
  overflow-x: clip;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  letter-spacing: 0;
}
.application-microsite main {
  overflow-x: clip;
}
.application-microsite img {
  display: block;
  max-width: 100%;
}
.application-microsite a {
  color: inherit;
  text-decoration: none;
}
.application-microsite h1,
.application-microsite h2,
.application-microsite h3,
.application-microsite p,
.application-microsite figure,
.application-microsite figcaption,
.application-microsite dl,
.application-microsite dd {
  margin: 0;
}
.application-microsite ::selection {
  background: var(--accent);
  color: var(--ink);
}
/* ---------- Shared building blocks ---------- */
/* Sehr helle Spaltenlinien hinter der Seite. */
/* Die Sektionen liegen über dem Raster, der Seitenhintergrund darunter. */
.application-microsite .application-page > section {
  position: relative;
  z-index: 1;
}
/* Der Hero bleibt stehen, der Rest der Seite schiebt sich darüber. */
.application-microsite .application-page {
  position: relative;
  z-index: 1;
  background: var(--paper);
  border-bottom-left-radius: clamp(20px, 2.6vw, 34px);
  border-bottom-right-radius: clamp(20px, 2.6vw, 34px);
  margin-bottom: var(--footer-h);
}
.application-microsite .section-shell {
  width: var(--shell);
  margin-inline: auto;
}
.application-microsite .kicker {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px 5px;
  border-radius: 12px;
  background: color-mix(in oklab, var(--ink) 8%, transparent);
  color: var(--soft-ink);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.application-microsite .kicker svg {
  width: 13px;
  height: 13px;
}
.application-microsite .kicker--light {
  background: color-mix(in oklab, var(--paper) 13%, transparent);
  color: color-mix(in oklab, var(--paper) 72%, transparent);
}
.application-microsite .eyebrow {
  display: flex;
  align-items: center;
  gap: 14px;
  color: var(--soft-ink);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  font-family: var(--font-mono);
}
.application-microsite h1,
.application-microsite h2,
.application-microsite h3 {
  font-family: var(--font-display);
  font-weight: 300;
  letter-spacing: 0;
}
.application-microsite h1 span,
.application-microsite h2 span {
  color: var(--silver);
}
/* One headline + lead pattern, used by every section. */
.application-microsite .section-intro {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: start;
  column-gap: var(--gutter);
  row-gap: 0;
}
.application-microsite .section-intro h2 {
  margin-top: 22px;
  font-size: clamp(36px, 4.4vw, 68px);
  line-height: 1.02;
  letter-spacing: -0.015em;
}
/* Deutlich kleiner und ruhiger als die Headline, damit die Ebenen stimmen. */
.application-microsite .section-intro__side > p {
  max-width: 52ch;
  padding-top: 10px;
  color: var(--soft-ink);
  font-size: 14px;
  line-height: 1.75;
}
.application-microsite .section-intro__image {
  width: 100%;
  max-width: 320px;
  aspect-ratio: 4 / 3;
  margin-top: 26px;
  margin-left: auto;
  border-radius: var(--radius-media);
  object-fit: cover;
}
/* Buttons ruhen in normaler Größe und wachsen beim Hovern mit dem Icon mit. */
.application-microsite .text-link,
.application-microsite .closing__cta {
  display: inline-flex;
  align-items: center;
  padding: 14px 22px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--paper);
  font-size: 13px;
  font-weight: 500;
  line-height: 1.1;
  white-space: nowrap;
  transition:
    background 0.35s ease,
    color 0.35s ease;
}
.application-microsite .btn__icon {
  display: grid;
  place-items: center;
  width: 0;
  margin-left: 0;
  overflow: hidden;
  opacity: 0;
  transition:
    width 0.45s cubic-bezier(0.22, 1, 0.36, 1),
    margin-left 0.45s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.3s ease;
}
.application-microsite .btn__icon svg {
  width: 17px;
  height: 17px;
}
.application-microsite .btn__icon--up svg {
  rotate: 180deg;
}
.application-microsite .text-link:hover,
.application-microsite .text-link:focus-visible,
.application-microsite .closing__cta:hover,
.application-microsite .closing__cta:focus-visible {
  background: var(--accent);
  color: var(--ink);
}
.application-microsite .text-link:hover .btn__icon,
.application-microsite .text-link:focus-visible .btn__icon,
.application-microsite .closing__cta:hover .btn__icon,
.application-microsite .closing__cta:focus-visible .btn__icon {
  width: 17px;
  margin-left: 10px;
  opacity: 1;
}
.application-microsite .reveal {
  opacity: 0;
  transform: translateY(26px);
  transition:
    opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms),
    transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms);
}
.application-microsite .reveal.is-visible {
  opacity: 1;
  transform: none;
}
/* ---------- Intro + header ---------- */
.application-microsite .intro-curtain {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  background: var(--mist);
  animation: am-curtain-up 0.85s cubic-bezier(0.76, 0, 0.24, 1) 1.5s both;
}
.application-microsite .intro-curtain p {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  gap: 0.1em 0.3em;
  padding-inline: 20px;
  white-space: nowrap;
  font-family: var(--font-display);
  font-size: clamp(19px, 5.6vw, 70px);
  font-weight: 400;
  line-height: 1.04;
  text-align: center;
}
/* Jedes Wort kommt einzeln, unscharf und von unten. */
.application-microsite .intro-curtain span {
  opacity: 0;
  translate: 0 0.5em;
  filter: blur(22px);
  animation: am-intro-word 0.9s cubic-bezier(0.19, 1, 0.22, 1) forwards;
}
.application-microsite .intro-curtain span:nth-child(2) {
  animation-delay: 0.16s;
}
.application-microsite .intro-curtain span:nth-child(3) {
  animation-delay: 0.32s;
  color: var(--soft-ink);
}
.application-microsite .intro-curtain span:nth-child(4) {
  animation-delay: 0.48s;
  color: var(--soft-ink);
}
@keyframes am-intro-word {
  to {
    opacity: 1;
    translate: 0 0;
    filter: blur(0);
  }
}
@keyframes am-curtain-up {
  to {
    transform: translateY(-101%);
  }
}
/* Der obere Rand bleibt stehen und verwischt, was darunter durchläuft. */
.application-microsite .site-header {
  position: fixed;
  z-index: 30;
  top: 0;
  left: 0;
  right: 0;
  height: 96px;
  display: flex;
  justify-content: flex-end;
  align-items: flex-start;
  padding: 24px max(calc((100vw - var(--shell)) / 2), 24px) 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
/* Die Unschärfe liegt als eigene Schicht dahinter und läuft nach unten aus. */
.application-microsite .site-header::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  -webkit-backdrop-filter: blur(13px);
  backdrop-filter: blur(13px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 100%);
}
.application-microsite .site-header > * {
  pointer-events: auto;
}
.application-microsite .wordmark {
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 500;
}
.application-microsite .site-header nav {
  display: flex;
  gap: 4px;
}
/* Leiste oben: zeigt, an welcher Stelle der Seite man gerade ist. */
.application-microsite .section-rail {
  position: absolute;
  top: 24px;
  left: 50%;
  width: min(560px, 44vw);
  height: 30px;
  transform: translateX(-50%);
  overflow: hidden;
  pointer-events: none;
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 13%, #000 87%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0%, #000 13%, #000 87%, transparent 100%);
}
.application-microsite .section-rail__track {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  gap: 34px;
  white-space: nowrap;
  transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}
.application-microsite .section-rail__step {
  color: color-mix(in oklab, var(--silver) 85%, transparent);
  font-size: 10px;
  font-weight: 500;
  line-height: 30px;
  letter-spacing: 0.075em;
  text-transform: uppercase;
  transition: color 0.3s ease;
  text-shadow: 0 0 10px var(--paper);
  font-family: var(--font-mono);
}
.application-microsite .section-rail__step.is-active {
  color: var(--ink);
}
/* Ton-Schalter: vier Balken, die mitlaufen, solange der Ton an ist. */
/* ---------- Hero: ein großer Satz, mittig, ohne Kasten ---------- */
.application-microsite .hero-scene {
  position: relative;
  z-index: 1;
}
.application-microsite .hero-scene > .hero {
  position: sticky;
  top: 0;
  z-index: 0;
}
.application-microsite .hero {
  min-height: min(100svh, 900px);
  padding: 132px 0 64px;
  display: grid;
  place-items: center;
  background: var(--paper);
}
.application-microsite .hero__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  width: 100%;
}
/* Reihenfolge im Hero: erst die Headline, dann der Kicker, dann die Pillen.
   Die Verzoegerungen zaehlen ab dem Moment, in dem die Headline anlaeuft. */
.application-microsite .hero .eyebrow {
  color: var(--soft-ink);
  opacity: 0;
  translate: 0 10px;
}
.application-microsite .hero__inner:has(.intro-statement.is-visible) .eyebrow {
  animation: am-rise-in 0.7s cubic-bezier(0.22, 1, 0.36, 1) 0.85s forwards;
}
.application-microsite .hero .intro-statement {
  max-width: 100%;
  margin-top: 42px;
  font-size: clamp(30px, 4.9vw, 72px);
  font-weight: 400;
  line-height: 1.12;
  text-align: center;
}
.application-microsite .hero__facts {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
  margin-top: 48px;
}
.application-microsite .hero__facts span {
  display: inline-flex;
  align-items: center;
  opacity: 0;
  translate: 0 12px;
  gap: 9px;
  padding: 10px 17px 10px 13px;
  border: 1px solid color-mix(in oklab, var(--silver) 42%, transparent);
  border-radius: 999px;
  color: var(--soft-ink);
  font-size: 12px;
}
.application-microsite .hero__inner:has(.intro-statement.is-visible) .hero__facts span {
  animation: am-rise-in 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.application-microsite .hero__inner:has(.intro-statement.is-visible) .hero__facts span:nth-child(1) {
  animation-delay: 1s;
}
.application-microsite .hero__inner:has(.intro-statement.is-visible) .hero__facts span:nth-child(2) {
  animation-delay: 1.12s;
}
.application-microsite .hero__inner:has(.intro-statement.is-visible) .hero__facts span:nth-child(3) {
  animation-delay: 1.24s;
}
.application-microsite .hero__facts svg {
  width: 15px;
  height: 15px;
}
/* Strichstärke der Icons: feiner als der Standard, damit sie neben dem
   leichten Schriftschnitt nicht fett wirken. */
.application-microsite .kicker svg,
.application-microsite .observation__icon svg,
.application-microsite .chrono-stop__years svg,
.application-microsite .role__icon svg,
.application-microsite .hero__facts svg,
.application-microsite .btn__icon svg {
  stroke-width: 1.25;
}
/* ---------- Section rhythm ---------- */
.application-microsite .why,
.application-microsite .work,
.application-microsite .combination,
.application-microsite .timeline {
  padding-top: 56px;
  padding-bottom: var(--space-section);
}
/* Schiebt sich beim Scrollen über den stehenden Hero. */
.application-microsite .combination.section-shell {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: none;
  padding-inline: max(calc((100vw - var(--shell)) / 2), 24px);
  /* Mindestens eine Bildschirmhöhe, sonst löst sich der Hero aus seiner
     Position, bevor dieser Abschnitt ihn ganz verdeckt hat. */
  min-height: 100svh;
  padding-top: calc(var(--space-section) * 1.25);
  padding-bottom: var(--space-section);
  border-radius: clamp(20px, 2.6vw, 34px) clamp(20px, 2.6vw, 34px) 0 0;
  background: var(--mist);
}
/* Die Beobachtungen bilden zusammen mit den Werkzeugen einen hellen Block. */
.application-microsite .why.section-shell {
  width: 100%;
  max-width: none;
  padding-inline: max(calc((100vw - var(--shell)) / 2), 24px);
  padding-top: calc(var(--space-section) * 0.9);
  padding-bottom: var(--space-section);
  border-radius: clamp(20px, 2.6vw, 34px) clamp(20px, 2.6vw, 34px) 0 0;
  background: var(--mist);
}
.application-microsite .why .observation {
  background: var(--paper);
}
/* Reinweiss, damit sich der Werdegang vom Abschnitt davor abhebt. */
.application-microsite .timeline {
  background: #fff;
}
/* ---------- Beobachtungen: Text kommt erst beim Hovern ---------- */
.application-microsite .observations {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
  margin-top: var(--space-block);
}
.application-microsite .observation {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  min-height: 236px;
  padding: clamp(22px, 2.2vw, 30px);
  border-radius: 16px;
  background: var(--mist);
}
/* Kein Kasten um das Icon, es steht frei oben in der Karte. */
.application-microsite .observation__icon {
  margin-bottom: auto;
  color: var(--ink);
  transition:
    opacity 0.35s ease,
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.application-microsite .observation__icon svg {
  width: 26px;
  height: 26px;
}
/* Titel und Hovertext liegen übereinander, dadurch bleibt die Karte gleich groß. */
.application-microsite .observation__slot {
  display: grid;
  width: 100%;
}
.application-microsite .observation__slot > * {
  grid-area: 1 / 1;
  align-self: end;
}
.application-microsite .observation h3 {
  margin: 0;
  font-size: clamp(19px, 1.7vw, 23px);
  line-height: 1.16;
  transition:
    opacity 0.3s ease,
    translate 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.application-microsite .observation__more {
  /* Als Raster-Feld waere das span ohnehin ein Block. Sobald Titel und
     Text untereinander stehen, zaehlt der Abstand oben sonst nicht. */
  display: block;
  /* Eine Spur heller als der uebrige Nebentext, damit die Zeile klar
     unter der Ueberschrift haengt und nicht mit ihr konkurriert. */
  color: color-mix(in oklab, var(--soft-ink) 88%, var(--paper));
  font-size: 14px;
  line-height: 1.6;
  opacity: 0;
  translate: 0 8px;
  transition:
    opacity 0.35s ease,
    translate 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
@media (hover: hover) and (pointer: fine) {
  .application-microsite .observation:hover h3,
  .application-microsite .observation:focus-within h3 {
    opacity: 0;
    translate: 0 -8px;
  }
  .application-microsite .observation:hover .observation__more,
  .application-microsite .observation:focus-within .observation__more {
    opacity: 1;
    translate: 0 0;
  }
  .application-microsite .observation:hover .observation__icon,
  .application-microsite .observation:focus-within .observation__icon {
    opacity: 0;
    transform: translateY(-10px);
  }
}
/* Ohne Maus steht alles untereinander und dauerhaft sichtbar. */
@media (hover: none), (pointer: coarse) {
  .application-microsite .observation__slot {
    display: block;
  }
  .application-microsite .observation__slot > * {
    grid-area: auto;
  }
  .application-microsite .observation__more {
    margin-top: 20px;
    opacity: 1;
    translate: 0 0;
    transition: none;
  }
  .application-microsite .observation h3,
  .application-microsite .observation__icon {
    transition: none;
  }
}
/* ---------- 02 Arbeitsweise ---------- */
.application-microsite .steps {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 22px;
  margin-top: var(--space-block);
}
.application-microsite .step__icon {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: 12px;
  background: var(--mist);
  color: var(--ink);
}
.application-microsite .step__icon svg {
  width: 20px;
  height: 20px;
}
.application-microsite .step h3 {
  margin-top: 20px;
  font-size: 19px;
  line-height: 1.2;
}
.application-microsite .step p {
  margin-top: 9px;
  color: var(--soft-ink);
  font-size: 13px;
  line-height: 1.5;
}
.application-microsite .manifesto {
  margin-top: var(--space-block);
  padding: clamp(34px, 4.6vw, 58px);
  border-radius: var(--radius-media);
  background: var(--ink);
  color: var(--paper);
}
.application-microsite .manifesto p {
  max-width: 22ch;
  text-wrap: balance;
  font-family: var(--font-display);
  font-size: clamp(30px, 3.8vw, 52px);
  line-height: 1.06;
}
.application-microsite .manifesto span {
  color: var(--silver);
}
/* ---------- Tools: liegen erst auf der Headline und geben sie frei ---------- */
.application-microsite .toolfield {
  position: relative;
  height: 210svh;
  background: var(--mist);
}
.application-microsite .toolfield__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  display: grid;
  place-items: center;
  overflow: hidden;
  padding: 24px;
}
.application-microsite .toolfield__headline {
  position: relative;
  z-index: 1;
  width: min(460px, 76vw);
  text-align: center;
}
.application-microsite .toolfield__headline > span {
  display: inline-block;
  padding: 5px 10px 4px;
  border-radius: 12px;
  background: color-mix(in oklab, var(--ink) 8%, transparent);
  color: var(--soft-ink);
  font-size: 10px;
  font-weight: 500;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.075em;
}
/* Der Hinweis gilt nur für Finger, mit Maus wäre er falsch. */
.application-microsite .toolfield__hint {
  display: none;
}
@media (hover: none), (pointer: coarse) {
  .application-microsite .toolfield__hint {
    display: block;
    margin-top: 16px;
    color: var(--soft-ink);
    font-family: var(--font-mono);
    font-size: 10px;
    letter-spacing: 0.075em;
    text-transform: uppercase;
  }
}
.application-microsite .toolfield__headline h2 {
  margin-top: 18px;
  font-size: clamp(38px, 4.6vw, 64px);
  line-height: 1.02;
}
.application-microsite .toolfield__headline p {
  max-width: 40ch;
  margin: 20px auto 0;
  color: var(--soft-ink);
  font-size: 14px;
  line-height: 1.6;
}
/* Über der Headline, damit sie am Anfang verdeckt ist. */
.application-microsite .toolfield__items {
  position: absolute;
  inset: 0;
  z-index: 3;
}
.application-microsite .tool-chip {
  --chip-w: 252px;
  --chip-h: 148px;
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: var(--chip-z, 1);
  width: 82px;
  height: 82px;
  outline: none;
}
/* Der gehoverte Chip legt sich über seine Nachbarn. */
.application-microsite .tool-chip:hover,
.application-microsite .tool-chip:focus-visible,
.application-microsite .tool-chip:focus-within {
  z-index: 40;
}
.application-microsite .tool-chip__inner {
  position: absolute;
  top: 50%;
  left: 50%;
  display: grid;
  place-items: center;
  width: 82px;
  height: 82px;
  /* Halbe Groesse als Versatz: so waechst das Plaettchen aus seiner Mitte
     nach links und rechts gleich weit. translate bleibt fuers Schweben frei. */
  margin: -41px 0 0 -41px;
  overflow: hidden;
  /* Breiter, weiss transparenter Rahmen statt feiner Kante. Er hebt das
     Plaettchen von der Flaeche ab, ohne dass dafuer ein Schatten noetig ist. */
  border: 10px solid color-mix(in srgb, #fff 42%, transparent);
  border-radius: 20px;
  background: var(--paper);
  animation-name: am-chip-float;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
  transition:
    width 0.55s cubic-bezier(0.22, 1, 0.36, 1),
    height 0.55s cubic-bezier(0.22, 1, 0.36, 1),
    margin 0.55s cubic-bezier(0.22, 1, 0.36, 1),
    border-radius 0.55s cubic-bezier(0.22, 1, 0.36, 1),
    background 0.45s ease,
    border-color 0.45s ease;
}
/* Schweben läuft über translate, damit der Hover-Zoom nicht kollidiert. */
@keyframes am-chip-float {
  0%, 100% {
    translate: 0 0;
  }
  50% {
    translate: 0 -8px;
  }
}
/* Logo und Text liegen übereinander und blenden ineinander über. */
.application-microsite .tool-chip__mark {
  grid-area: 1 / 1;
}
.application-microsite .tool-chip__mark {
  display: grid;
  place-items: center;
  transition:
    opacity 0.18s ease,
    scale 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Kein fill hier: die Marken bringen ihre eigenen Farben und Verläufe mit. */
.application-microsite .tool-chip__mark svg {
  width: 30px;
  height: 30px;
}
.application-microsite .tool-chip__word {
  color: var(--ink);
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -0.02em;
}
.application-microsite .tool-chip__info {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--chip-w);
  height: var(--chip-h);
  margin: calc(var(--chip-h) / -2) 0 0 calc(var(--chip-w) / -2);
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 6px;
  padding-inline: 22px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.26s ease;
}
.application-microsite .tool-chip__info strong {
  color: var(--ink);
  font-family: var(--font-display);
  font-size: 17px;
  font-weight: 400;
  letter-spacing: -0.01em;
}
.application-microsite .tool-chip__info small {
  max-width: 26ch;
  margin-top: 3px;
  color: var(--soft-ink);
  font-size: 12px;
  line-height: 1.45;
  white-space: normal;
  text-align: center;
}
/* Nur im Kranz: Das Plaettchen klappt auf und tauscht Logo gegen Text.
   In der Handy-Liste steht ohnehin alles da, dort wuerde das Antippen die
   Zeile aus ihrer Position reissen und das Logo ausblenden. */
@media (min-width: 821px) {
  .application-microsite .tool-chip:hover .tool-chip__inner,
  .application-microsite .tool-chip:focus-visible .tool-chip__inner,
  .application-microsite .tool-chip:focus-within .tool-chip__inner {
    width: var(--chip-w);
    height: var(--chip-h);
    margin: calc(var(--chip-h) / -2) 0 0 calc(var(--chip-w) / -2);
    border-radius: 22px;
    border-color: color-mix(in srgb, #fff 58%, transparent);
  }
  .application-microsite .tool-chip:hover .tool-chip__mark,
  .application-microsite .tool-chip:focus-visible .tool-chip__mark,
  .application-microsite .tool-chip:focus-within .tool-chip__mark {
    opacity: 0;
    scale: 0.72;
  }
  .application-microsite .tool-chip:hover .tool-chip__info,
  .application-microsite .tool-chip:focus-visible .tool-chip__info,
  .application-microsite .tool-chip:focus-within .tool-chip__info {
    opacity: 1;
    transition-delay: 0.18s;
  }
}
/* ---------- 03 Arbeiten: Projekte ziehen versetzt durch eine stehende Bühne ---------- */
.application-microsite .work {
  padding-top: var(--space-section);
  padding-bottom: calc(var(--space-section) * 0.6);
}
.application-microsite .section-intro--center {
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  text-align: center;
}
.application-microsite .section-intro--center .section-intro__head,
.application-microsite .section-intro--center .section-intro__side {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.application-microsite .section-intro--center .section-intro__side {
  margin-top: 18px;
}
.application-microsite .section-intro--center .section-intro__side > p {
  max-width: 48ch;
  padding-top: 0;
}
.application-microsite .work-stage {
  /* Genug Strecke fuer vier gleichmaessig ueberlappende Projektbahnen. */
  height: 360svh;
  margin-top: calc(var(--space-block) * 0.4);
}
.application-microsite .work-stage__inner {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
}
.application-microsite .work-slide {
  position: absolute;
  inset: 0;
  display: grid;
  align-items: center;
  padding-inline: clamp(16px, 9vw, 190px);
  /* Die vier Bahnen liegen deckungsgleich uebereinander. Ohne das faengt
     die oberste alle Klicks ab und nur das letzte Projekt ist anklickbar. */
  pointer-events: none;
}
.application-microsite .work-slide__figure {
  pointer-events: auto;
}
.application-microsite .work-slide--left {
  justify-items: start;
}
.application-microsite .work-slide--right {
  justify-items: end;
}
.application-microsite .work-slide__figure {
  position: relative;
  display: block;
  width: min(62vw, 820px);
  will-change: transform;
}
/* Über dem Bild ersetzt der runde Hinweis den Mauszeiger. */
@media (hover: hover) and (pointer: fine) {
  .application-microsite .work-slide__figure,
  .application-microsite .work-slide__figure * {
    cursor: none;
  }
}
.application-microsite .work-slide__image {
  position: relative;
  display: block;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--mist);
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--silver) 30%, transparent);
}
.application-microsite .work-slide__image img {
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  object-position: center;
}
/* Kleiner Glasknopf, der unter der Maus mitläuft. */
.application-microsite .work-slide__cta {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2;
  width: 116px;
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 999px;
  background: color-mix(in oklab, var(--ink) 26%, transparent);
  -webkit-backdrop-filter: blur(12px) saturate(150%);
  backdrop-filter: blur(12px) saturate(150%);
  /* Eigene Ebene, sonst flackert der Filter über dem bewegten Bild. */
  will-change: opacity;
  transform: translateZ(0);
  color: var(--paper);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-align: center;
  opacity: 0;
  /* Die Position setzt WorkParallax pro Frame. */
  translate: 0 0;
  scale: 0.8;
  transition:
    opacity 0.28s ease,
    scale 0.4s cubic-bezier(0.22, 1, 0.36, 1);
  pointer-events: none;
}
.application-microsite .work-slide__figure:hover .work-slide__cta,
.application-microsite .work-slide__figure:focus-visible .work-slide__cta {
  opacity: 1;
  scale: 1;
}
.application-microsite .work-slide__card {
  position: absolute;
  left: clamp(-34px, -2vw, -18px);
  bottom: -30px;
  width: min(392px, 84%);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: 22px 24px;
  border-radius: 14px;
  /* Ohne Rahmen und eine Spur heller als der Rest. */
  background: color-mix(in oklab, var(--mist) 55%, var(--paper));
  will-change: transform;
}
/* Icon und Projektname stehen in einer Zeile. */
.application-microsite .work-slide__head {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.application-microsite .work-slide__icon {
  display: inline-grid;
  place-items: center;
  color: var(--soft-ink);
}
.application-microsite .work-slide__icon svg {
  width: 16px;
  height: 16px;
  stroke-width: 1.25;
}
.application-microsite .work-slide--right .work-slide__card {
  left: auto;
  right: clamp(-34px, -2vw, -18px);
  align-items: flex-start;
}
.application-microsite .work-slide__name {
  color: var(--soft-ink);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-family: var(--font-mono);
}
.application-microsite .work-slide__title {
  margin-top: 12px;
  font-family: var(--font-display);
  font-size: 20px;
  line-height: 1.16;
}
.application-microsite .work-slide__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 12px;
}
.application-microsite .work-slide__tags span {
  padding: 5px 10px 4px;
  border-radius: 999px;
  background: color-mix(in oklab, var(--ink) 7%, transparent);
  color: var(--soft-ink);
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.2;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
/* Was gebaut wurde, abgesetzt durch einen feinen Strich. */
.application-microsite .work-slide__note {
  display: block;
  margin-top: 16px;
  padding-top: 13px;
  border-top: 1px solid color-mix(in oklab, var(--silver) 28%, transparent);
  color: var(--soft-ink);
  font-size: 12px;
  line-height: 1.55;
}
.application-microsite .work-slide__figure:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 6px;
  border-radius: var(--radius-media);
}
.application-microsite .work__outro {
  display: flex;
  justify-content: center;
  margin-top: var(--space-block);
}
.application-microsite .work__outro > div {
  will-change: transform, filter;
}
/* ---------- 04 Meine Kombination: waagerechtes Akkordeon ---------- */
.application-microsite .roles {
  display: flex;
  gap: 8px;
  margin-top: var(--space-block);
}
/* Der Reveal-Wrapper muss die Flex-Verteilung übernehmen. */
.application-microsite .role-slot {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  transition:
    flex-grow 0.7s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms),
    transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms);
}
.application-microsite .role-slot:has(.role--active) {
  flex-grow: 2.3;
}
.application-microsite .role {
  position: relative;
  flex: 1 1 100%;
  min-width: 0;
  min-height: 430px;
  display: flex;
  align-items: flex-end;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 16px;
  outline: none;
  background: var(--mist);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: flex-grow 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Weiss auf dem hellgrauen Abschnitt. In derselben Farbe wie der Grund sieht
   man von den ruhenden Karten nur ihren Text. */
.application-microsite .role {
  background: var(--paper);
}
.application-microsite .role:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
.application-microsite .role__media {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.65s ease;
}
.application-microsite .role--active .role__media {
  opacity: 1;
}
.application-microsite .role__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: contrast(1.06) saturate(1.05);
  transform: scale(1.08);
  transition: transform 1.1s cubic-bezier(0.22, 1, 0.36, 1);
}
.application-microsite .role--active .role__media img {
  transform: scale(1);
}
/* Feines, hartes Korn über dem Bild. */
.application-microsite .role__grain {
  position: absolute;
  inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23g)' opacity='0.92'/%3E%3C/svg%3E");
  background-size: 128px 128px;
  opacity: 0.9;
  mix-blend-mode: overlay;
  pointer-events: none;
}
/* Leseflaeche unten links, damit der Text auf jedem Bild sitzt. */
.application-microsite .role__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to top,
    color-mix(in oklab, var(--paper) 94%, transparent) 0%,
    color-mix(in oklab, var(--paper) 78%, transparent) 19%,
    color-mix(in oklab, var(--paper) 26%, transparent) 44%,
    transparent 64%
  );
}
.application-microsite .role__body {
  position: relative;
  width: min(100%, 330px);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: clamp(22px, 2.2vw, 30px);
}
.application-microsite .role__icon {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: 12px;
  background: var(--paper);
  color: var(--ink);
  transition: background 0.4s ease;
}
.application-microsite .role__icon svg {
  width: 20px;
  height: 20px;
}
.application-microsite .role__title {
  margin-top: 20px;
  font-family: var(--font-display);
  font-size: clamp(24px, 2.4vw, 34px);
  line-height: 1.06;
}
.application-microsite .role__text {
  max-width: none;
  margin-top: 10px;
  color: var(--soft-ink);
  font-size: 14px;
  line-height: 1.55;
}
.application-microsite .combination__close {
  max-width: 24ch;
  margin-top: var(--space-block);
  text-wrap: balance;
  font-family: var(--font-display);
  font-size: clamp(26px, 3vw, 42px);
  line-height: 1.1;
}
.application-microsite .extra {
  display: grid;
  grid-template-columns: minmax(0, 0.34fr) minmax(0, 1fr);
  align-items: start;
  gap: var(--gutter);
  margin-top: var(--space-block);
  padding: clamp(26px, 2.8vw, 38px);
  border-radius: 16px;
  background: var(--mist);
}
.application-microsite .extra h3 {
  font-size: clamp(22px, 2.1vw, 30px);
  line-height: 1.12;
}
.application-microsite .extra p {
  max-width: 56ch;
  margin-top: 14px;
  color: var(--soft-ink);
  font-size: 15px;
  line-height: 1.65;
}
/* Satz mit mitlaufenden Bildchen, erscheint Wort für Wort. */
.application-microsite .intro-statement {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.12em 0.25em;
  margin-top: 22px;
  font-family: var(--font-display);
  font-size: clamp(30px, 4.3vw, 60px);
  line-height: 1.1;
}
/* Die globale h2-span-Regel faerbt sonst jedes Wort silbern. */
.application-microsite .intro-statement > span {
  color: var(--ink);
}
.application-microsite .intro-statement > span[data-tone="soft"] {
  color: var(--silver);
}
.application-microsite .intro-statement > .intro-statement__mobile-break {
  display: none;
}
.application-microsite .intro-statement > * {
  opacity: 0;
  transform: translateY(0.35em);
}
.application-microsite .intro-statement.is-visible > * {
  animation: am-intro-token 0.62s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  animation-delay: calc(var(--i) * 46ms);
}
.application-microsite .intro-statement.is-visible .intro-chip--card {
  animation-name: am-intro-token-card;
}
@keyframes am-intro-token-card {
  to {
    opacity: 1;
    transform: rotate(-4deg);
  }
}
.application-microsite .intro-chip--card {
  width: 1.52em;
  height: 0.96em;
  border-radius: 0.14em;
}
.application-microsite .intro-chip--card img {
  object-fit: contain;
}
@keyframes am-intro-token {
  to {
    opacity: 1;
    transform: none;
  }
}
.application-microsite .intro-chip {
  flex: 0 0 auto;
  display: inline-block;
  overflow: hidden;
  background: var(--mist);
}
.application-microsite .intro-chip img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.application-microsite .intro-chip--circle {
  width: 1.04em;
  height: 1.04em;
  border-radius: 999px;
}
/* ---------- 07 Werdegang: eine Linie läuft von links nach rechts ---------- */
.application-microsite .timeline {
  padding-top: 56px;
  padding-bottom: var(--space-section);
}
.application-microsite .chrono {
  height: 420svh;
  margin-top: 8px;
}
.application-microsite .chrono__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  display: flex;
  align-items: center;
  overflow: hidden;
}
.application-microsite .chrono__track {
  position: relative;
  display: flex;
  align-items: stretch;
  gap: clamp(90px, 15vw, 280px);
  height: min(60svh, 460px);
  margin: 0;
  padding: 0 max(calc(50vw - 300px), 24px) 0 max(calc(50vw - 170px), 24px);
  list-style: none;
  will-change: transform;
}
/* Die Linie liegt hinter den Stationen und läuft durch das ganze Gleis. */
.application-microsite .chrono__line {
  position: absolute;
  top: 50%;
  left: max(calc(50vw - 230px), 24px);
  right: 0;
  height: 1px;
  background: color-mix(in oklab, var(--silver) 40%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 4%, #000 96%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 4%, #000 96%, transparent);
}
.application-microsite .chrono__line-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 0;
  background: linear-gradient(
    90deg,
    color-mix(in oklab, var(--accent) 45%, transparent),
    var(--accent)
  );
}
.application-microsite .chrono-stop {
  position: relative;
  flex: 0 0 auto;
  width: min(76vw, 345px);
  display: grid;
  grid-template-rows: 1fr 1fr;
}
/* Der Punkt sitzt genau auf der Linie, der Text hängt daran. */
.application-microsite .chrono-stop__dot {
  position: absolute;
  top: 50%;
  left: 0;
  z-index: 1;
  width: 10px;
  height: 10px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 22%, transparent);
}
/* Der aktuelle Punkt pulsiert leise weiter. */
.application-microsite .chrono-stop__dot::after {
  content: "";
  position: absolute;
  inset: -5px;
  border-radius: 50%;
  border: 1px solid color-mix(in oklab, var(--accent) 75%, transparent);
  animation: am-chrono-pulse 2.4s ease-out infinite;
}
@keyframes am-chrono-pulse {
  0% {
    opacity: 0.7;
    transform: scale(0.7);
  }
  70%, 100% {
    opacity: 0;
    transform: scale(1.6);
  }
}
.application-microsite .chrono-stop__body {
  position: relative;
  padding-left: 18px;
}
.application-microsite .chrono-stop--up .chrono-stop__body {
  grid-row: 1;
  align-self: end;
  padding-bottom: 32px;
}
.application-microsite .chrono-stop--down .chrono-stop__body {
  grid-row: 2;
  align-self: start;
  padding-top: 32px;
}
/* Das Jahr steht als kleines Schild über der Station. */
.application-microsite .chrono-stop__years {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px 4px;
  border-radius: 12px;
  background: color-mix(in oklab, var(--ink) 8%, transparent);
  color: var(--soft-ink);
  font-size: 10px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.075em;
  text-transform: uppercase;
  font-family: var(--font-mono);
}
.application-microsite .chrono-stop__years svg {
  width: 13px;
  height: 13px;
}
.application-microsite .chrono-stop h3 {
  margin: 13px 0 0;
  font-size: clamp(18px, 1.55vw, 22px);
  line-height: 1.18;
  white-space: nowrap;
}
.application-microsite .chrono-stop__org {
  display: block;
  margin-top: 7px;
  color: var(--soft-ink);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-family: var(--font-mono);
}
.application-microsite .chrono-stop p {
  margin-top: 12px;
  max-width: 34ch;
  color: var(--soft-ink);
  font-size: 13.5px;
  line-height: 1.6;
}
/* ---------- Kurz privat ---------- */
.application-microsite .personal {
  padding-top: 56px;
  padding-bottom: var(--space-section);
}
/* Links steht die Headline und bleibt stehen, rechts laufen die Zeilen durch. */
.application-microsite .personal__split {
  display: grid;
  grid-template-columns: minmax(0, 0.82fr) minmax(0, 1fr);
  align-items: start;
  column-gap: var(--gutter);
}
.application-microsite .personal__head {
  position: sticky;
  top: 150px;
}
.application-microsite .personal__head h2 {
  margin-top: 22px;
  font-size: clamp(36px, 4.4vw, 68px);
  line-height: 1.02;
  letter-spacing: -0.015em;
}
/* Statt Kästen eine ruhige Liste mit feinen Linien. */
.application-microsite .personal__list {
  margin: 8px 0 0;
}
.application-microsite .personal-row {
  display: grid;
  grid-template-columns: minmax(0, 0.46fr) minmax(0, 1fr);
  align-items: baseline;
  gap: var(--gutter);
  padding: clamp(22px, 2.6vw, 34px) 4px;
  border-bottom: 1px solid color-mix(in oklab, var(--silver) 40%, transparent);
  text-align: left;
  transition: padding-left 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.application-microsite .personal-row:last-of-type {
  border-bottom: 0;
}
.application-microsite .personal-row:hover {
  padding-left: 14px;
}
/* Nummer, Punkt, Stichwort. Klein und in Mono wie ein Register im Magazin,
   ohne Icons, damit es nicht nach UI-Baustein aussieht. */
.application-microsite .personal-row dt {
  display: inline-flex;
  align-items: baseline;
  /* Ein Leerzeichen der Mono breit, so passt "03 · Nachteule" auch bei 360 px. */
  gap: 6px;
  margin: 0;
  color: var(--soft-ink);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.application-microsite .personal-row__num {
  color: var(--ink);
}
.application-microsite .personal-row dd {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(20px, 2vw, 30px);
  line-height: 1.16;
  letter-spacing: -0.01em;
}
/* ---------- 09 Kontakt: liegt fest hinten und wird freigescrollt ---------- */
.application-microsite .closing {
  position: fixed;
  inset: auto 0 0 0;
  z-index: 0;
  height: var(--footer-h);
  display: grid;
  place-items: center;
  background: var(--ink);
  color: var(--paper);
}
.application-microsite .closing__inner {
  position: relative;
  width: var(--shell);
  height: 100%;
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
}
.application-microsite .closing__lead {
  margin: 18px 0 0;
  color: color-mix(in oklab, var(--paper) 62%, transparent);
  font-size: 15px;
}
.application-microsite .closing h2 {
  margin: 0;
  max-width: 16ch;
  font-size: clamp(40px, 7.4vw, 108px);
  line-height: 0.94;
  letter-spacing: -0.02em;
}
.application-microsite .closing__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  margin-top: 38px;
}
.application-microsite .closing__cta {
  padding: 16px 26px;
  background: var(--paper);
  color: var(--ink);
  font-size: 14px;
  overflow: hidden;
}
.application-microsite .closing__cta--ghost {
  background: transparent;
  color: var(--paper);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--paper) 45%, transparent);
}
.application-microsite .closing__cta--ghost:hover,
.application-microsite .closing__cta--ghost:focus-visible {
  background: var(--accent);
  color: var(--ink);
  box-shadow: inset 0 0 0 1px transparent;
}
/* Buchstabe für Buchstabe: erst unscharf und tief, dann scharf. */
/* Die globale h2-span-Regel faerbt sonst jeden Buchstaben silbern. */
/* Ein Wort bleibt zusammen, der Abstand kommt aus dem Wortabstand. */
.application-microsite .blur-letters__word {
  display: inline-block;
  white-space: nowrap;
  /* Sonst faerbt die globale h2-span-Regel jedes Wort silbern. */
  color: inherit;
}
.application-microsite .blur-letters__word + .blur-letters__word {
  margin-left: 0.24em;
}
.application-microsite .blur-letters__char {
  display: inline-block;
  color: inherit;
}
.application-microsite .blur-letters__char {
  opacity: 0;
  translate: 0 0.7em;
  filter: blur(18px);
}
.application-microsite .blur-letters.is-visible .blur-letters__char {
  animation: am-letter-focus 0.85s cubic-bezier(0.19, 1, 0.22, 1) forwards;
  animation-delay: calc(var(--i) * 70ms);
}
/* Der Knopf kommt erst, wenn der Satz fertig geschrieben ist. */
.application-microsite .closing__actions {
  opacity: 0;
  translate: 0 16px;
}
.application-microsite .closing__inner:has(.blur-letters.is-visible) .closing__actions {
  animation: am-rise-in 0.7s cubic-bezier(0.22, 1, 0.36, 1) 1.5s forwards;
}
@keyframes am-rise-in {
  to {
    opacity: 1;
    translate: 0 0;
  }
}
@keyframes am-letter-focus {
  to {
    opacity: 1;
    translate: 0 0;
    filter: blur(0);
  }
}
.application-microsite .visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* Die Zeile ganz unten hält sich zurück. */
.application-microsite .closing__foot {
  position: absolute;
  bottom: 26px;
  left: 0;
  right: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px 18px;
  color: color-mix(in oklab, var(--paper) 30%, transparent);
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-family: var(--font-mono);
}
/* Instagram und LinkedIn im selben Ton wie die Rechtszeile. */
.application-microsite .closing__foot a {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  /* Etwas Luft nach oben und unten, damit der Daumen sie trifft. */
  padding: 7px 0;
  margin: -7px 0;
  transition: color 0.25s ease;
}
/* Eine Mailadresse in Grossbuchstaben liest sich schlecht. */
.application-microsite .closing__foot .is-mail {
  text-transform: none;
  letter-spacing: 0.02em;
}
.application-microsite .closing__foot a:hover,
.application-microsite .closing__foot a:focus-visible {
  color: var(--paper);
}
.application-microsite .closing__foot svg {
  width: 12px;
  height: 12px;
  fill: currentColor;
}
.application-microsite .closing__foot .is-stroked svg,
.application-microsite .closing__foot .is-stroked svg path {
  fill: none;
  stroke: currentColor;
}
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
  .application-microsite {
    --gutter: 40px;
    --space-section: 108px;
    --space-block: 50px;
  }
  .application-microsite .observations {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .application-microsite .steps {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 34px 24px;
  }
  .application-microsite .role {
    min-height: 360px;
  }
  .application-microsite .work-slide__figure {
    width: min(62vw, 540px);
  }
}
@media (max-width: 820px) {
  .application-microsite {
    --shell: calc(100% - 32px);
    --gutter: 30px;
    --space-section: 84px;
    --space-block: 40px;
  }
  /* Die Abschnitte hatten oben 44, 56, 76 und 84 Pixel. Jetzt ueberall
     derselbe Abstand, nur die Rollen behalten ihre Extraluft. */
  .application-microsite .why.section-shell,
  .application-microsite .work,
  .application-microsite .timeline,
  .application-microsite .personal {
    padding-top: var(--space-section);
    padding-bottom: var(--space-section);
  }
  .application-microsite .work {
    padding-bottom: calc(var(--space-section) * 0.75);
  }
  /* Der Kicker lief fast von Rand zu Rand und war zu fett. */
  .application-microsite .hero .eyebrow {
    font-size: 9.5px;
    font-weight: 400;
    letter-spacing: 0.06em;
  }
  /* Auf schmalen Bildschirmen steht der Text der Karten immer da, auch
     wenn eine Maus im Spiel ist. Zum Hovern ist hier kein Platz. */
  .application-microsite .observation__slot {
    display: block;
  }
  .application-microsite .observation__slot > * {
    grid-area: auto;
  }
  .application-microsite .observation__more {
    margin-top: 20px;
    opacity: 1;
    translate: 0 0;
    transition: none;
  }
  .application-microsite .observation:hover h3,
  .application-microsite .observation:focus-within h3 {
    opacity: 1;
    translate: 0 0;
  }
  .application-microsite .observation:hover .observation__icon,
  .application-microsite .observation:focus-within .observation__icon {
    opacity: 1;
    transform: none;
  }
  .application-microsite .closing__cta {
    justify-content: center;
  }
  /* ---- Werdegang steht auf dem Handy senkrecht ---- */
  .application-microsite .chrono {
    /* Das Gleis läuft sonst bis an den Bildschirmrand, die Linie wäre weg. */
    width: var(--shell);
    height: auto;
    margin: var(--space-block) auto 0;
  }
  .application-microsite .chrono__stage {
    position: static;
    display: block;
    height: auto;
    overflow: visible;
  }
  .application-microsite .chrono__track {
    flex-direction: column;
    gap: 0;
    height: auto;
    padding: 4px 0 4px 30px;
    transform: none !important;
  }
  .application-microsite .chrono__line {
    top: 0;
    bottom: 0;
    left: 4px;
    right: auto;
    width: 1px;
    height: auto;
    opacity: 1 !important;
    -webkit-mask-image: linear-gradient(180deg, transparent, #000 5%, #000 95%, transparent);
    mask-image: linear-gradient(180deg, transparent, #000 5%, #000 95%, transparent);
  }
  .application-microsite .chrono__line-fill {
    inset: 0 0 auto 0;
    width: 100% !important;
    height: 0;
  }
  .application-microsite .chrono-stop {
    display: block;
    width: 100%;
    grid-template-rows: none;
  }
  .application-microsite .chrono-stop__dot {
    top: 30px;
    left: -26px;
    margin: 0 0 0 -4px;
  }
  .application-microsite .chrono-stop--up .chrono-stop__body,
  .application-microsite .chrono-stop--down .chrono-stop__body {
    grid-row: auto;
    align-self: auto;
    padding: 0 0 46px;
  }
  .application-microsite .chrono-stop h3 {
    white-space: normal;
  }
  .application-microsite .chrono-stop p {
    max-width: none;
  }
  /* ---- Projekte ---- */
  /* Kein Durchlauf durch eine stehende Buehne, die Projekte stehen
     einfach untereinander. */
  .application-microsite .work-stage {
    height: auto;
    margin-top: var(--space-block);
  }
  .application-microsite .work-stage__inner {
    position: static;
    height: auto;
    overflow: visible;
    display: grid;
    gap: var(--space-block);
  }
  .application-microsite .work-slide {
    position: relative;
    inset: auto;
    transform: none !important;
  }
  .application-microsite .work-slide__figure {
    transform: none !important;
  }
  /* Ohne Maus gibt es nichts zu verfolgen, der Hinweis steht in der Karte. */
  .application-microsite .work-slide__cta {
    display: none;
  }
  /* Die Karte lag ueber dem Bild und hat es komplett verdeckt. */
  .application-microsite .work-slide__card,
  .application-microsite .work-slide--right .work-slide__card {
    position: relative;
    left: auto;
    right: auto;
    bottom: auto;
    width: auto;
    margin-top: 16px;
    padding: 0;
    background: none;
    box-shadow: none;
  }
  /* Ein unterstrichener Textlink statt eines Knopfes. */
  .application-microsite .work-slide__card::after {
    content: "View project";
    margin-top: 16px;
    color: var(--ink);
    font-size: 12.5px;
    font-weight: 500;
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-decoration-color: color-mix(in oklab, var(--ink) 38%, transparent);
    text-underline-offset: 5px;
  }
  /* Der negative Rand gilt der stehenden Buehne am Desktop. Hier stehen
     die Projekte untereinander, der Knopf gehoert dicht ans letzte. */
  .application-microsite .work__outro {
    margin-top: 28px;
  }
  .application-microsite .work__outro .text-link {
    padding: 13px 20px;
  }
  /* Auf schmalen Bildschirmen stehen Headline und Zeilen untereinander. */
  .application-microsite .personal__split {
    grid-template-columns: minmax(0, 1fr);
    row-gap: 30px;
  }
  .application-microsite .personal__head {
    position: static;
  }
  .application-microsite .personal__list {
    margin-top: 0;
  }
  .application-microsite .handoff__chain li + li::before {
    margin: 0 7px;
  }
  .application-microsite .site-header {
    height: 74px;
    padding-top: 14px;
  }
  .application-microsite .section-rail {
    display: none;
  }
  .application-microsite .hero {
    min-height: min(100svh, 680px);
    padding: 104px 0 48px;
  }
  .application-microsite .hero .intro-statement {
    max-width: none;
    margin-top: 26px;
    font-size: clamp(29px, 8.4vw, 42px);
    column-gap: 0.18em;
  }
  .application-microsite .hero .intro-statement > .intro-statement__mobile-break {
    display: block;
    flex-basis: 100%;
    height: 0;
  }
  /* Die Pillen standen fast so gross da wie der Satz darueber. Jetzt
     deutlich kleiner, damit die Reihenfolge im Blick stimmt. */
  .application-microsite .hero__facts {
    margin-top: 30px;
    gap: 8px;
  }
  .application-microsite .hero__facts span {
    gap: 7px;
    padding: 7px 12px 7px 10px;
    font-size: 10.5px;
  }
  .application-microsite .hero__facts svg {
    width: 13px;
    height: 13px;
  }
  /* Headline und Nebenzeile gehoeren zusammen, der Luftraum kommt
     danach vor dem eigentlichen Inhalt. */
  .application-microsite .section-intro {
    grid-template-columns: 1fr;
    gap: 16px;
  }
  .application-microsite .section-intro__side > p {
    padding-top: 0;
  }
  .application-microsite .section-intro--center .section-intro__side {
    margin-top: 0;
  }
  .application-microsite .section-intro h2 {
    margin-top: 16px;
    font-size: clamp(30px, 8.4vw, 40px);
  }
  .application-microsite .section-intro__image {
    aspect-ratio: 16 / 10;
    margin-top: 20px;
  }
  .application-microsite .observations {
    grid-template-columns: 1fr;
    gap: 10px;
  }
  .application-microsite .observation {
    min-height: 0;
  }
  .application-microsite .observation__icon {
    margin-bottom: 20px;
  }
  .application-microsite .steps {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 32px 22px;
  }
  .application-microsite .step h3 {
    margin-top: 16px;
    font-size: 18px;
  }
  .application-microsite .manifesto p {
    max-width: none;
  }
  .application-microsite .work-slide {
    justify-items: center;
    padding-inline: 16px;
  }
  .application-microsite .work-slide--left,
  .application-microsite .work-slide--right {
    justify-items: center;
  }
  .application-microsite .work-slide__figure {
    width: min(90vw, 460px);
  }
  .application-microsite .work-slide__title {
    font-size: 18px;
  }
  .application-microsite .roles {
    flex-direction: column;
    gap: 10px;
  }
  .application-microsite .role-slot,
  .application-microsite .role-slot:has(.role--active) {
    display: block;
    flex: none;
  }
  .application-microsite .role,
  .application-microsite .role--active {
    flex: none;
    width: 100%;
    min-height: 260px;
    transition: none;
  }
  /* Ohne Hover zeigt jede Karte ihr Bild dauerhaft. */
  .application-microsite .role__media {
    opacity: 1;
  }
  .application-microsite .role__media img {
    transform: none;
  }
  .application-microsite .role__icon {
    width: 40px;
    height: 40px;
  }
  .application-microsite .extra {
    grid-template-columns: 1fr;
    gap: 16px;
  }
  .application-microsite .combination__close {
    max-width: none;
  }
  .application-microsite .intro-statement {
    max-width: none;
  }
  /* ---- Werkzeuge als Liste ---- */
  .application-microsite .toolfield {
    height: auto;
    padding: var(--space-section) 0;
  }
  .application-microsite .toolfield__stage {
    position: static;
    display: block;
    height: auto;
    padding: 0;
    overflow: visible;
  }
  .application-microsite .toolfield__headline {
    width: var(--shell);
    margin-inline: auto;
    text-align: center;
  }
  .application-microsite .toolfield__headline h2 {
    margin-top: 0;
    font-size: clamp(30px, 8.4vw, 40px);
  }
  .application-microsite .toolfield__headline p:not(.toolfield__hint) {
    display: none;
  }
  /* In der Liste steht alles schon da, der Tipp-Hinweis ist ueberfluessig. */
  .application-microsite .toolfield__hint {
    display: none;
  }
  .application-microsite .toolfield__items {
    position: static;
    width: var(--shell);
    margin: var(--space-block) auto 0;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .application-microsite .tool-chip {
    position: static;
    z-index: auto;
    width: 100%;
    height: auto;
    transform: none !important;
  }
  .application-microsite .tool-chip__inner {
    position: static;
    width: 100%;
    height: auto;
    margin: 0;
    display: grid;
    grid-template-columns: 34px minmax(0, 1fr);
    /* place-items: center aus der Grundregel wuerde kurze Zeilen mittig
       in ihre Spalte setzen, dadurch springt der Text nach rechts. */
    align-items: start;
    justify-items: stretch;
    gap: 14px;
    padding: 16px;
    border-radius: 18px;
    animation: none;
    transition: none;
  }
  .application-microsite .tool-chip__mark {
    grid-area: auto;
    justify-self: center;
    padding-top: 2px;
  }
  .application-microsite .tool-chip__mark svg {
    width: 24px;
    height: 24px;
  }
  .application-microsite .tool-chip__info {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    padding: 0;
    gap: 3px;
    justify-items: start;
    text-align: left;
    opacity: 1;
    transition: none;
  }
  .application-microsite .tool-chip__info small {
    max-width: none;
    text-align: left;
  }
  .application-microsite .tool-chip__word {
    font-size: 11px;
  }
  .application-microsite .tool-chip__info strong {
    font-size: 13px;
  }
  .application-microsite .tool-chip__info em {
    font-size: 10px;
  }
  .application-microsite .chrono-stop p {
    font-size: 13px;
  }
  /* Untereinander und nur so breit wie die laengere Beschriftung, nicht
     ueber die ganze Spalte gezogen. */
  .application-microsite .closing__actions {
    flex-direction: column;
    align-items: stretch;
    width: auto;
    gap: 10px;
  }
  .application-microsite .closing h2 {
    font-size: clamp(34px, 10.5vw, 58px);
    margin: 0;
  }
  .application-microsite .closing__foot {
    margin-top: 32px;
  }
}
@media (prefers-reduced-motion: reduce) {
  .application-microsite {
    scroll-behavior: auto;
  }
  .application-microsite .intro-curtain {
    display: none;
  }
  .application-microsite .closing__actions,
  .application-microsite .hero .eyebrow,
  .application-microsite .hero__facts span {
    opacity: 1;
    translate: 0 0;
    animation: none;
  }
    .application-microsite .chrono-stop__dot::after {
    animation: none;
    opacity: 0;
  }
  .application-microsite .tool-chip__inner {
    animation: none;
  }
  .application-microsite .chrono {
    height: auto;
  }
  .application-microsite .chrono__stage {
    position: static;
    height: auto;
    overflow-x: auto;
  }
  .application-microsite .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .application-microsite .role,
  .application-microsite .role__media,
  .application-microsite .role__media img {
    transition: none;
  }
  .application-microsite .role__media img {
    transform: none;
  }
  .application-microsite .work-stage {
    height: auto;
  }
  .application-microsite .work-stage__inner {
    position: static;
    height: auto;
    overflow: visible;
    display: grid;
    gap: var(--space-block);
  }
  .application-microsite .work-slide {
    position: relative;
    inset: auto;
  }
  .application-microsite .work-slide__card,
  .application-microsite .work-slide--right .work-slide__card {
    position: relative;
    left: auto;
    right: auto;
    bottom: auto;
    width: auto;
    margin-top: 14px;
    padding: 0;
  }
  /* Ohne Bewegung sitzt der Hinweis fest in der Bildecke. */
  .application-microsite .work-slide__cta {
    left: auto;
    right: 12px;
    top: auto;
    bottom: 12px;
    width: auto;
    aspect-ratio: auto;
    padding: 9px 14px;
    border-radius: 999px;
    translate: none;
    transform: none;
    scale: 1;
    opacity: 1;
    font-size: 11px;
    transition: none;
  }
  /* Titel und Text stehen untereinander, sonst liegen sie aufeinander. */
  .application-microsite .observation__slot {
    display: block;
  }
  .application-microsite .observation__slot > * {
    grid-area: auto;
  }
  .application-microsite .observation__more {
    opacity: 1;
    translate: 0 0;
    margin-top: 20px;
    transition: none;
  }
  .application-microsite .observation h3 {
    opacity: 1;
    translate: 0 0;
  }
  .application-microsite .observation__icon {
    opacity: 1;
    transform: none;
    transition: none;
  }
  .application-microsite .intro-statement > *,
  .application-microsite .intro-statement.is-visible > * {
    opacity: 1;
    transform: none;
    animation: none;
  }
}
`;

/* ---------------------------------------------------------------------- */
/* Bausteine                                                               */
/* ---------------------------------------------------------------------- */

/** Blendet den Inhalt ein, sobald er ins Bild kommt. Mit `index` kommen
    mehrere Karten nacheinander statt alle auf einmal. */
function Reveal({
  children,
  className = "",
  index = 0,
  early = false,
}: {
  children: ReactNode;
  className?: string;
  index?: number;
  early?: boolean;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [sichtbar, setSichtbar] = useState(false);

  useEffect(() => {
    const knoten = ref.current;
    if (!knoten) return;
    const beobachter = new IntersectionObserver(
      ([eintrag]) => {
        if (eintrag?.isIntersecting) {
          setSichtbar(true);
          beobachter.disconnect();
        }
      },
      { rootMargin: early ? "0px 0px 22% 0px" : "0px 0px -12% 0px", threshold: 0.01 },
    );
    beobachter.observe(knoten);
    return () => beobachter.disconnect();
  }, [early]);

  return (
    <div
      ref={ref}
      className={`reveal ${sichtbar ? "is-visible" : ""} ${className}`}
      style={
        index ? ({ ["--reveal-delay" as string]: `${index * 110}ms` } as CSSProperties) : undefined
      }
    >
      {children}
    </div>
  );
}

/** Schreibt einen Satz Buchstabe fuer Buchstabe: erst unscharf und tief,
    dann scharf an seinem Platz. */
function BlurLetters({ text, className = "" }: { text: string; className?: string }) {
  const ref = useRef<HTMLHeadingElement>(null);
  const [sichtbar, setSichtbar] = useState(false);

  useEffect(() => {
    const knoten = ref.current;
    if (!knoten) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setSichtbar(true);
      return;
    }
    const beobachter = new IntersectionObserver(
      ([eintrag]) => {
        if (eintrag?.isIntersecting) {
          beobachter.disconnect();
          setSichtbar(true);
        }
      },
      { threshold: 0.4 },
    );
    beobachter.observe(knoten);
    return () => beobachter.disconnect();
  }, []);

  // Die Buchstaben stecken in Woertern, sonst bricht die Zeile mitten im Wort.
  let laufend = 0;
  return (
    <h2 ref={ref} className={`blur-letters${sichtbar ? " is-visible" : ""} ${className}`.trim()}>
      <span className="visually-hidden">{text}</span>
      {text.split(" ").map((wort, wortNr) => (
        <span className="blur-letters__word" key={wortNr} aria-hidden="true">
          {[...wort].map((zeichen, nr) => {
            const verzug = laufend;
            laufend += 1;
            return (
              <span
                className="blur-letters__char"
                key={nr}
                style={{ ["--i" as string]: verzug } as CSSProperties}
              >
                {zeichen}
              </span>
            );
          })}
        </span>
      ))}
    </h2>
  );
}

/** Zeichnet jedes Strich-Icon der Seite ein, sobald es in den Blick kommt. */
function useDrawnIcons() {
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const beobachter = new IntersectionObserver(
      (eintraege) => {
        for (const eintrag of eintraege) {
          if (!eintrag.isIntersecting) continue;
          beobachter.unobserve(eintrag.target);

          const formen = eintrag.target.querySelectorAll<SVGGeometryElement>(
            "path, line, polyline, polygon, circle, rect, ellipse",
          );
          formen.forEach((form, nr) => {
            const strich = getComputedStyle(form).stroke;
            if (!strich || strich === "none") return;
            let laenge = 0;
            try {
              laenge = form.getTotalLength();
            } catch {
              return;
            }
            if (!laenge || laenge > 4000) return;

            form.style.strokeDasharray = String(laenge);
            form.style.strokeDashoffset = String(laenge);
            form.style.transition = `stroke-dashoffset 0.85s cubic-bezier(0.22, 1, 0.36, 1) ${nr * 65}ms`;
            requestAnimationFrame(() => {
              requestAnimationFrame(() => {
                form.style.strokeDashoffset = "0";
              });
            });
          });
        }
      },
      { threshold: 0.35 },
    );

    const icons = document.querySelectorAll<SVGSVGElement>("[data-draw-icons] svg");
    icons.forEach((icon) => beobachter.observe(icon));

    return () => beobachter.disconnect();
  }, []);
}

/** Der Hero bleibt beim Scrollen stehen und tritt nur leise zurueck,
    waehrend der Abschnitt darunter sich darueber schiebt. */
function useHeroParallax() {
  useEffect(() => {
    const hero = document.querySelector<HTMLElement>(".application-microsite .hero__inner");
    const naechster = document.querySelector<HTMLElement>(".application-microsite .combination");
    if (!hero || !naechster) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    gsap.registerPlugin(ScrollTrigger);

    const tween = gsap.to(hero, {
      opacity: 0.4,
      scale: 0.97,
      ease: "none",
      scrollTrigger: {
        trigger: naechster,
        start: "top bottom",
        end: "top top",
        scrub: 0.8,
        invalidateOnRefresh: true,
      },
    });

    return () => {
      tween.scrollTrigger?.kill();
      tween.kill();
      gsap.set(hero, { clearProps: "transform,opacity" });
    };
  }, []);
}

/* ---------------------------------------------------------------------- */
/* Kopfzeile: zeigt, an welcher Stelle der Seite man gerade ist            */
/* ---------------------------------------------------------------------- */

function SectionRail({ steps }: { steps: { id: string; label: string }[] }) {
  const leiste = useRef<HTMLDivElement>(null);
  const gleis = useRef<HTMLDivElement>(null);
  const [aktiv, setAktiv] = useState(0);

  useEffect(() => {
    const leisteKnoten = leiste.current;
    const gleisKnoten = gleis.current;
    if (!leisteKnoten || !gleisKnoten) return;

    gsap.registerPlugin(ScrollTrigger);

    /** Fuer jeden Abschnitt merken wir uns, wo er anfaengt und aufhoert. */
    let marken: { start: number; ende: number; mitte: number }[] = [];

    const messen = () => {
      const punkte = [...gleisKnoten.children] as HTMLElement[];
      marken = steps.map((schritt, nr) => {
        const knoten = document.getElementById(schritt.id);
        const punkt = punkte[nr];
        const hoehe = knoten?.offsetHeight ?? 0;
        /* Der Schlussabschnitt steht fest am unteren Rand. Sein Rechteck sagt
           deshalb nicht, wo er im Dokument liegt: es waere immer knapp unter
           dem Sichtfeld. Gezaehlt wird der Moment, in dem die Seite ihn zur
           Haelfte freigibt. */
        const fest = knoten ? getComputedStyle(knoten).position === "fixed" : false;
        const oben = !knoten
          ? 0
          : fest
            ? document.documentElement.scrollHeight - hoehe - window.innerHeight * 0.5
            : knoten.getBoundingClientRect().top + window.scrollY;
        return {
          start: oben,
          ende: oben + hoehe,
          mitte: punkt ? punkt.offsetLeft + punkt.offsetWidth / 2 : 0,
        };
      });
    };

    const setzen = () => {
      if (!marken.length) return;
      // Der Punkt, der entscheidet, in welchem Abschnitt wir stecken.
      const sonde = window.scrollY + window.innerHeight * 0.4;
      let nr = 0;
      while (nr < marken.length - 1 && sonde >= (marken[nr + 1]?.start ?? Infinity)) nr += 1;

      const jetzt = marken[nr];
      const naechst = marken[nr + 1];
      if (!jetzt) return;
      const spanne = Math.max(1, jetzt.ende - jetzt.start);
      const drin = Math.min(1, Math.max(0, (sonde - jetzt.start) / spanne));
      // Zwischen zwei Namen wird weich ueberblendet, nicht gesprungen.
      const mitte = naechst ? jetzt.mitte + (naechst.mitte - jetzt.mitte) * drin : jetzt.mitte;

      gleisKnoten.style.transform = `translateX(${leisteKnoten.offsetWidth / 2 - mitte}px)`;
      // Ueber dem dunklen Schluss waere der Kopfbereich nicht zu lesen.
      const fuss = document.querySelector<HTMLElement>(".application-microsite .closing");
      const verdeckt = fuss
        ? window.scrollY + 60 > document.body.scrollHeight - fuss.offsetHeight
        : false;
      const balken = leisteKnoten.closest<HTMLElement>(".site-header") ?? leisteKnoten;
      balken.style.opacity = verdeckt ? "0" : "1";
      // Ueber dem ersten gelisteten Abschnitt leuchtet noch nichts.
      setAktiv(sonde < (marken[0]?.start ?? 0) ? -1 : nr);
    };

    messen();
    setzen();

    const ausloeser = ScrollTrigger.create({
      start: 0,
      end: "max",
      onUpdate: setzen,
      onRefresh: () => {
        messen();
        setzen();
      },
    });

    void document.fonts?.ready.then(() => {
      messen();
      setzen();
    });

    return () => ausloeser.kill();
  }, [steps]);

  return (
    <div className="section-rail" ref={leiste} aria-hidden="true">
      <div className="section-rail__track" ref={gleis}>
        {steps.map((schritt, nr) => (
          <span
            className={`section-rail__step${nr === aktiv ? " is-active" : ""}`}
            key={schritt.id}
          >
            {schritt.label}
          </span>
        ))}
      </div>
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Hero: ein Satz auf einer Zeile, mit zwei Bildern mittendrin             */
/* ---------------------------------------------------------------------- */

type Token =
  | { art: "wort"; wert: string; ton?: "soft" }
  | { art: "chip"; src: string; form: "circle" | "card" }
  | { art: "umbruch" };

const satz: Token[] = [
  { art: "wort", wert: "Hi," },
  { art: "wort", wert: "I'm" },
  { art: "chip", src: "/portraits/portrait-morning-light.webp", form: "circle" },
  { art: "wort", wert: "Hannah." },
  { art: "umbruch" },
  { art: "wort", wert: "I", ton: "soft" },
  { art: "wort", wert: "build", ton: "soft" },
  { art: "wort", wert: "websites", ton: "soft" },
  { art: "chip", src: `${BILD}hero-laptop.webp`, form: "card" },
  { art: "wort", wert: "that", ton: "soft" },
  { art: "wort", wert: "people", ton: "soft" },
  { art: "wort", wert: "remember.", ton: "soft" },
];

function IntroStatement({ startDelay = 0 }: { startDelay?: number }) {
  const ref = useRef<HTMLHeadingElement>(null);
  const [sichtbar, setSichtbar] = useState(false);

  useEffect(() => {
    const knoten = ref.current;
    if (!knoten) return;
    let uhr = 0;
    const beobachter = new IntersectionObserver(
      ([eintrag]) => {
        if (eintrag?.isIntersecting) {
          beobachter.disconnect();
          // Im Hero erst starten, wenn der Vorhang oben ist.
          if (startDelay > 0) uhr = window.setTimeout(() => setSichtbar(true), startDelay);
          else setSichtbar(true);
        }
      },
      { rootMargin: "0px 0px -15% 0px", threshold: 0.2 },
    );
    beobachter.observe(knoten);
    return () => {
      beobachter.disconnect();
      window.clearTimeout(uhr);
    };
  }, [startDelay]);

  return (
    <h2 ref={ref} className={`intro-statement${sichtbar ? " is-visible" : ""}`}>
      {satz.map((token, nr) => {
        if (token.art === "umbruch")
          return <span key={nr} className="intro-statement__mobile-break" aria-hidden="true" />;
        if (token.art === "chip")
          return (
            <span
              key={nr}
              className={`intro-chip intro-chip--${token.form}`}
              style={{ ["--i" as string]: nr } as CSSProperties}
              aria-hidden="true"
            >
              <img src={token.src} alt="" loading="eager" draggable={false} />
            </span>
          );
        return (
          <span key={nr} data-tone={token.ton} style={{ ["--i" as string]: nr } as CSSProperties}>
            {token.wert}
          </span>
        );
      })}
    </h2>
  );
}

/* ---------------------------------------------------------------------- */
/* Drei Rollen in einer Person: waagerechtes Akkordeon                     */
/* ---------------------------------------------------------------------- */

const rollen = [
  {
    titel: "Designer",
    text: "Type, layout, UI and the visual direction.",
    icon: Palette,
    bild: `${BILD}role-design.webp`,
  },
  {
    titel: "Builder",
    text: "I build the site myself afterwards.",
    icon: Code,
    bild: `${BILD}role-build.webp`,
  },
  {
    titel: "Founder",
    text: "Clients, budgets, decisions and launches.",
    icon: Briefcase,
    bild: `${BILD}role-studio.webp`,
  },
];

function RoleAccordion() {
  const [aktiv, setAktiv] = useState(0);

  return (
    <div className="roles">
      {rollen.map((rolle, nr) => {
        const Icon = rolle.icon;
        return (
          <Reveal className="role-slot" key={rolle.titel} index={nr}>
            <button
              type="button"
              className={`role${nr === aktiv ? " role--active" : ""}`}
              aria-expanded={nr === aktiv}
              onMouseEnter={() => setAktiv(nr)}
              onFocus={() => setAktiv(nr)}
              onClick={() => setAktiv(nr)}
            >
              <span className="role__media" aria-hidden="true">
                <img src={rolle.bild} alt="" loading="lazy" draggable={false} />
                <span className="role__grain" />
                <span className="role__scrim" />
              </span>
              <span className="role__body">
                <span className="role__icon">
                  <Icon aria-hidden="true" />
                </span>
                <span className="role__title">{rolle.titel}</span>
                <span className="role__text">{rolle.text}</span>
              </span>
            </button>
          </Reveal>
        );
      })}
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Arbeiten: jedes Projekt auf einer eigenen Bahn durch eine stehende      */
/* Buehne                                                                  */
/* ---------------------------------------------------------------------- */

type Projekt = {
  name: string;
  titel: string;
  rollen: string;
  notiz: string;
  bild: string;
  icon: LucideIcon;
};

const bahnen = [
  { seite: "left" as const, versatz: 0.12 },
  { seite: "right" as const, versatz: 0.08 },
  { seite: "left" as const, versatz: 0.14 },
  { seite: "right" as const, versatz: 0.1 },
];

function WorkParallax({ projekte }: { projekte: Projekt[] }) {
  const buehne = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const abschnitt = buehne.current;
    if (!abschnitt) return;
    // Auf kleinen Schirmen stehen die Projekte einfach untereinander.
    if (window.matchMedia("(prefers-reduced-motion: reduce), (max-width: 820px)").matches) return;

    gsap.registerPlugin(ScrollTrigger);

    const raum = gsap.context(() => {
      const folien = gsap.utils.toArray<HTMLElement>(abschnitt.querySelectorAll(".work-slide"));
      if (!folien.length) return;

      const einheit = () => window.innerHeight;
      const schritt = 0.78;
      const dauer = 1.32;

      /* Der Weg liegt fast ganz an den Enden: in den ersten und letzten 28
         Prozent der Zeit legt eine Bahn 88 Prozent ihrer Strecke zurueck,
         dazwischen zieht sie nur noch langsam weiter. Ein Projekt kommt also
         zuegig herein, steht die halbe Zeit fast still und geht zuegig
         wieder. Gleichmaessig verteilt steht dagegen nie eines in der Mitte:
         dann haengt immer eines halb oben und das naechste halb unten, und
         dazwischen ist das Bild leer. */
      const ruhig = (p: number) =>
        p < 0.28
          ? (p / 0.28) * 0.44
          : p < 0.72
            ? 0.44 + ((p - 0.28) / 0.44) * 0.12
            : 0.56 + ((p - 0.72) / 0.28) * 0.44;

      const zeitachse = gsap.timeline({
        scrollTrigger: {
          trigger: abschnitt,
          start: "top top",
          end: "bottom bottom",
          scrub: 0.65,
          invalidateOnRefresh: true,
        },
      });

      folien.forEach((folie, nr) => {
        const bahn = bahnen[nr % bahnen.length]!;
        const bild = folie.querySelector("[data-work-figure]");
        const karte = folie.querySelector("[data-work-card]");
        const bei = nr * schritt;
        /* Das letzte Projekt faehrt nicht mehr oben hinaus, es kommt in der
           Mitte an und bleibt dort. Sonst ist die Buehne auf ihrem letzten
           Bildschirm leer: sie steht dann noch, hat aber nichts mehr zu
           zeigen. So traegt das letzte Projekt bis zum Schluss und geht
           danach mit der Seite nach oben weg. */
        const letzte = nr === folien.length - 1;
        const lauf = letzte ? dauer * 0.5 : dauer;

        zeitachse.fromTo(
          bild,
          { y: () => einheit() * (nr === 0 ? 0.12 : 0.82) },
          {
            y: letzte ? 0 : () => -einheit() * 0.82,
            ease: letzte ? "power2.out" : ruhig,
            duration: lauf,
          },
          bei,
        );

        /* Auf dem Handy steht die Karte unter dem Bild. Ein eigener Versatz
           wuerde sie davon loesen, deshalb laeuft sie dort einfach mit. */
        if (window.innerWidth > 820) {
          zeitachse.fromTo(
            karte,
            { y: () => einheit() * bahn.versatz },
            {
              y: letzte ? 0 : () => -einheit() * bahn.versatz,
              ease: letzte ? "power2.out" : ruhig,
              duration: lauf,
            },
            bei,
          );
        }
      });
    }, abschnitt);

    void document.fonts?.ready.then(() => ScrollTrigger.refresh());

    return () => raum.revert();
  }, []);

  /* Der runde Hinweis laeuft unter der Maus mit, bleibt aber im Bild. Er
     folgt pro Bild weich nach und rechnet die Position aus der letzten
     Mausposition neu, auch wenn sich nur das Bild unter ihr bewegt. */
  useEffect(() => {
    const abschnitt = buehne.current;
    if (!abschnitt) return;
    if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const radius = 58;
    let aktiv: { bild: HTMLElement; knopf: HTMLElement } | null = null;
    let zeigerX = 0;
    let zeigerY = 0;
    let x = 0;
    let y = 0;

    const ziel = (bild: HTMLElement) => {
      const kasten = bild.getBoundingClientRect();
      return [
        Math.min(Math.max(zeigerX - kasten.left, radius), kasten.width - radius),
        Math.min(Math.max(zeigerY - kasten.top, radius), kasten.height - radius),
      ] as const;
    };
    const setzen = (knopf: HTMLElement) => {
      knopf.style.translate = `${x - radius}px ${y - radius}px`;
    };

    let hatZeiger = false;
    let schmutzig = false;
    const beiBewegung = (e: PointerEvent) => {
      zeigerX = e.clientX;
      zeigerY = e.clientY;
      hatZeiger = true;
      schmutzig = true;
    };
    const beiScroll = () => {
      schmutzig = true;
    };

    const takt = () => {
      if (schmutzig && hatZeiger) {
        schmutzig = false;
        const bild = document
          .elementFromPoint(zeigerX, zeigerY)
          ?.closest<HTMLElement>("[data-work-figure]");
        const knopf = bild?.querySelector<HTMLElement>(".work-slide__cta");
        if (!bild || !knopf) aktiv = null;
        else if (aktiv?.bild !== bild) {
          // Neu drauf: sofort unter die Maus, nicht aus der Ecke heranfliegen.
          aktiv = { bild, knopf };
          [x, y] = ziel(bild);
          setzen(knopf);
        }
      }
      if (!aktiv) return;
      const [zx, zy] = ziel(aktiv.bild);
      x += (zx - x) * 0.28;
      y += (zy - y) * 0.28;
      setzen(aktiv.knopf);
    };

    window.addEventListener("pointermove", beiBewegung, { passive: true });
    window.addEventListener("scroll", beiScroll, { passive: true });
    gsap.ticker.add(takt);
    return () => {
      window.removeEventListener("pointermove", beiBewegung);
      window.removeEventListener("scroll", beiScroll);
      gsap.ticker.remove(takt);
    };
  }, []);

  return (
    <div className="work-stage" ref={buehne}>
      <div className="work-stage__inner">
        {projekte.map((projekt, nr) => {
          const Icon = projekt.icon;
          return (
            <div
              className={`work-slide work-slide--${bahnen[nr % bahnen.length]!.seite}`}
              key={projekt.name}
            >
              <a className="work-slide__figure" data-work-figure href="#arbeiten">
                <span className="work-slide__image">
                  <img
                    src={projekt.bild}
                    alt={`Project image for ${projekt.name}`}
                    loading="lazy"
                    decoding="async"
                  />
                  <span className="work-slide__cta" aria-hidden="true">
                    View project
                  </span>
                </span>
                <span className="work-slide__card" data-work-card>
                  <span className="work-slide__head">
                    <span className="work-slide__icon">
                      <Icon aria-hidden="true" />
                    </span>
                    <span className="work-slide__name">{projekt.name}</span>
                  </span>
                  <span className="work-slide__title">{projekt.titel}</span>
                  <span className="work-slide__tags">
                    {projekt.rollen.split(" · ").map((tag) => (
                      <span key={tag}>{tag}</span>
                    ))}
                  </span>
                  <span className="work-slide__note">{projekt.notiz}</span>
                </span>
              </a>
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Vier Beobachtungen: Karten, die am Scrollstand haengen                  */
/* ---------------------------------------------------------------------- */

function ObservationCards({
  punkte,
  icons,
}: {
  punkte: { titel: string; text: string }[];
  icons: LucideIcon[];
}) {
  const wurzel = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const raster = wurzel.current;
    if (!raster) return;
    const karten = gsap.utils.toArray<HTMLElement>(raster.querySelectorAll(".observation"));
    if (!karten.length) return;

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      gsap.set(karten, { opacity: 1, y: 0 });
      return;
    }

    gsap.registerPlugin(ScrollTrigger);

    const raum = gsap.context(() => {
      /* Nebeneinander teilen sich die Karten eine Strecke, untereinander
         bekommt jede ihre eigene, sonst sind sie fertig, bevor man sie
         sieht. */
      if (window.innerWidth > 820) {
        /* Der Startzustand muss fuer alle vier sofort stehen. Bei einem
           fromTo mit stagger springt sonst jede Karte erst auf unsichtbar,
           wenn sie an der Reihe ist, und das sieht man. */
        gsap.set(karten, { y: 90, opacity: 0 });
        gsap.to(karten, {
          y: 0,
          opacity: 1,
          ease: "none",
          stagger: 0.3,
          scrollTrigger: {
            trigger: raster,
            start: "top bottom-=5%",
            end: "top 40%",
            scrub: 0.6,
            invalidateOnRefresh: true,
          },
        });
        return;
      }

      for (const karte of karten) {
        gsap.fromTo(
          karte,
          { y: 60, opacity: 0 },
          {
            y: 0,
            opacity: 1,
            ease: "none",
            scrollTrigger: {
              trigger: karte,
              start: "top bottom-=4%",
              end: "top 62%",
              scrub: 0.6,
              invalidateOnRefresh: true,
            },
          },
        );
      }
    }, raster);

    void document.fonts?.ready.then(() => ScrollTrigger.refresh());

    return () => raum.revert();
  }, []);

  return (
    <div className="observations" ref={wurzel}>
      {punkte.map((punkt, nr) => {
        const Icon = icons[nr] ?? icons[0]!;
        return (
          <div className="observation" key={punkt.titel}>
            <span className="observation__icon">
              <Icon aria-hidden="true" />
            </span>
            <span className="observation__slot">
              <h3>{punkt.titel}</h3>
              <span className="observation__more">{punkt.text}</span>
            </span>
          </div>
        );
      })}
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Werkzeuge: ein Kranz, der die Ueberschrift freigibt                     */
/* ---------------------------------------------------------------------- */

type Werkzeug = {
  name: string;
  zweck: string;
  mehr: string;
  winkel: number;
  ring: number;
  kurz: string;
};

/* Die Werkzeuge stehen als Wortmarke im Plaettchen und nicht als Logo.
   Ein Logo ist ein fremdes Markenzeichen: es wandert mit der Vorlage in jedes
   Projekt, in dem sie landet, und es braucht eine Bibliothek dafuer. Ein Name
   ist in zwei Sekunden ausgetauscht, und genau das soll hier jeder tun, der
   die Seite uebernimmt. */
const werkzeuge: Werkzeug[] = [
  {
    name: "Code assistant",
    kurz: "Code",
    zweck: "Build",
    mehr: "Builds components and whole pages together with me.",
    winkel: 20,
    ring: 1,
  },
  {
    name: "Design tool",
    kurz: "Design",
    zweck: "Design",
    mehr: "Where grids, type and every state take shape.",
    winkel: 65,
    ring: 1.18,
  },
  {
    name: "Editor",
    kurz: "Editor",
    zweck: "Development",
    mehr: "An editor with a model alongside. Saves half the typing.",
    winkel: 110,
    ring: 1,
  },
  {
    name: "Shop system",
    kurz: "Shop",
    zweck: "Online shops",
    mehr: "Shops, payments and the whole way to the order.",
    winkel: 155,
    ring: 1.2,
  },
  {
    name: "Image model",
    kurz: "Image",
    zweck: "Image worlds",
    mehr: "Image worlds and moodboards when a stock photo will not do.",
    winkel: 200,
    ring: 1,
  },
  {
    name: "Research model",
    kurz: "Research",
    zweck: "Research",
    mehr: "Quick variants and material that would otherwise take too long.",
    winkel: 245,
    ring: 1.16,
  },
  {
    name: "Prototyper",
    kurz: "Proto",
    zweck: "New to me",
    mehr: "You use it, I barely have so far. It works like the others, so I pick it up fast.",
    winkel: 290,
    ring: 1,
  },
  {
    name: "Site builder",
    kurz: "Build",
    zweck: "Websites & products",
    mehr: "From idea to a clickable product in one afternoon.",
    winkel: 335,
    ring: 1.18,
  },
];

function ToolField() {
  const wurzel = useRef<HTMLElement>(null);
  const text = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const abschnitt = wurzel.current;
    const ueberschrift = text.current;
    if (!abschnitt || !ueberschrift) return;

    gsap.registerPlugin(ScrollTrigger);
    const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    /* Auf dem Handy stehen die Werkzeuge als Liste untereinander. Der Kranz
       braucht Platz in beide Richtungen, den gibt es dort nicht. */
    if (window.matchMedia("(max-width: 820px)").matches) {
      if (ruhig) return;
      const liste = gsap.context(() => {
        const zeilen = gsap.utils.toArray<HTMLElement>(
          abschnitt.querySelectorAll(".tool-chip__inner"),
        );

        gsap.fromTo(
          ueberschrift,
          { y: 22, opacity: 0 },
          {
            y: 0,
            opacity: 1,
            duration: 0.7,
            ease: "power2.out",
            scrollTrigger: { trigger: ueberschrift, start: "top bottom-=12%" },
          },
        );

        gsap.set(zeilen, { y: 24, opacity: 0 });
        ScrollTrigger.batch(zeilen, {
          start: "top bottom-=6%",
          onEnter: (stapel) =>
            gsap.to(stapel, {
              y: 0,
              opacity: 1,
              duration: 0.62,
              stagger: 0.07,
              ease: "power2.out",
              overwrite: true,
            }),
        });
      }, abschnitt);

      void document.fonts?.ready.then(() => ScrollTrigger.refresh());
      return () => liste.revert();
    }

    const raum = gsap.context(() => {
      const plaettchen = gsap.utils.toArray<HTMLElement>(abschnitt.querySelectorAll("[data-tool]"));

      /** Ruheposition: weit genug raus, dass die Ueberschrift frei bleibt. */
      const ruheplatz = (chip: HTMLElement) => {
        const winkel = Number(chip.dataset["angle"] ?? 0);
        const ring = Number(chip.dataset["ring"] ?? 1);
        const bogen = (winkel * Math.PI) / 180;
        const dx = Math.sin(bogen);
        const dy = -Math.cos(bogen);
        const luft = window.innerWidth <= 820 ? 26 : 64;

        const abstaende: number[] = [];
        if (Math.abs(dx) > 0.001)
          abstaende.push(
            (ueberschrift.offsetWidth / 2 + chip.offsetWidth / 2 + luft) / Math.abs(dx),
          );
        if (Math.abs(dy) > 0.001)
          abstaende.push(
            (ueberschrift.offsetHeight / 2 + chip.offsetHeight / 2 + luft) / Math.abs(dy),
          );
        const noetig = abstaende.length ? Math.min(...abstaende) : 0;

        const maxX = Math.max(0, (window.innerWidth - chip.offsetWidth) / 2 - 14);
        const maxY = Math.max(0, (window.innerHeight - chip.offsetHeight) / 2 - 20);
        const grenzen: number[] = [];
        if (Math.abs(dx) > 0.001) grenzen.push(maxX / Math.abs(dx));
        if (Math.abs(dy) > 0.001) grenzen.push(maxY / Math.abs(dy));
        const erlaubt = grenzen.length ? Math.min(...grenzen) : noetig;

        const weite = Math.min(Math.max(noetig * ring, noetig), Math.max(erlaubt, noetig));
        return { x: dx * weite, y: dy * weite };
      };

      gsap.set(plaettchen, { xPercent: -50, yPercent: -50 });

      if (ruhig) {
        for (const chip of plaettchen) gsap.set(chip, { ...ruheplatz(chip), scale: 1, opacity: 1 });
        gsap.set(ueberschrift, { opacity: 1, scale: 1, filter: "none" });
        return;
      }

      /* Startpunkt: alle Plaettchen liegen als Stapel genau in der Mitte, nur
         minimal versetzt, damit man die Kanten darunter erkennt. */
      const startplatz = (nr: number) => {
        const drift = (nr - (plaettchen.length - 1) / 2) * 1.6;
        return { x: drift, y: drift * 0.8 };
      };

      const zeitachse = gsap.timeline({
        scrollTrigger: {
          trigger: abschnitt,
          // Die Plaettchen gehen schon auseinander, waehrend der Abschnitt
          // hereinkommt.
          start: "top bottom",
          // Etwas vor Schluss fertig, damit der Ruhezustand kurz steht.
          end: "bottom bottom-=22%",
          scrub: 0.6,
          invalidateOnRefresh: true,
        },
      });

      plaettchen.forEach((chip, nr) => {
        const versatz = ((Number(chip.dataset["angle"] ?? 0) * 7) % 100) / 100;
        zeitachse.fromTo(
          chip,
          { x: startplatz(nr).x, y: startplatz(nr).y, opacity: 1 },
          {
            x: () => ruheplatz(chip).x,
            y: () => ruheplatz(chip).y,
            // Ganze Pixel, sonst stehen die Woerter auf halben und wirken unscharf.
            modifiers: { x: gsap.utils.unitize(Math.round), y: gsap.utils.unitize(Math.round) },
            ease: "power2.out",
            duration: 0.6 + versatz * 0.55,
          },
          versatz * 0.4,
        );
      });

      // Die Ueberschrift kommt aus dem Hintergrund nach vorn, waehrend sie frei wird.
      zeitachse.fromTo(
        ueberschrift,
        { opacity: 0, scale: 0.7, filter: "blur(28px)" },
        { opacity: 1, scale: 1, filter: "blur(0px)", ease: "power2.out", duration: 0.95 },
        0.08,
      );
    }, abschnitt);

    void document.fonts?.ready.then(() => ScrollTrigger.refresh());

    return () => raum.revert();
  }, []);

  return (
    <section ref={wurzel} id="tools" className="toolfield" aria-label="Tools">
      <div className="toolfield__stage">
        <div ref={text} className="toolfield__headline">
          <h2>
            What I
            <br />
            work with
          </h2>
          {/* Ohne Maus merkt sonst niemand, dass hinter den Plaettchen etwas steckt. */}
          <p className="toolfield__hint">Tap for details</p>
        </div>

        <div className="toolfield__items">
          {werkzeuge.map((werkzeug, nr) => (
            <div
              className="tool-chip"
              data-tool
              data-angle={werkzeug.winkel}
              data-ring={werkzeug.ring}
              key={werkzeug.name}
              tabIndex={0}
              style={{ ["--chip-z" as string]: werkzeuge.length - nr } as CSSProperties}
              aria-label={`${werkzeug.name}, ${werkzeug.zweck}`}
            >
              <span
                className="tool-chip__inner"
                style={{
                  animationDuration: `${5 + (nr % 4) * 0.9}s`,
                  animationDelay: `${(nr % 5) * 0.4}s`,
                }}
              >
                <span className="tool-chip__mark">
                  <span className="tool-chip__word">{werkzeug.kurz}</span>
                </span>
                <span className="tool-chip__info" aria-hidden="true">
                  <strong>{werkzeug.name}</strong>
                  <em>{werkzeug.zweck}</em>
                  <small>{werkzeug.mehr}</small>
                </span>
              </span>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ---------------------------------------------------------------------- */
/* Werdegang: eine Linie laeuft waagerecht durch das Bild                   */
/* ---------------------------------------------------------------------- */

type Station = {
  jahre: string;
  titel: string;
  haus: string;
  text: string;
  icon: LucideIcon;
};

function ChronoRail({ stationen }: { stationen: Station[] }) {
  const wurzel = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const abschnitt = wurzel.current;
    if (!abschnitt) return;

    const gleis = abschnitt.querySelector<HTMLElement>(".chrono__track");
    const fuellung = abschnitt.querySelector<HTMLElement>(".chrono__line-fill");
    if (!gleis || !fuellung) return;

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      fuellung.style.width = "100%";
      return;
    }

    gsap.registerPlugin(ScrollTrigger);

    /* Auf dem Handy steht die Leiste senkrecht. Ein waagerechtes Gleis liest
       sich mit dem Daumen nicht: die Stationen waeren staendig angeschnitten.
       Die Linie waechst dort von oben nach unten. */
    if (window.matchMedia("(max-width: 820px)").matches) {
      const senkrecht = gsap.context(() => {
        gsap.fromTo(
          fuellung,
          { height: "0%" },
          {
            height: "100%",
            ease: "none",
            scrollTrigger: {
              trigger: gleis,
              start: "top 72%",
              end: "bottom 78%",
              scrub: 0.5,
              invalidateOnRefresh: true,
            },
          },
        );

        for (const halt of gsap.utils.toArray<HTMLElement>(
          abschnitt.querySelectorAll(".chrono-stop"),
        )) {
          const teile = halt.querySelectorAll(".chrono-stop__body > *");
          const punkt = halt.querySelector(".chrono-stop__dot");
          gsap.set(teile, { opacity: 0, y: 16 });
          gsap.set(punkt, { scale: 0 });
          gsap.to(teile, {
            opacity: 1,
            y: 0,
            duration: 0.5,
            ease: "power2.out",
            stagger: 0.1,
            scrollTrigger: {
              trigger: halt,
              start: "top 86%",
              toggleActions: "play none none reverse",
            },
          });
          gsap.to(punkt, {
            scale: 1,
            duration: 0.4,
            ease: "power2.out",
            scrollTrigger: {
              trigger: halt,
              start: "top 88%",
              toggleActions: "play none none reverse",
            },
          });
        }
      }, abschnitt);

      void document.fonts?.ready.then(() => ScrollTrigger.refresh());
      return () => senkrecht.revert();
    }

    const raum = gsap.context(() => {
      const strecke = () => Math.max(0, gleis.scrollWidth - window.innerWidth);

      // Die Grundlinie zeigt sich mit dem Scrollen, nicht von Anfang an.
      const linie = abschnitt.querySelector<HTMLElement>(".chrono__line");
      if (linie) {
        gsap.fromTo(
          linie,
          { opacity: 0 },
          {
            opacity: 1,
            ease: "none",
            scrollTrigger: {
              trigger: abschnitt,
              start: "top bottom-=12%",
              end: "top top",
              scrub: 0.5,
            },
          },
        );
      }

      /** Zeichnet die Linie bis zu einem Punkt in Gleiskoordinaten. */
      const zeichneLinie = (anteil: number) => {
        const anfang = fuellung.parentElement?.offsetLeft ?? 0;
        const gezogen = anteil * strecke() + window.innerWidth * 0.5;
        fuellung.style.width = `${Math.max(0, gezogen - anfang)}px`;
      };
      zeichneLinie(0);

      const fahrt = gsap.to(gleis, {
        x: () => -strecke(),
        ease: "none",
        scrollTrigger: {
          trigger: abschnitt,
          start: "top top",
          end: "bottom bottom",
          scrub: 0.6,
          invalidateOnRefresh: true,
          // Die Linie waechst mit dem Scrollen nach rechts weiter.
          onUpdate: (selbst) => zeichneLinie(selbst.progress),
          onRefresh: (selbst) => zeichneLinie(selbst.progress),
        },
      });

      const haltepunkte = gsap.utils.toArray<HTMLElement>(
        abschnitt.querySelectorAll(".chrono-stop"),
      );
      for (const [nr, halt] of haltepunkte.entries()) {
        // Die erste Station steht schon da, damit die Leiste nicht leer beginnt.
        if (nr === 0) continue;
        const oben = halt.classList.contains("chrono-stop--up");
        const teile = halt.querySelectorAll(".chrono-stop__body > *");
        const punkt = halt.querySelector(".chrono-stop__dot");
        // Vor dem ersten Ausloesen unsichtbar, sonst stehen alle sofort da.
        gsap.set(teile, { opacity: 0, y: oben ? 20 : -20 });
        gsap.set(punkt, { scale: 0 });
        // Jahr, Ueberschrift, Haus und Text kommen nacheinander.
        gsap.to(teile, {
          opacity: 1,
          y: 0,
          duration: 0.5,
          ease: "power2.out",
          stagger: 0.12,
          scrollTrigger: {
            trigger: halt,
            containerAnimation: fahrt,
            start: "left 82%",
            toggleActions: "play none none reverse",
          },
        });
        gsap.to(punkt, {
          scale: 1,
          duration: 0.42,
          ease: "power2.out",
          scrollTrigger: {
            trigger: halt,
            containerAnimation: fahrt,
            start: "left 86%",
            toggleActions: "play none none reverse",
          },
        });
      }
    }, abschnitt);

    void document.fonts?.ready.then(() => ScrollTrigger.refresh());

    return () => raum.revert();
  }, []);

  return (
    <div className="chrono" ref={wurzel}>
      <div className="chrono__stage">
        <ol className="chrono__track">
          <span className="chrono__line" aria-hidden="true">
            <span className="chrono__line-fill" />
          </span>
          {stationen.map((station, nr) => {
            const Icon = station.icon;
            return (
              <li
                className={`chrono-stop chrono-stop--${nr % 2 === 0 ? "up" : "down"}`}
                key={station.titel}
              >
                <span className="chrono-stop__dot" aria-hidden="true" />
                <div className="chrono-stop__body">
                  <span className="chrono-stop__years">
                    <Icon aria-hidden="true" />
                    {station.jahre}
                  </span>
                  <h3>{station.titel}</h3>
                  <span className="chrono-stop__org">{station.haus}</span>
                  <p>{station.text}</p>
                </div>
              </li>
            );
          })}
        </ol>
      </div>
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Wiederkehrende Bausteine der Abschnitte                                 */
/* ---------------------------------------------------------------------- */

function Kicker({
  icon: Icon,
  light = false,
  children,
}: {
  icon: LucideIcon;
  light?: boolean;
  children: ReactNode;
}) {
  return (
    <span className={`kicker${light ? " kicker--light" : ""}`}>
      <Icon aria-hidden="true" />
      {children}
    </span>
  );
}

/** Kicker, Ueberschrift und die Nebenspalte rechts. Fuer jeden Abschnitt
    derselbe Aufbau, damit die Seite ein Raster hat und nicht neun. */
function SectionIntro({
  icon,
  kicker,
  center = false,
  side,
  children,
}: {
  icon: LucideIcon;
  kicker: string;
  center?: boolean;
  side?: ReactNode;
  children: ReactNode;
}) {
  // Kicker, Ueberschrift und Nebentext kommen nacheinander, nicht zusammen.
  return (
    <div className={`section-intro${center ? " section-intro--center" : ""}`}>
      <div className="section-intro__head">
        <Reveal index={0}>
          <Kicker icon={icon}>{kicker}</Kicker>
        </Reveal>
        <Reveal index={1}>{children}</Reveal>
      </div>
      {side ? (
        <Reveal className="section-intro__side" index={2}>
          {side}
        </Reveal>
      ) : (
        <div className="section-intro__side" />
      )}
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* Die Inhalte der Seite                                                   */
/* ---------------------------------------------------------------------- */

/** Die Leiste oben zeigt, wo man auf der Seite steht. Sie ist keine
    Navigation, deshalb folgt sie der Reihenfolge der Seite. */
const railSteps = [
  { id: "work", label: "Work" },
  { id: "method", label: "How I work" },
  { id: "why", label: "Why this role" },
  { id: "background", label: "Background" },
  { id: "personal", label: "Off the clock" },
  { id: "contact", label: "Contact" },
];

const projekte: Projekt[] = [
  {
    name: "Norrvik",
    icon: Stethoscope,
    titel: "A dental practice site where booking is the first thing you see, not the last.",
    rollen: "Web design · Frontend · Photo direction",
    notiz:
      "Treatments in plain words, prices where people look for them, and one booking flow that works on a phone in a waiting room.",
    bild: `${BILD}project-clinic.webp`,
  },
  {
    name: "Court North",
    icon: Trophy,
    titel: "A club page that puts the court calendar where the hero image used to be.",
    rollen: "Web design · Booking flow · Brand",
    notiz:
      "Members book in three taps, guests see free slots before they sign up. The season schedule comes from one file the club edits itself.",
    bild: `${BILD}project-club.webp`,
  },
  {
    name: "Fieldnote",
    icon: Feather,
    titel: "A writing app landing page that shows the product instead of describing it.",
    rollen: "Web design · Frontend · Motion",
    notiz:
      "The editor runs in the page, with real keystrokes and a real result. No video, no screenshot, nothing that could look better than the app.",
    bild: "/heros/editorial-laptop-typing.webp",
  },
];

/** Sechs Schritte von der ersten Mail bis zum Start. */
const schritte = [
  {
    icon: MessageSquare,
    titel: "First call",
    text: "Thirty minutes. What the site has to achieve, who it is for, what is in the way today.",
  },
  {
    icon: Compass,
    titel: "Structure",
    text: "Sitemap and wireframes before any colour. If the order is wrong, no layout saves it.",
  },
  {
    icon: PenLine,
    titel: "Design",
    text: "Two directions for the home page, one of them gets built out across every template.",
  },
  {
    icon: Code,
    titel: "Build",
    text: "Hand written, responsive, with a CMS your team can actually use without me.",
  },
  {
    icon: Gauge,
    titel: "Check",
    text: "Speed, contrast, keyboard, screen reader. A page nobody can use is not finished.",
  },
  {
    icon: Rocket,
    titel: "Launch",
    text: "Go live together, watch the first week, fix what the numbers show instead of guessing.",
  },
];

const beobachtungen = [
  {
    titel: "Questions before mockups.",
    text: "Every project starts with questions about the business, not about the layout. A design can only be good once it knows what it has to achieve.",
  },
  {
    titel: "Honest estimates, kept promises.",
    text: "Your time is blocked in the calendar, not squeezed between other calls. If a deadline is at risk you hear it early, not on the day.",
  },
  {
    titel: "I take results personally.",
    text: "From the first sketch to launch I stay responsible for the outcome. If something does not perform I come back with a fix, not with reasons.",
  },
  {
    titel: "Design with a job to do.",
    text: "Pretty alone is not enough. Every section has to earn its place, through clarity, trust or conversion. Fast pages are part of that.",
  },
];

const beobachtungIcons: LucideIcon[] = [Sparkles, Clock, Check, Palette];

const stationen: Station[] = [
  {
    jahre: "2017–2020",
    titel: "Media & Communication (B.A.)",
    haus: "University",
    text: "Where I learned how messages work, why people trust some brands and never come back to others.",
    icon: GraduationCap,
  },
  {
    jahre: "Since 2018",
    titel: "Freelance web design",
    haus: "Independent client work",
    text: "First client sites while still studying. Deadlines, feedback from five directions at once, and the lesson that shipped beats perfect.",
    icon: PenLine,
  },
  {
    jahre: "2020–2023",
    titel: "Co-founded an online shop",
    haus: "Own venture",
    text: "Brand, shop, ads, packaging, all self built. My crash course in conversion, customer care and cash flow.",
    icon: ShoppingBag,
  },
  {
    jahre: "2023–2025",
    titel: "UX & Content Lead",
    haus: "Digital product team",
    text: "Interfaces and content used by thousands of people every day. What counts here is that it still works in month six, not just on launch day.",
    icon: Building2,
  },
  {
    jahre: "Since 2025",
    titel: "Founder & Web Designer",
    haus: "Own studio",
    text: "Sites and brand identities for small businesses. I run projects from the first call to launch and stand behind the result.",
    icon: Rocket,
  },
];

/** Kleine private Notizen. Keine Icons, die Nummer kommt aus der Reihenfolge. */
const privat = [
  {
    wort: "Coffee",
    wert: "Fuelled by flat whites. The grinder is the loudest tool in the studio.",
  },
  {
    wort: "Playlists",
    wert: "Every project gets its own. Quiet while designing, loud while shipping.",
  },
  {
    wort: "Trails",
    wert: "Weekends you will find me on a hiking trail, camera in the backpack.",
  },
];

/* ---------------------------------------------------------------------- */
/* Die Seite                                                               */
/* ---------------------------------------------------------------------- */

export function ApplicationMicrositePage() {
  const [vorhang, setVorhang] = useState(true);
  useDrawnIcons();
  useHeroParallax();

  useEffect(() => {
    // Ohne Adresszusatz beginnt die Seite immer oben. Sonst laeuft der Vorhang
    // und danach steht man dort, wo man beim letzten Mal war.
    if (!window.location.hash) window.scrollTo(0, 0);

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setVorhang(false);
      return;
    }
    const uhr = window.setTimeout(() => setVorhang(false), 2500);
    return () => window.clearTimeout(uhr);
  }, []);

  return (
    <div className="application-microsite">
      <style>{scopedStyles}</style>

      <main className="application-page" data-draw-icons>
        {vorhang && (
          <div className="intro-curtain" aria-hidden="true">
            <p>
              <span>Nice</span> <span>to</span> <span>meet</span> <span>you.</span>
            </p>
          </div>
        )}

        <header className="site-header">
          <SectionRail steps={railSteps} />
          <span className="wordmark">Hannah</span>
        </header>

        <div className="hero-scene">
          <section id="top" className="hero section-shell">
            <div className="hero__inner">
              <div className="eyebrow">Application · Web Designer</div>
              <IntroStatement startDelay={1300} />
              <div className="hero__facts" aria-label="Quick profile">
                <span>
                  <Clock aria-hidden="true" />
                  20 to 30 hrs per week
                </span>
                <span>
                  <User aria-hidden="true" />
                  Remote, two days on site
                </span>
                <span>
                  <LineChart aria-hidden="true" />
                  Eight years, 60 plus sites
                </span>
              </div>
            </div>
          </section>

          <section id="roles" className="combination section-shell">
            <SectionIntro icon={Blend} kicker="My combination" center>
              <h2>
                Three roles, <span>one person.</span>
              </h2>
            </SectionIntro>
            <RoleAccordion />
            <p className="combination__close">Which means no handover, and nothing lost in one.</p>
          </section>
        </div>

        <section id="work" className="work">
          <div className="section-shell">
            <SectionIntro
              icon={LayoutGrid}
              kicker="Selected work"
              center
              side={
                <p>
                  Three projects from the studio. Concept, design and build are each entirely mine,
                  and each one is still live today.
                </p>
              }
            >
              <h2>Three projects</h2>
            </SectionIntro>
          </div>
          <WorkParallax projekte={projekte} />
          <div className="section-shell">
            <Reveal className="extra">
              <h3>And the ones that did not make it here.</h3>
              <p>
                A newsletter archive, two shops and a booking tool for a driving school. Less to
                look at, more to solve. Happy to walk through any of them in a call.
              </p>
            </Reveal>
          </div>
        </section>

        <section id="method" className="work section-shell">
          <SectionIntro
            icon={Compass}
            kicker="How I work"
            side={
              <p>
                Six steps, the same every time. Not because projects are alike, but because knowing
                what comes next is what keeps a project calm.
              </p>
            }
          >
            <h2>
              From the first call
              <br />
              <span>to launch day.</span>
            </h2>
          </SectionIntro>

          <div className="steps">
            {schritte.map((schritt, nr) => {
              const Icon = schritt.icon;
              return (
                <Reveal className="step" key={schritt.titel} index={nr}>
                  <span className="step__icon">
                    <Icon aria-hidden="true" />
                  </span>
                  <h3>{schritt.titel}</h3>
                  <p>{schritt.text}</p>
                </Reveal>
              );
            })}
          </div>

          <Reveal className="manifesto">
            <p>
              A site is finished when it earns its keep, <span>not when it looks done.</span>
            </p>
          </Reveal>
        </section>

        <section id="why" className="why section-shell">
          <SectionIntro
            icon={Target}
            kicker="Why this role"
            side={
              <p>
                What convinced me is how your case studies read like answers to business problems,
                not like a portfolio. You think about the people behind every click, and that is how
                I work too. I am not writing because I need a job, I am writing because I would have
                bookmarked your work anyway.
              </p>
            }
          >
            <h2>
              Why I am applying
              <br />
              <span>to you.</span>
            </h2>
          </SectionIntro>
          <ObservationCards punkte={beobachtungen} icons={beobachtungIcons} />
        </section>

        <ToolField />

        <section id="background" className="timeline">
          <div className="section-shell">
            <SectionIntro
              icon={LineChart}
              kicker="Background"
              center
              side={<p>Five stops between university and my own studio.</p>}
            >
              <h2>The road here</h2>
            </SectionIntro>
          </div>
          <ChronoRail stationen={stationen} />
        </section>

        <section id="personal" className="personal section-shell">
          <div className="personal__split">
            <div className="personal__head">
              <Reveal index={0}>
                <Kicker icon={Glasses}>Off the clock</Kicker>
              </Reveal>
              <Reveal index={1}>
                <h2>
                  Three things
                  <br />
                  <span>still missing.</span>
                </h2>
              </Reveal>
            </div>
            <div>
              <dl className="personal__list">
                {privat.map((eintrag, nr) => (
                  <Reveal className="personal-row" key={eintrag.wort} index={nr}>
                    <dt>
                      <span className="personal-row__num">{String(nr + 1).padStart(2, "0")}</span>
                      <span aria-hidden="true">·</span>
                      {eintrag.wort}
                    </dt>
                    <dd>{eintrag.wert}</dd>
                  </Reveal>
                ))}
              </dl>
            </div>
          </div>
        </section>
      </main>

      <footer id="contact" className="closing" data-draw-icons>
        <div className="closing__inner">
          <BlurLetters text="Looks like a match?" />
          <p className="closing__lead">Then we should get to know each other.</p>
          <div className="closing__actions">
            <a className="closing__cta" href="#work">
              View selected work
              <span className="btn__icon">
                <ChevronDown aria-hidden="true" />
              </span>
            </a>
            <a className="closing__cta closing__cta--ghost" href="#top">
              Back to top
              <span className="btn__icon btn__icon--up">
                <ChevronDown aria-hidden="true" />
              </span>
            </a>
          </div>
          <div className="closing__foot">
            <a className="is-mail" href="mailto:hello@example.com">
              hello@example.com
            </a>
            <span>Available from March</span>
            <span>© 2026</span>
          </div>
        </div>
      </footer>
    </div>
  );
}

export default ApplicationMicrositePage;
FAQ

Frequently asked questions

A curated landing page made of matching library sections in a sensible order from hero to footer. You copy the sections one by one and assemble them with the composition snippet.

Didn't find your question? We're happy to help.

Get in touch