/* Daelken Preview-Onepager — Layout und Verhalten.
   Maße 1:1 aus Figma "Onepager V1 · 1440" (2682:1643). */

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

/* Kein scroll-behavior: smooth — die 0,6 s Ease-in-out macht anker() in main.js.
   Beides zusammen würde sich gegenseitig ausbremsen. */

body {
  margin: 0;
  background: var(--page-bg);
  color: var(--text-black);
  font-family: var(--font);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

h1, h2, h3, p, figure, blockquote, ul { margin: 0; }
ul { padding: 0; list-style: none; }
img, svg, video { display: block; max-width: 100%; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; }

.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--d-blue); padding: 0.75rem 1.25rem;
}
.skip-link:focus { left: 0; }

/* Nur für Screenreader: visuell versteckt, bleibt aber im Accessibility-Tree. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

:focus-visible { outline: 2px solid var(--text-black); outline-offset: 3px; }

/* Globaler Hover: Telefon, E-Mail, Impressum/Datenschutz — 70 %, nie unterstrichen */
.textlink { text-decoration: none; transition: opacity .2s ease-out; }
.textlink:hover { opacity: var(--hover-opacity); }

/* Sektions-Raster: immer 100 % Viewport-Breite, nur die Ränder skalieren mit.
   Bewusst KEIN max-width — oberhalb 1440 wächst das Layout proportional weiter. */
.header, .ddd, .hero, .keyfacts, .details, .ss03, .bauaufgaben,
.referenzen, .testimonials, .team, .standorte, .footer {
  width: 100%;
  padding-inline: var(--page-pad);
}

/* ============ HEADER ============ */
.header { padding-block: 1.5625rem; }
.wordmark { display: inline-flex; gap: 0.625rem; align-items: center; text-decoration: none; }
.wordmark__sub { text-transform: uppercase; }

/* ============ ABOVE THE FOLD ============ */
/* Header, Bühne und Hero teilen sich genau eine Bildschirmhöhe. Header und Hero
   behalten ihre natürliche Höhe, die Bühne bekommt den Rest. Unten steht
   derselbe Rand wie seitlich, damit die Gruppe gleichmäßig gerahmt ist.
   dvh statt vh wegen der ein- und ausfahrenden Browserleisten auf Mobilgeräten. */
/* Bewusst max-height, nicht height: Der Hero füllt die Bildschirmhöhe nur,
   solange er sie braucht — reicht die Höhe im Überfluss, bleibt er auf seiner
   Inhaltshöhe stehen und das Laufband schneidet oben an. Ist der Bildschirm zu
   flach, greift max-height und die Bühne schrumpft (flex: 0 1 auto). */
.oben {
  display: flex; flex-direction: column;
  max-height: 100dvh;
  padding-bottom: var(--page-pad);
}

/* ============ DDD-BÜHNE ============ */
.ddd {
  /* Die Bühne bekommt genau die Höhe der D-Gruppe plus je 25 px Luft nach
     oben und unten — den doppelten Mindestabstand aus Header-Padding (25) und
     hero padding-top (25). Damit ist der vertikale Zuwachs bei 25 px gedeckelt.
     Der Wert entspricht dem breitenbegrenzten Fall; ist die Höhe die engere
     Grenze, schrumpft die Bühne über flex: 0 1 auto darunter. */
  flex: 0 1 auto; min-height: 0;
  height: calc((100vw - 2 * var(--page-pad)) * var(--ddd-ratio-inv) + 2 * var(--gap-s));

  /* linksbündig statt zentriert — überschüssige Breite läuft rechts auf.
     position/z-index sorgen dafür, dass die Bühne über dem Hero liegt: In der
     Intro wächst sie nach unten in den Hero hinein, und H1/Button sollen dabei
     unter ihr hervorkommen, nicht über sie hinweg. */
  position: relative; z-index: 1;
  display: flex; align-items: center; justify-content: flex-start;
  container-type: size;

  /* Endzustand = mask-video.svg (1380 × 467), Startzustand der Intro = volle
     D-Höhe (1380 × 700). Beides als Verhältnis, damit die Bühne in jeden
     Restplatz passt statt auf feste Höhen festgenagelt zu sein. */
  --ddd-ratio: calc(1380 / 467);
  --ddd-ratio-inv: calc(467 / 1380);
  --ddd-start: calc(700 / 467);
}
/* Contain: so groß wie möglich, ohne die D-Formen zu verzerren oder zu
   beschneiden — je nachdem, ob Breite oder Resthöhe die engere Grenze ist. */
.ddd__inner {
  position: relative;
  width: min(100cqw, 100cqh * var(--ddd-ratio));
  height: min(100cqh, 100cqw * var(--ddd-ratio-inv));
}

/* Beide Deko-Ebenen enden auf opacity:0, bleiben aber im Layout — ohne
   pointer-events:none fangen sie Klicks auf Film-Button und Hero-Video ab. */
.ddd__anim01 {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
}
.ddd__anim01 > img { width: 100%; height: 100%; }

/* Die Deckfläche sitzt am maskierten Container, nicht am <video>. Damit ist
   die Bühne innerhalb der D-Maske IMMER blickdicht — auch wenn das Video nie
   lädt. Erst das macht die Intro-Timeline unabhängig von Ladezeiten. */
.ddd__video {
  position: absolute; left: 0; top: 0;
  width: 100%; height: 100%;
  background: var(--section-bg);
  -webkit-mask-image: url("../img/d/mask-video.svg");
  mask-image: url("../img/d/mask-video.svg");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
.ddd__video-el {
  width: 100%; height: 100%;
  object-fit: cover;
}

.ddd__anim02 {
  position: absolute; left: 0; top: 0;
  width: 100%; height: 100%;
  pointer-events: none;
}
/* Beide Deko-Ebenen wachsen für die Intro nach unten aus der Bühne heraus —
   deshalb der Ursprung an der Oberkante. */
.ddd__anim01, .ddd__video, .ddd__d13 { transform-origin: 50% 0; }
/* Die Bühnensilhouette liegt als Maske auch über den einfahrenden farbigen
   Ds — dieselbe Datei, mit der schon .ddd__video maskiert ist, damit es nur
   EINE Pfadquelle für die Silhouette gibt. Kein farbiges Pixel erscheint
   außerhalb der beigen Form, in keinem Frame; während der Einfahrt ist die
   sichtbare Schnittmenge aus Silhouette und verschobener D-Form gewollt.
   Maske und rechteckiger Schnitt greifen im lokalen Koordinatensystem, also
   VOR scaleY, und folgen der schrumpfenden Bühne deshalb exakt. Der Schnitt
   bleibt als zweite Sicherung gegen Auslaufen aus der Bühnenfläche stehen. */
/* Der Abstand MUSS proportional sein, nicht var(--gap-xs). Alle Grafiken
   der Bühne (animation01.svg, mask-video.svg, d1-d3.svg) tragen die Lücke fest
   im Pfad: 24 von 1380 Einheiten = 1,7391 %. --gap-xs hängt dagegen an der
   fluiden Root-Größe, die Bühnenbreite aber an der Fensterhöhe
   (min(100cqw, 100cqh * ratio)) — ab ~1800 px Fensterbreite laufen beide
   auseinander, sichtbar als beiger Rand neben den farbigen Ds und als
   Formsprung gegen die Videomaske. Bei exakt 1440 px fällt beides zufällig
   zusammen, dort fehlt der Fehler also. */
.ddd__d13 {
  display: flex; gap: calc(24 / 1380 * 100%);
  width: 100%; height: 100%;
  clip-path: inset(0);
  -webkit-mask-image: url("../img/d/mask-video.svg");
  mask-image: url("../img/d/mask-video.svg");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
.ddd__d { flex: 1 1 0; width: 0; height: 100%; }

/* Blur-Overlay: liegt unter dem Film-Button in derselben D-Maske und wird
   dadurch auf die drei D-Formen geschnitten. Figma: Füllung #1c1a15 30 %,
   Hintergrund-Weichzeichnung 10 — Figmas Radius entspricht in CSS aber etwa
   der Hälfte; bei blur(10px) ist vom Video nichts mehr zu erkennen. */
/* display:none statt nur opacity/visibility, solange die Intro läuft.
   opacity:0 nimmt das Element nicht aus dem Painting — der backdrop-filter legt
   trotzdem eine Kompositor-Ebene an, die den Hintergrund liest; visibility:hidden
   unterbindet zwar das Malen, das Element bleibt aber im Boxbaum und damit ein
   Kandidat für eine Render-Surface. display:none entfernt die Box vollständig:
   während Laden und Intro KANN keine Ebene und kein Backdrop-Schnappschuss
   entstehen. Erst ab .intro-fertig existiert die Fläche überhaupt, und dann
   weiterhin unsichtbar bis zum Hover.
   Die Transition hängt ebenfalls an .intro-fertig, damit ein ungewollter
   Wertwechsel während des Ladens hart springt statt 0,3 s sichtbar zu sein.
   Die 0s-Stufen auf visibility sind Absicht — beim Einblenden sofort sichtbar,
   beim Ausblenden erst nach der Opacity-Blende verschwinden. */
.ddd__video-blur {
  position: absolute; inset: 0; z-index: 1;
  display: none;
  opacity: 0;
  visibility: hidden;
  background: rgba(28, 26, 21, .3);
  backdrop-filter: blur(0.3125rem);
  -webkit-backdrop-filter: blur(0.3125rem);
  pointer-events: none;
}
.intro-fertig .ddd__video-blur {
  display: block;
  transition: opacity .3s ease-out, visibility 0s linear .3s;
}

/* Film-Button auf dem Hero-Video — Figma 89:2794: transparent, 1 px weißer
   Rahmen, Padding 15/20, Gap 8, Icon 18, Text 15 SemiBold weiß. */
/* wie beim Blur — vor .intro-fertig existiert die Box gar nicht. */
.film-button {
  position: absolute; left: 1.875rem; bottom: 1.875rem; z-index: 2;
  display: none; align-items: center; gap: 0.5rem;
  padding: 0.9375rem 1.25rem;
  border: 1px solid #fff;
  border-radius: var(--radius-voll);
  color: #fff;
  opacity: 0;
  transition: opacity .3s ease-out;
  pointer-events: none;
}
.intro-fertig .film-button { display: inline-flex; }
.film-button__icon { display: flex; }

/* Hover gilt für den Eltern-Container aller drei D-Formen — die Maske kappt
   nur die Darstellung, nicht die Trefferfläche. So gibt es keine Lücke
   zwischen den Ds. */
@media (hover: hover) {
  .ddd__video:hover .ddd__video-blur {
    opacity: 1; visibility: visible;
    transition: opacity .3s ease-out, visibility 0s;
  }
  .ddd__video:hover .film-button { opacity: 1; pointer-events: auto; }
  .ddd__video:hover .film-button:hover { opacity: var(--hover-opacity); }
}

/* ============ HERO ============ */
.hero {
  display: flex; flex-direction: column; gap: var(--gap-s);
  padding-top: var(--gap-s);
}
.hero__h1 { max-width: 63.0625rem; }
.hero__cta { display: flex; gap: var(--gap-xs); align-items: center; flex-wrap: wrap; }

.btn-cta {
  display: inline-flex; align-items: center; justify-content: center;
  height: 3.25rem; padding-inline: var(--gap-xs);
  border-radius: var(--radius-voll);
  background: var(--d-blue);
  font-size: 1.0625rem; line-height: 1.65;
  text-decoration: none;
  transition: opacity .2s ease-out;
}
.btn-cta:hover { opacity: var(--hover-opacity); }

/* Zweitrangige Aktion in derselben Pillen-Optik wie .btn-cta — gleiche
   Höhe, gleiches Padding, gleicher Radius, gleiche Schriftgröße. Nur Rahmen
   statt Fläche. Der Rahmen läuft nach innen (border-box), damit die Grundlinie
   des Labels mit der des Nachbarbuttons fluchtet. */
.btn-ghost {
  display: inline-flex; align-items: center; justify-content: center;
  height: 3.25rem; padding-inline: var(--gap-xs);
  border: 1px solid var(--text-black);
  border-radius: var(--radius-voll);
  font-size: 1.0625rem; line-height: 1.65;
  text-decoration: none;
  transition: opacity .2s ease-out;
}
.btn-ghost:hover { opacity: var(--hover-opacity); }

/* Schwarzer Button, Label in der Seitenfarbe — dieselbe Pillen-Geometrie
   wie .btn-cta/.btn-ghost. Dient sowohl als Öffner auf der Standortkarte als
   auch als Telefon-/Mail-Aktion im Kontakt-Modal. */
.btn-kontakt {
  display: inline-flex; align-items: center; justify-content: center;
  height: 3.25rem; padding-inline: var(--gap-xs);
  border-radius: var(--radius-voll);
  background: var(--text-black);
  color: var(--page-bg);
  font-size: 1.0625rem; line-height: 1.65;
  text-decoration: none;
  transition: opacity .2s ease-out;
}
.btn-kontakt:hover { opacity: var(--hover-opacity); }

/* ============ LAUFBAND ============ */
/* Der Abstand zum Hero steht hier oben, weil der Hero selbst bündig mit der
   Bildschirmkante abschließen muss (siehe .oben). */
/* overflow-x: clip statt hidden — clip macht die Bar nicht zum Scroll-Container
   und lässt overflow-y sichtbar, sodass das auf 120 % vergrößerte Laufband oben
   und unten frei in das Padding wachsen darf, während seitlich nichts ausläuft. */
.marquee-bar {
  padding-block: var(--gap-xl);
  overflow-x: clip;
  overflow-y: visible;
}
/* Skalierung auf der Bar, NICHT auf .marquee__track: der Track trägt die
   Endlos-Animation, dort würde die Skalierung Kachelbreite und Sprungweite
   verschieben und die Naht sichtbar machen. */
.marquee {
  background: var(--section-bg);
  padding: 1.5625rem 1.875rem;
  overflow: hidden;
  transform-origin: center;
  transition: transform .3s ease-in-out;
}
/* Nur für echte Zeigegeräte. Auf Touch latcht :hover nach einem Tap und
   bleibt hängen — das Laufband stand dann dauerhaft auf 120 % und wirkte
   gegenüber "Wir sind Architekten …" zu groß, obwohl beide 22 px sind. */
@media (hover: hover) and (pointer: fine) {
  .marquee:hover { transform: scale(1.2); }
}
.marquee__track {
  display: flex; width: max-content;
  will-change: transform;
}
/* Ein Gap für alles — Pfeil und Middots sind eigene Flex-Kinder, also steht
   links wie rechts von ihnen derselbe Wert. Die Naht zur nächsten Kachel muss
   denselben Abstand haben, sonst fällt sie beim Durchlauf auf. */
.marquee__kachel {
  display: flex; align-items: center; gap: 0.625rem;
  flex: 0 0 auto;
  padding-right: 0.625rem;
  font-size: 1.5rem; line-height: 1.3; letter-spacing: -0.005em;
  white-space: nowrap;
}
.marquee__stark { font-weight: 500; }

.pill {
  display: inline-flex; align-items: center;
  height: 1.875rem; padding-inline: 0.9375rem;
  border-radius: var(--radius-voll);
  background: var(--d-blue);
  font-size: 1.0625rem; line-height: 1.65;
  text-decoration: none;
  transition: opacity .2s ease-out;
}
.pill:hover { opacity: var(--hover-opacity); }

/* ============ KEY FACTS ============ */
.keyfacts {
  display: flex; gap: var(--gap-xs); align-items: flex-start;
  padding-top: var(--gap-s); padding-bottom: var(--gap-l);
}
.keyfacts__fix { width: 42.375rem; flex: 0 0 auto; }
.keyfacts__var { flex: 1 1 0; min-width: 0; }

/* Die Maskenhöhe rechnet sich aus den Typo-Metriken der beiden Zeilen, statt
   als eigener Wert danebenzustehen — ein unabhängiges Maß (vw oder fest) läuft
   gegen den rem-Inhalt, sobald die fluide Root-Größe klemmt, und schneidet dann
   ab oder lässt den nächsten Block hervorlugen. Die vier Variablen
   müssen den Werten von .t-display und .t-24 folgen; wird eine der beiden
   Typo-Rollen geändert, gehört sie hier nachgezogen. */
.ticker {
  --zahl: 6.875rem; --zahl-zeile: 1.12;
  --text: 1.5rem;   --text-zeile: 1.3;
  height: calc(var(--zahl) * var(--zahl-zeile) + var(--text) * var(--text-zeile));
  overflow: hidden;
}
.ticker__track { padding-inline: 1.875rem; will-change: transform; }
.ticker__block > .t-display { white-space: nowrap; }

/* ============ DETAILS ============ */
.details { padding-block: var(--gap-m); }
/* Ankerziel „Alle Leistungen und Expertisen": die Zeile soll 50 px unterhalb
   der Fensterkante zu stehen kommen. anker() liest den Wert aus. */
#leistungen { scroll-margin-top: 3.125rem; }

/* ============ SS03 AKKORDEONS ============ */
.ss03 { display: flex; gap: var(--gap-xs); align-items: stretch; }

.karte {
  position: relative;
  flex: 1 1 0; min-width: 0;
  min-height: 51.6875rem;
  padding: 1.875rem;
  background: var(--section-bg);
}
/* Die Hover-Farbe liegt auf einem eigenen Layer und wird nur ein-/ausgeblendet.
   Eine background-color-Transition auf der Karte selbst flackerte gelegentlich:
   der Spalten-Hover öffnet im selben Frame das erste Akkordeon, und das Neu-
   Layouten der Flex-Zeile konnte einen Frame ohne Farbe zeigen. */
.karte::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  opacity: 0;
  transition: opacity .3s ease-in-out;
  will-change: opacity;
}
.karte--leistungen::before { background: var(--d-blue); }
.karte--expertisen::before { background: var(--d-green); }
.karte:hover::before { opacity: 1; }
.karte > * { position: relative; z-index: 1; }
.karte__label { margin-bottom: 1.375rem; }

.akk__item { border-top: 1px solid rgba(28, 26, 21, .3); }
.akkordeon { border-bottom: 1px solid rgba(28, 26, 21, .3); }

/* Das Heading ist reiner Semantik-Rahmen um den Button (Standard-Akkordeon-
   muster). Ohne den Reset sickerte die UA-h3-Schrift (bold, 1.17em) über
   button { font: inherit } in den Button. Margin ist global genullt. */
.akk__titel { font: inherit; }

.akk__head {
  display: flex; align-items: center; justify-content: space-between; gap: 1.25rem;
  width: 100%; padding-block: 0.875rem;
  text-align: left;
}

/* Plus-Icon — Figma 2686:1785: 24 × 24, Striche 1 px. Dasselbe Icon dient als
   Close-X der Modals, dort um 45° gedreht (siehe .modal__close). currentColor,
   weil es im Video-Modal hell auf dunkel steht. */
.plus { position: relative; flex: 0 0 auto; width: 1.5rem; height: 1.5rem; }
.plus__h, .plus__v { position: absolute; background: currentColor; }
.plus__h { left: 0; top: 0.71875rem; width: 1.5rem; height: 1px; }
.plus__v {
  left: 0.71875rem; top: 0; width: 1px; height: 1.5rem;
  transform-origin: 50% 50%;
  transition: transform .18s ease-out;
}
.akk__head[aria-expanded="true"] .plus__v { transform: rotate(-90deg); }

/* Nur grid-template-rows animiert. Die Paddings stehen dauerhaft im
   overflow:hidden-Kind — würden sie beim Öffnen/Schließen umschalten, sprängen sie
   ohne Übergang und rissen die Nachbarn kurz nach oben. */
.akk__body {
  display: grid; grid-template-rows: minmax(0, 0fr);
  overflow: hidden;
  transition: grid-template-rows .3s ease-in-out;
}
.akk__body > p {
  min-height: 0;
  max-width: 35rem;
  padding-top: 1rem;
  padding-bottom: 0.875rem;
}
.akk__item[data-open] .akk__body { grid-template-rows: minmax(0, 1fr); }

/* ============ NEUN BAUAUFGABEN ============ */
.bauaufgaben {
  display: flex; gap: var(--gap-xs); align-items: flex-start;
  padding-block: var(--gap-xl);
}
.bauaufgaben__intro { width: 42.375rem; flex: 0 0 auto; }
.bauaufgaben__liste { display: flex; flex-direction: column; gap: 0.3125rem; }

/* ============ REFERENZEN ============ */
.referenzen {
  display: flex; flex-direction: column; gap: var(--gap-s);
  padding-block: var(--gap-s);
}
.referenzen__kopf {
  display: flex; flex-direction: column; gap: var(--gap-s);
  padding-bottom: var(--gap-s);
}

/* pan-y: waagerechte Gesten gehören dem Slider, senkrechtes Seitenscrollen dem
   Browser. Ohne das übernimmt der Browser den Wisch und bricht ihn per
   pointercancel ab, bevor der Finger abhebt. */
.hslider {
  position: relative; overflow: hidden;
  touch-action: pan-y;
  -webkit-user-select: none; user-select: none;
}
.hslider__track { display: flex; will-change: transform; transition: transform .8s cubic-bezier(.22, 1, .36, 1); }
.hslide { flex: 0 0 100%; }

.hslide__mask {
  position: relative;
  aspect-ratio: 1380 / 575;
  overflow: hidden;
  background: var(--section-bg);
}
.hslide__img {
  position: absolute; left: 0; top: 0;
  width: 100%; height: auto;      /* 2:1-Bild gegen 2,4:1-Maske = 1/12 Höhenüberhang, bei 1380 sind das 115 px */
  will-change: transform;
  -webkit-user-drag: none;
}

/* Figma 2091:2432 — Fußzeile 1380×46, 25 px unter dem Bild. Titel beginnt bei
   x=46, die Projektdaten enden bündig mit der rechten Bildkante. Der Ring
   sitzt bei x=0 außerhalb des Titel-Einzugs. */
.hslide__fuss {
  position: relative;
  display: flex; align-items: baseline; justify-content: space-between;
  /* Mindestabstand Titel↔Daten. 64 px hat keinen Token (--gap-m 50 / --gap-l 75),
     darum als fester Wert. */
  gap: 4rem;
  margin-top: var(--gap-s);
  padding-left: 2.875rem;
}
/* Der Titel nimmt nur seine Eigenbreite, die Daten den Rest — eine feste
   50/50-Teilung zwang die Daten in die zweite Zeile, obwohl neben dem Titel
   Platz frei war. min-width: 0 hebt das automatische min-width: auto der
   Flex-Items auf, sonst wachsen beide über den Container statt umzubrechen. */
.hslide__titel { flex: 0 1 auto; min-width: 0; }
/* Basis 0 statt auto: Die Datenspalte beansprucht keinen Platz, sie wächst nur
   in das, was neben dem Titel frei ist — und bricht dort am ·-Trenner um. Mit
   Basis auto zog ihre volle Zeilenbreite den Titel anteilig unters Eigenmaß
   (Woolworth: 408,72 nötig, 408,59 bekommen — 0,125 px Rundung) und brach ihn
   mit um. min-content hält sie nie schmaler als die längste Angabe; wird es
   enger, gibt erst dann der Titel nach und bricht selbst. */
.hslide__daten { flex: 1 1 0; min-width: min-content; text-align: right; }
/* Jede Angabe ist ein unteilbares Segment; der ·-Trenner steckt IM Folge-
   segment (per &nbsp; ans erste Wort gebunden), die Umbruchstelle ist das
   <wbr> davor. So bricht die Zeile immer VOR einem Trenner — er kann nie am
   Zeilenende stranden, jede Folgezeile beginnt mit „· ". Die Ränder ersetzen
   die früheren Leerzeichen-Paare: links 0,5em (das <wbr> ist breitenlos),
   rechts 0,25em + &nbsp; — einzeilig identische Abstände wie zuvor. */
.hslide__seg { white-space: nowrap; }
.hslide__trenner { margin: 0 0.25em 0 0.5em; }

/* Autoplay-Fortschrittsring — 32 px, Strichstärke 2 px (Figma 2260:928:
   Außenradius 16, Innenradius 14). top 7 px setzt ihn mittig auf die
   Versalhöhe des Projektnamens (gemessen: Cap-Mitte bei 22,6 px). */
.hslider__progress {
  position: absolute; left: 0; top: 0.4375rem;
  width: 2rem; height: 2rem; border-radius: 50%;
  background: conic-gradient(var(--text-black) calc(var(--p, 0) * 1turn), var(--linie-grey) 0);
  -webkit-mask: radial-gradient(circle, transparent 0 0.84375rem, #000 0.875rem);
  mask: radial-gradient(circle, transparent 0 0.84375rem, #000 0.875rem);
}

/* ============ TESTIMONIALS + D-FORM-PARALLAX ============ */
/* Normaler Sektionsrhythmus (100 raus, 25 rein — wie Bauaufgaben→Referenzen).
   min-height = Formhöhe 66,5rem (dieselbe fluide Einheit wie die D-Form, beide
   skalieren über alle Breiten synchron): Die Form steht damit IMMER in voller
   Größe, egal wie wenig Testimonials gepflegt sind (aktuell 1, mit #552 mehr).
   Greift die min-height, zentriert justify-content den Inhaltsblock — die Luft
   liegt komponiert um den Text statt unten zu hängen. Wächst der Bestand über
   die min-height hinaus, trägt wieder allein der Inhalt die Sektionshöhe. */
.testimonials {
  position: relative;
  display: flex; flex-direction: column; justify-content: center;
  min-height: 66.5rem;
  padding-block: var(--gap-xl);
}

/* Feste, inhaltsunabhängige Größe: Der D-Vektor (SVG 592×1064) steht bei seiner
   Breite 37rem genau 66,5rem hoch — das höchste Kind bestimmt die Boxhöhe. Mit
   der gesetzten Breite 44.3125rem (709px-Raster) ergibt sich das Kastenmaß
   709:1064. Die Box ist in der Sektion zentriert. Das min(…, 100%) ist seit der
   min-height der Sektion (ebenfalls 66,5rem) nur noch theoretischer Fallback —
   die Sektion kann nicht mehr niedriger sein als die Form; griffe er doch,
   führte aspect-ratio die Breite proportional nach (Kinder in Prozenten). */
/* z-index -1 legt die Form in die negative Ebene des Wurzel-Stacking-Contexts.
   Sie malt damit hinter JEDEN Textblock der Seite — auch hinter dem der Folge-
   Sektionen, in die sie durch den Parallax hineinragt. Ein isolation/z-index auf
   .testimonials würde nicht reichen: die Sektion ist position:relative und malt
   als Ganzes über die statisch folgende .team-Sektion. Der Seitenhintergrund
   liegt auf dem body und wird zur Canvas-Fläche, die Form bleibt also sichtbar. */
/* translate statt transform fürs Zentrieren: transform gehört der Parallax-Logik
   (JS setzt style.transform auf den Kindern) und wird bei reduced motion global
   auf none gezwungen — die translate-Eigenschaft bleibt davon unberührt. */
.dform {
  position: absolute; left: 0; top: 50%; translate: 0 -50%;
  height: min(66.5rem, 100%);
  aspect-ratio: 709 / 1064;
  z-index: -1;
  pointer-events: none;
}
.dform__vektoren {
  position: absolute; left: 0; top: 0;
  width: calc(592 / 709 * 100%); height: 100%;
  background: url("../img/deko/dform-d.svg") no-repeat 0 0 / 100% 100%;
  will-change: transform;
}
.dform__bild {
  position: absolute; left: 0; top: 50%; translate: 0 -50%;
  width: calc(658 / 709 * 100%); height: calc(465 / 1064 * 100%);
  background: rgba(246, 244, 236, .2);
  backdrop-filter: blur(0.9375rem);
  -webkit-backdrop-filter: blur(0.9375rem);
  will-change: transform;
}

.testimonials__inhalt { position: relative; }
.testimonials__inhalt > h2 { padding-left: 43.875rem; }
.testimonials__liste { display: flex; flex-direction: column; }

/* Nur Spaltenabstand — als gap käme er zusätzlich zwischen Zitat und Fußzeile
   und machte den Abstand dort um 30 px zu groß. */
.testi {
  display: grid; grid-template-columns: 42.375rem 1fr; column-gap: 1.875rem;
  padding-block: var(--gap-s);
}
.testi:first-child { padding-top: var(--gap-m); }
.testi:last-child { padding-bottom: var(--gap-m); }
.testi > * { grid-column: 2; }
.testi > footer { margin-top: var(--gap-s); }

/* Icon auf die Ink-Grenzen beschnitten (20×19), dadurch zentriert es der
   Browser über die Zeilenhöhe optisch auf die Versalhöhe des Namens.
   Als Maske statt als Bild, damit die Farbe aus --testi-icon kommt. */
.testi__name {
  position: relative;
  padding-left: 1.875rem;
}
.testi__name::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 1.25rem;
  background-color: var(--testi-icon, var(--d-green));
  -webkit-mask: url("../img/deko/quote.svg") no-repeat 0 center / 1.25rem 1.1875rem;
  mask: url("../img/deko/quote.svg") no-repeat 0 center / 1.25rem 1.1875rem;
}

/* Farbfolge Blau → Grün → Rot, zyklisch für den Fall, dass später mehr als
   drei Testimonials gepflegt werden. */
.testi:nth-child(3n + 1) { --testi-icon: var(--d-blue); }
.testi:nth-child(3n + 2) { --testi-icon: var(--d-green); }
.testi:nth-child(3n + 3) { --testi-icon: var(--d-red); }

/* ============ FACHKÖPFE-SLIDER ============ */
/* Normaler Einlauf wie bei den übrigen H2-Sektionen (25 rein nach 100 raus der
   Testimonials). Der frühere calc-Vorlauf kompensierte den statischen Überhang
   der D-Form — seit die Form in den Testimonials gedeckelt und zentriert ist,
   ragt sie in Scroll-Ständen mit sichtbarer H2 nicht mehr hierher (der Parallax
   zieht sie dort nach oben, weg[0] < 0 bei p > 0.5). */
.team {
  display: flex; flex-direction: column; gap: var(--gap-m);
  padding-top: var(--gap-s);
  padding-bottom: var(--gap-xl);
}

/* Kein Textmarkieren und kein natives Bild-Ziehen im Slider — sonst greift der
   Drag den Text bzw. das Portrait statt der Spur. */
.kslider {
  display: flex; flex-direction: column; gap: 2.5rem;
  -webkit-user-select: none; user-select: none;
}
.kslider__track {
  display: flex; gap: var(--gap-xs);
  width: max-content;
  will-change: transform;
  cursor: grab;
  touch-action: pan-y;
}
.kslider__track.is-dragging { cursor: grabbing; }
.kslider > .kslider__track { overflow: visible; }
.kslider { overflow: hidden; }

/* Figma 2752:2251 — Karte 229×346: Bild 229 rund, 20 px Abstand, dann vier
   Zeilen à 23 px, vor der Links-Gruppe zusätzlich 5 px. Deshalb kein Flex-Gap,
   die Abstände stecken in Zeilenhöhe und margin-top. */
.kopf {
  flex: 0 0 14.3125rem; width: 14.3125rem;
  display: flex; flex-direction: column; align-items: center;
  text-align: center;
}
.kopf__bild {
  width: 14.3125rem; height: 14.3125rem;
  object-fit: cover; border-radius: 50%;
  -webkit-user-drag: none;
}
.kopf > p, .kopf > .textlink { line-height: 1.4375rem; }
.kopf__name { margin-top: 1.25rem; }
.kopf > .textlink:first-of-type { margin-top: 0.3125rem; }
.kopf > .textlink { white-space: nowrap; }

/* touch-action: none — sonst deutet der Browser eine leicht schräge
   Fingerbewegung auf dem Griff als Seiten-Scroll, übernimmt die Geste und
   beendet den laufenden Drag per pointercancel. Das war die Ursache dafür, dass
   der Anfasser mitten im Ziehen stehenblieb; eine Winkel-Schwelle im JS kann das
   nicht abfangen, weil die Entscheidung im Compositor fällt. Senkrecht scrollen
   lässt sich hier weiterhin — nur eben nicht mit dem Finger auf dem Griff. */
.kslider__spur {
  position: relative;
  height: 2.1875rem;
  cursor: pointer;
  touch-action: none;
}
.kslider__spur::before {
  content: ""; position: absolute; left: 0; top: 0.96875rem;
  width: 100%; height: 1px; border-radius: 1px;
  background: var(--linie-grey);
}
.kslider__griff {
  position: absolute; left: 0; top: 0.78125rem;
  width: 12.5625rem; height: 0.4375rem;
  border-radius: var(--radius-voll);
  background: var(--linie-black);
  will-change: transform;
  touch-action: none;
}

/* ============ ABBINDER-PARALLAX ============ */
.abbinder {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  height: 42.1875rem;
  padding-inline: 0;
  margin-bottom: var(--gap-m);
  background: var(--section-bg);
  overflow: hidden;
}
.abbinder__d {
  position: absolute; inset: 0;
  pointer-events: none;
  will-change: transform;
}
.abbinder__d::before, .abbinder__d::after {
  content: ""; position: absolute;
  width: 62rem; height: 65.375rem;
  background-size: 100% 100%;
  background-repeat: no-repeat;
}
.abbinder__d::before {
  left: calc(50% - 17rem); top: 24.0625rem;
  background-image: url("../img/deko/abbinder-d-rechts.svg");
}
.abbinder__d::after {
  left: calc(50% - 76.53125rem); top: -32.625rem;
  background-image: url("../img/deko/abbinder-d-links.svg");
}

.abbinder__statement {
  position: relative;
  display: flex; flex-direction: column; justify-content: center;
  width: 100%; min-height: 16.3125rem;
  padding: var(--gap-m) var(--page-pad);
  background: rgba(246, 244, 236, .2);
  backdrop-filter: blur(0.9375rem);
  -webkit-backdrop-filter: blur(0.9375rem);
  will-change: transform;
}

/* ============ STANDORTE ============ */
.standorte {
  display: flex; flex-direction: column; gap: var(--gap-m);
  padding-top: var(--gap-m); padding-bottom: var(--gap-s);
}
.standorte__kopf { display: flex; flex-direction: column; gap: var(--gap-m); }
.standorte__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap-xs); }

/* Figma 148:728 / 144:427 — Bildfläche 327×218 (3:2), 20 px zum Ortsnamen,
   20 px zum Kopf-Block. height:auto ist nötig, sonst schlägt das height-Attribut
   des <img> als Presentational Hint die aspect-ratio. */
.standort { display: flex; flex-direction: column; gap: 1.25rem; }
.standort__bild { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }

/* Portrait 80 rund, 20 px Abstand, rechts daneben alle vier Zeilen à 23 px. */
.standort__person { display: flex; gap: 1.25rem; align-items: center; }
.standort__portrait {
  flex: 0 0 5rem; width: 5rem; height: 5rem;
  object-fit: cover; border-radius: 50%;
}
.standort__daten { display: flex; flex-direction: column; min-width: 0; }
.standort__daten > * { line-height: 1.4375rem; }
.standort__daten > .textlink { width: fit-content; }

/* Der Öffner ist eine reine Mobile-Zutat — auf Desktop stehen Nummer und
   Mailadresse direkt auf der Karte. */
.standort__kontakt { display: none; }
/* stretch statt flex-start — die beiden Buttons füllen die Breite des
   Modal-Inhalts. Den Abstand zum Seitenrand liefert das padding von
   .modal__inhalt, hier wird nichts zusätzlich gerechnet. Das justify-content
   aus .btn-kontakt zentriert das Label dann von selbst. */
.kontakt__aktionen { display: flex; flex-direction: column; gap: 0.75rem; align-items: stretch; }

/* ============ FOOTER ============ */
.footer {
  display: flex; align-items: center; justify-content: space-between; gap: 1.25rem;
  padding-block: var(--gap-m);
}
.footer__rechtliches { display: flex; gap: 0.5rem; align-items: center; }

/* ============ MODALS ============ */
.modal {
  position: fixed; inset: 0; z-index: 50;
  height: 100dvh;
  display: none;
  align-items: center; justify-content: center;
  padding: 1.875rem;
}
.modal[open], .modal.is-open { display: flex; }

.modal__scrim {
  position: absolute; inset: 0;
  background: rgba(28, 26, 21, .4);
  animation: dissolve .2s ease-out;
}
/* Freigegebener Endwert für das Video-Modal (Neutralschwarz, nicht das warme
   #1c1a15 der übrigen Modals) — der Seiteninhalt soll hinter dem Video fast
   verschwinden. */
.modal--video .modal__scrim { background: rgba(0, 0, 0, .9); }

.modal__panel {
  position: relative;
  width: min(100%, 56.25rem);
  max-height: calc(100vh - 7.5rem);
  overflow: hidden;
  padding: 3.75rem;
  background: var(--page-bg);
  animation: dissolve .2s ease-out;
}
/* Gescrollt wird der Text, nicht das Panel: das Close-X sitzt absolut im Panel
   und wanderte beim Panel-Scroll mit nach oben aus dem Bild — in der
   Datenschutzerklärung um über 9000 px. Auf Mobile bleibt es beim Panel-Scroll,
   dort klebt das X per sticky mit (siehe Mobile-Block). */
.modal--impressum .modal__panel,
.modal--datenschutz .modal__panel,
.modal--sprache .modal__panel { display: flex; flex-direction: column; }
.modal--impressum .modal__inhalt,
.modal--datenschutz .modal__inhalt,
.modal--sprache .modal__inhalt { min-height: 0; overflow-y: auto; }
/* Figma 2730:2130: Bühne 1216 × 684 (16:9) bei 1440 × 900 — ringsum 112 px
   seitlich, 108 px oben wie unten. Die Ränder stehen als Padding am Modal, das
   Panel zentriert sich per Flex darin. Dadurch bleibt der Abstand nach oben und
   unten immer gleich groß, egal wie flach das Fenster ist. */
.modal--video { padding: 6.75rem 7rem; container-type: size; }
/* Auf der engeren Achse begrenzt der Rand die Bühne, auf der weiteren bleibt
   ohnehin Luft. Deshalb wird der Rand nur auf der jeweils begrenzenden Achse
   halbiert: quer liegendes Fenster oben/unten, hochkant links/rechts. Beide
   Ränder einer Achse bleiben dabei gleich groß, das Video behält sein
   Seitenverhältnis (Contain-Rechnung oben) und bleibt vollständig sichtbar. */
@media (orientation: landscape) {
  .modal--video { padding-block: 3.375rem; }
}
@media (orientation: portrait) {
  .modal--video { padding-inline: 3.5rem; }
}
/* Contain in 16:9 — dieselbe Rechnung wie bei der DDD-Bühne. aspect-ratio mit
   max-width/max-height reicht hier nicht: der Browser kappt nur eine Achse und
   verzerrt dadurch. Die Maße dürfen außerdem nicht am Video hängen, sonst springt
   das Modal beim Laden der Metadaten von 300 px auf Vollbild. */
/* Das Kontakt-Modal steht optisch für die Standortkarten, die auf
   --section-bg liegen. Der hellere --page-bg der übrigen Panels ließ es
   davor abheben. */
.modal--kontakt .modal__panel { background: var(--section-bg); }

.modal--video .modal__panel {
  width: min(100cqw, 100cqh * 16 / 9);
  height: min(100cqh, 100cqw * 9 / 16);
  max-height: none;
  padding: 0;
  background: none;
  overflow: visible;
}

/* 44 px sind hier ein Mindestmaß, keine Design-Größe: unterhalb von ~1260 px
   friert die fluide Root-Größe bei 14 px ein, 2.75rem wären dann nur noch 38,5 px
   und damit unter der Trefferfläche für Finger. */
.modal__close {
  position: absolute; right: 1.5rem; top: 1.5rem; z-index: 3;
  display: flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem;
  min-width: 44px; min-height: 44px;
  transition: opacity .2s ease-out;
}
.modal__close:hover { opacity: var(--hover-opacity); }
/* Dasselbe Icon wie im Akkordeon, nur gekippt. */
.modal__close .plus { transform: rotate(45deg); }
/* Figma 2730:2134: 44 × 44, 24 px von der oberen und rechten Videokante —
   also innerhalb der Bühne, nicht darüber. */
.modal--video .modal__close { right: 1.5rem; top: 1.5rem; color: #fff; }
/* Der globale Fokusring ist --text-black. Im Video-Modal liegt er auf dem
   dunklen Scrim bzw. (Mobile) als dunkler Ring auf dem hellen Close-Kreis —
   beide Male falsch herum. Hell ist hier der lesbare Kontrast. */
.modal--video .modal__close:focus-visible { outline-color: var(--page-bg); }
/* Das Panel bekommt beim Öffnen den Fokus (siehe modals() in main.js). Es ist der
   Container, nicht das Bedienelement, und zeigt deshalb keinen Ring. */
.modal__panel:focus { outline: none; }

/* Figma setzt zwischen allen Blöcken denselben Abstand — auch vor Überschriften. */
.modal__inhalt > * + * { margin-top: 1.5rem; }

/* Die Datenschutzerklärung führt volle Rechts-URLs (facebook.com/legal/terms/…)
   und lange Komposita, die keine Umbruchstelle anbieten. anywhere statt
   break-word, weil nur anywhere auch in die Mindestbreiten-Rechnung eingeht —
   sonst bleibt das Panel breiter als der Bildschirm, obwohl der Text umbricht. */
.modal__inhalt { overflow-wrap: anywhere; hyphens: auto; }

@keyframes dissolve { from { opacity: 0; } to { opacity: 1; } }

/* Video-Player */
.player { position: relative; width: 100%; height: 100%; background: #000; cursor: none; }
/* contain statt cover: lieber Balken als ein angeschnittenes Bild, falls das
   Quellvideo nicht exakt 16:9 liefert. */
.player__video { width: 100%; height: 100%; object-fit: contain; background: #000; }

/* Leiste liegt in der Bühne auf dem Video. Der Verlauf hält die weißen Bedien-
   elemente auch über hellen Bildinhalten lesbar — im Figma ist kein Player
   angelegt, das ist eine Umsetzungsentscheidung. */
.player__leiste {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
  display: flex; align-items: center; gap: 0.9375rem;
  padding: 1.875rem 1.25rem 0.9375rem;
  background: linear-gradient(to top, rgba(28, 26, 21, .8), rgba(28, 26, 21, 0));
  color: #fff;
  opacity: 0;
  transition: opacity .3s ease-out;
  pointer-events: none;
}
/* Sichtbar, solange die Maus sich über der Bühne bewegt — siehe player() in main.js. */
.player.zeigt-bedienung .player__leiste { opacity: 1; pointer-events: auto; }
.player.zeigt-bedienung { cursor: default; }

.player__btn {
  display: flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem; flex: 0 0 2.75rem;
  transition: opacity .2s ease-out;
}
.player__btn:hover { opacity: var(--hover-opacity); }
/* Beide Zustände liegen im Markup, sichtbar ist der, den steuerung() gerade
   auf data-zustand gesetzt hat. */
.player__icon { display: none; }
.player__btn[data-zustand="play"] [data-fuer="play"],
.player__btn[data-zustand="pause"] [data-fuer="pause"],
.player__btn[data-zustand="laut"] [data-fuer="laut"],
.player__btn[data-zustand="stumm"] [data-fuer="stumm"] { display: block; }
/* Die Größe steht hier und nicht an den width/height-Attributen der SVGs, damit
   beide Zustände eines Buttons zwangsläufig gleich groß bleiben und beim
   Umschalten nichts springt. Bei 1440 sind das 22 bzw. 32 px. */
.player__btn--play .player__icon { width: 1.375rem; height: 1.375rem; }
.player__btn--ton .player__icon { width: 2rem; height: 2rem; }

.player__spur {
  flex: 1 1 auto;
  appearance: none; -webkit-appearance: none;
  height: 0.4375rem; border-radius: var(--radius-voll);
  background: rgba(246, 244, 236, .35);
  cursor: pointer;
}
.player__spur::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 0.9375rem; height: 0.9375rem; border-radius: 50%;
  background: #fff;
}
.player__spur::-moz-range-thumb {
  width: 0.9375rem; height: 0.9375rem; border: 0; border-radius: 50%;
  background: #fff;
}
.player__zeit { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

body.modal-offen { overflow: hidden; }

/* ============ HERO-INTRO (~5,2 s, einmalig) ============ */
/* Startzustand nur setzen, wenn JS die Timeline auch fährt — und nur solange sie
   läuft. Der Endzustand ist bewusst kein eigener Regelsatz: sonst überschriebe er
   den Hover der Hero-Elemente (gleiche Spezifität, aber später in der Datei). */
.js:not(.intro-fertig) .header,
.js:not(.intro-fertig) .hero__h1,
.js:not(.intro-fertig) .hero__cta,
.js:not(.intro-fertig) .hero__textlink { opacity: 0; }

/* ACHTUNG, nicht auf opacity umbauen: Das <video> ist die einzige Ebene im
   Hero, die der Kompositor eigenständig verwaltet. Läuft es mit animierter
   oder Null-Deckkraft dekodierend mit, kann jedes Ereignis, das den
   Animationszustand dieser Kompositor-Ebene neu setzt (erster dekodierter
   Frame, Neupromotion, Wechsel auf eine Overlay-Plane), einen einzelnen Frame
   erzeugen, in dem die Ebene OHNE die animierte Deckkraft eingeblendet wird —
   ein dunkelgraues Rechteck in D-Form, auf Desktop wie Mobile. Keine
   DOM-Messung kann das sehen, weil die berechnete Deckkraft korrekt 0 ist.
   Konstruktion deshalb: Die Videoebene startet visibility:hidden — dann
   entsteht gar keine Kompositor-Ebene — und wird per Klasse hart sichtbar
   geschaltet, wenn BEIDE Bedingungen erfüllt sind: ein Frame ist nachweislich
   dekodiert (requestVideoFrameCallback bzw. loadeddata + rAF) UND die Timeline
   ist an der Stelle, an der das Video gezeigt werden soll. Es wird dabei NICHT
   eingeblendet, sondern erscheint deckend hinter den noch voll deckenden Ds
   (Fenster 2,196–3,324 s). Damit trägt die Videoebene zu keinem Zeitpunkt eine
   animierte oder eine Null-Deckkraft — der Fehlerfall braucht aber genau das.
   Er ist konstruktionsbedingt ausgeschlossen, nicht nur unwahrscheinlich. */
.js .ddd__video-el { visibility: hidden; }
.js .ddd--video-an .ddd__video-el { visibility: visible; }

.js:not(.intro-fertig) .ddd__d--1 { transform: translateX(-28.125rem); }
.js:not(.intro-fertig) .ddd__d--2 { transform: translateY(-44.375rem); }
.js:not(.intro-fertig) .ddd__d--3 { transform: translateX(28.125rem); }

/* Die Bühne startet in voller D1-3-Höhe (1380 × 700) und schrumpft auf
   das Videoformat (mask-video, 1380 × 467). Weil die Bühne jetzt den Restplatz
   über Flex bekommt, darf die Intro keine Höhen animieren — sie streckt die
   Deko-Ebenen stattdessen von der Oberkante aus. */
/* Die Videoebene MUSS mitskalieren. Sie trägt die Deckfläche der Bühne;
   bliebe sie ungestreckt, läge dieselbe D-Kurve in einer anderen Boxhöhe als
   die farbigen Ds. Zwei Kurven mit gleicher Maximalbreite, aber verschiedener
   Höhe laufen in absoluten Koordinaten auseinander — sichtbar als beiger Saum
   rechts neben den farbigen Ds während der ganzen Intro. */
.js:not(.intro-fertig) .ddd__anim01,
.js:not(.intro-fertig) .ddd__video,
.js:not(.intro-fertig) .ddd__d13 { transform: scaleY(var(--ddd-start)); }

.intro .header {
  animation: fade-in .201s cubic-bezier(.5, 0, .5, 1) .691s both;
}
.intro .ddd__d--1 { animation: d1-ein 1.5s cubic-bezier(.22, 1, .36, 1) .701s both; }
.intro .ddd__d--2 { animation: d2-ein 1.5s cubic-bezier(.22, 1, .36, 1) .701s both; }
.intro .ddd__d--3 { animation: d3-ein 1.5s cubic-bezier(.22, 1, .36, 1) .701s both; }

.intro .ddd__anim01 { animation: fade-out .05s cubic-bezier(.5, 0, .5, 1) 1.86s both; }

.intro .ddd__video,
.intro .ddd__d13 { animation: buehne-schrumpfen 1.128s cubic-bezier(.22, 1, .36, 1) 2.196s both; }

/* Video-Erscheinen und Ds-Ausblende dürfen sich NICHT überlappen: zwei
   unabhängig blendende Ebenen decken zusammen nie voll — die freie Fläche ist
   (1−Video)·(1−Ds) — und durch diese Lücke mischt sich der helle
   Seitenhintergrund ins Bild. Deshalb erscheint das Video in einem Schritt
   deckend (siehe .ddd--video-an oben), während es noch vollständig hinter den
   voll deckenden Ds liegt; erst danach blenden die Ds aus, über einem bereits
   voll deckenden Video. Zu jedem Zeitpunkt ist so mindestens eine Ebene voll
   deckend; ein Frame ohne Deckung kann es konstruktionsbedingt nicht geben. */
.intro .ddd__anim02 { animation: fade-out 1.201s cubic-bezier(.5, 0, .5, 1) 3.324s both; }

/* H1 und Button laufen WÄHREND der Bühnen-Schrumpfung (2,196 s – 3,324 s)
   nach oben ein. Sie starten aber erst 0,35 s nach deren Beginn: Die Bühne wächst
   in der Intro um 233/467 ihrer Höhe nach unten in den Hero hinein und zieht sich
   mit cubic-bezier(.22,1,.36,1) in den ersten ~30 % der Laufzeit auf wenige Pixel
   zusammen. Ab 2,55 s ist ihr Überstand kleiner als der Startversatz der H1
   (6,25 rem) — von da an bleibt der Text durchgehend unterhalb der D-Form. Der
   z-index auf .ddd deckt den Rest ab. Button 0,2 s versetzt (Stagger). */
.intro .hero__h1 { animation: von-unten 1s cubic-bezier(.22, 1, .36, 1) 2.55s both; }
.intro .hero__cta { animation: von-unten-klein 1s cubic-bezier(.22, 1, .36, 1) 2.75s both; }
.intro .hero__textlink { animation: fade-in .701s cubic-bezier(.5, 0, .5, 1) 4.525s both; }

@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes d1-ein { from { transform: translateX(-28.125rem); } to { transform: none; } }
@keyframes d2-ein { from { transform: translateY(-44.375rem); } to { transform: none; } }
@keyframes d3-ein { from { transform: translateX(28.125rem); } to { transform: none; } }
@keyframes buehne-schrumpfen {
  from { transform: scaleY(var(--ddd-start)); }
  to { transform: scaleY(1); }
}
@keyframes von-unten {
  from { opacity: 0; transform: translateY(6.25rem); }
  to { opacity: 1; transform: none; }
}
@keyframes von-unten-klein {
  from { opacity: 0; transform: translateY(3.125rem); }
  to { opacity: 1; transform: none; }
}

/* Nach der Timeline liegen die D-Formen als Video-Maske vor, die farbigen
   Deko-Ebenen werden nicht mehr gebraucht. Ohne JS bleiben sie stehen — dort gibt
   es kein Video, das sie ersetzen könnte. */
.js.intro-fertig .ddd__anim01,
.js.intro-fertig .ddd__anim02 { opacity: 0; }

/* ============ RESPONSIVE ============ */

/* < 1440: Typo skaliert über die fluide Root-Größe — hier nur noch das Raster.
   Die festen Spaltenbreiten weichen prozentualen, damit nichts überläuft. */
@media (max-width: 1439px) {
  .keyfacts__fix, .bauaufgaben__intro { width: 47%; }
  .testi { grid-template-columns: 47% 1fr; }
  .testimonials__inhalt > h2 { padding-left: calc(47% + var(--page-pad)); }
}

/* Tablet: 3 Ds bleiben, zweispaltiges Raster wird schmaler */
@media (max-width: 1024px) {
  /* Zwei Karten nebeneinander sind hier zu schmal für die 38-px-Überschriften
     ("Architekturvisualisierung" bricht nicht) — deshalb schon ab Tablet gestapelt. */
  /* Sobald gestapelt wird, sind Leistungen und Expertisen zwei eigene
     Blöcke — sie bekommen den Blockabstand der Nachbarsektionen statt des
     engeren Spaltenabstands aus dem Desktop-Raster. */
  .ss03 { flex-direction: column; gap: var(--gap-m); }
  /* Gestapelt liegt die Flex-Hauptachse senkrecht: die Basis 0 aus flex:1 1 0 gilt
     dann für die HÖHE. Zusammen mit min-height:0 (das den automatischen
     Mindestgröße-Schutz aufhebt) fielen beide Karten auf ihr Padding zusammen und
     der Inhalt lief übereinander hinaus. Basis auto stellt die Inhaltshöhe her. */
  .karte { flex: 0 0 auto; min-height: 0; }
  .abbinder { height: 520px; }
  .standorte__grid { grid-template-columns: repeat(2, 1fr); gap: var(--gap-m) var(--gap-xs); }
  .hslide__fuss { gap: 2rem; }
}

/* Mobile */
@media (max-width: 720px) {
  :root { --page-pad: 20px; }

  .t-display { font-size: 68px; }
  .t-h1, .t-h2 { font-size: 32px; }
  .t-38-medium { font-size: 26px; }
  .t-34, .t-34-medium { font-size: 22px; }
  .t-24 { font-size: 18px; }

  /* Wortmarke bleibt einzeilig: Wortmarke + Zusatz messen zusammen 21,2 em, mit
     Gap 21,7 em — die Schrift skaliert deshalb mit der verfügbaren Breite. */
  .wordmark {
    gap: 0.5em;
    font-size: min(1.0625rem, calc((100vw - 2 * var(--page-pad)) / 22));
  }
  .wordmark .t-wortmarke,
  .wordmark .t-subline { font-size: 1em; white-space: nowrap; }

  /* Hero: nur ein D, nur D-Blue — Seitenverhältnis aus mask-mobile.svg (444 × 467) */
  .ddd { --ddd-ratio: calc(444 / 467); --ddd-ratio-inv: calc(467 / 444); }
  /* Das beige D bleibt auch auf Mobile der Ausgangszustand der Intro —
     nur als ein D statt drei (Quelle über <source> im Markup). */
  .ddd__d--2, .ddd__d--3 { display: none; }
  .ddd__d13 { gap: 0; }
  /* Videoebene und farbige D-Ebene tragen dieselbe Mobile-Silhouette —
     das einfahrende D-Blue bleibt damit auch im Initialzustand in der Form. */
  .ddd__video,
  .ddd__d13 {
    -webkit-mask-image: url("../img/d/mask-mobile.svg");
    mask-image: url("../img/d/mask-mobile.svg");
  }
  /* Touch: Tap aufs Video öffnet das Modal. Die zweite Selektorstufe ist nötig,
     weil .intro-fertig .film-button sonst spezifischer wäre — Media
     Queries erhöhen die Spezifität nicht. */
  .film-button, .intro-fertig .film-button { display: none; }
  .ddd__video { cursor: pointer; }

  /* Der Hero ist auf Mobile NICHT an die Viewporthöhe gebunden. Die
     Bühne bekommt genau die Höhe des einen D (Maske 444 × 467), sodass oben und
     unten kein Leerraum entsteht und der Folgeinhalt schon anschneidet.
     Die Bühne braucht eine definite Höhe, weil container-type:size sonst keine
     cqh-Basis hat und .ddd__inner auf 0 fiele. */
  /* max-height: none — der 100dvh-Deckel von .oben gilt nur fürs
     Desktop-Above-the-fold. */
  .oben { height: auto; max-height: none; }
  .ddd { flex: 0 0 auto; height: calc((100vw - 2 * var(--page-pad)) * var(--ddd-ratio-inv)); }

  /* Die manuellen Umbrüche gelten nur fürs Desktop-Raster — auf Mobile
     bricht der Text von allein. Das <br> bleibt im Markup stehen. */
  .hero__h1 br,
  .details > .t-34 br,
  .abbinder__statement br,
  .standorte__kopf .t-34 br { display: none; }

  /* Ohne die manuellen Umbrüche fällt der Flattersatz auseinander — balance
     verteilt die Zeilen gleichmäßig. Nur hier auf Mobile, damit die
     gesetzten Umbrüche aus dem Figma-Entwurf ab Tablet unberührt bleiben. */
  .hero__h1,
  .abbinder__statement { text-wrap: balance; }

  .marquee-bar { padding-block: var(--gap-m); }
  .marquee { padding: 20px; }
  /* Eine Größe fürs ganze Laufband — dieselbe 22 px wie ".t-34" bei
     "Wir sind Architekten …". Die Pillen erben sie, statt eine zweite Größe
     mitzuführen; Höhe und Innenabstand wachsen proportional mit. */
  .marquee__kachel { font-size: 22px; }
  .pill { height: 34px; font-size: inherit; padding-inline: 14px; }

  .keyfacts { flex-direction: column; gap: var(--gap-s); padding-bottom: var(--gap-m); }
  .keyfacts__fix { width: 100%; }
  /* Spiegelt die Mobile-Überschreibungen von .t-display (68px) und .t-24 (18px) —
     die Maske rechnet sich daraus wie oben selbst aus. */
  .ticker { --zahl: 68px; --text: 18px; }
  .ticker__track { padding-inline: 0; }

  .karte { padding: 20px; }
  .karte:hover::before { opacity: 0; }   /* kein Hover auf Touch */
  .akk__body > p { max-width: none; }
  /* "Architekturvisualisierung" ist bei 26 px breiter als die Karte — ohne
     min-width:0 schiebt der Titel das Plus-Icon aus der Karte heraus. */
  .akk__head > span:first-child { min-width: 0; overflow-wrap: break-word; }

  .bauaufgaben { flex-direction: column; gap: var(--gap-m); padding-block: var(--gap-m); }
  .bauaufgaben__intro { width: 100%; }

  /* Kein Bild-Parallax auf Mobile. Die Maske übernimmt das
     Seitenverhältnis des Mobile-Zuschnitts — dadurch füllt das Bild sie exakt
     aus: nichts wird verzerrt, und object-fit hat nichts zu beschneiden (es
     fängt nur Rundungsreste im Subpixelbereich ab).
     Das transform setzt highlightSlider() auf Mobile gar nicht erst (main.js).
     Der Wert kommt jetzt je Slide aus --ar-mobil (index.html), weil es zwei
     Mobile-Formate gibt. Der Default hier ist der häufigere der beiden und greift
     nur, wenn an einer Maske die Angabe fehlt. */
  .hslide__mask { aspect-ratio: var(--ar-mobil, 1200 / 801); }

  /* Ohne flex-start dehnt der Flex-Container alle Slides auf die Höhe des
     höchsten — dann meldet jeder Slide dieselbe Höhe und die Messung in
     hoeheAngleichen() hätte nichts zu messen. */
  .hslider__track { align-items: flex-start; }

  /* Die Höhe wird von hoeheAngleichen() als fester px-Wert gesetzt; hier
     steht nur die Blende dazu. Als eigene Klasse, damit die allererste Zuweisung
     beim Laden NICHT animiert — sonst wächst der Slider sichtbar aus 0 heraus.
     JS schaltet die Klasse erst nach dem ersten Setzen scharf. */
  .hslider--hoehe { transition: height .35s ease-in-out; }
  .hslide__img {
    top: 0; height: 100%;
    object-fit: cover; object-position: center;
    will-change: auto;
  }

  /* Titel und Projektdaten stehen untereinander, der Indikator senkrecht
     links daneben. Grid statt Flex, weil nur damit ein Element über beide
     Zeilenblöcke greifen kann (grid-row 1/-1) und so genau deren gemeinsame
     Höhe bekommt. */
  .hslide__fuss {
    display: grid;
    grid-template-columns: auto 1fr;
    /* Beide Zeilen müssen explizit stehen: ohne grid-template-rows liegen sie im
       impliziten Raster, dort zeigt die Linie -1 auf Linie 1 — der Indikator kam
       so auf 0 px Höhe. */
    grid-template-rows: auto auto;
    column-gap: 0.875rem; row-gap: 0.25rem;
    padding-left: 0;
  }
  .hslide__titel { grid-area: 1 / 2; }
  .hslide__daten { grid-area: 2 / 2; text-align: left; }
  /* Linksbündig gestapelt dürfen Angaben wieder intern umbrechen: die längsten
     Segmente (Vahle, Woolworth) sind breiter als die Mobilspalte und liefen
     mit nowrap rechts aus dem Bild. */
  .hslide__seg { white-space: normal; }

  /* Aus dem Ring wird ein Balken: dieselbe Rolle, dieselben Farben, dieselbe
     Quelle (--p aus highlightSlider). Der Fortschritt läuft als Hintergrund-
     bild von oben nach unten, die Grundfarbe bleibt die Spur. */
  .hslider__progress {
    position: static;
    grid-area: 1 / 1 / -1 / 2;
    /* align-items: flex-start steht in der Basisregel und gilt im Grid weiter —
       ohne dieses stretch bleibt der Balken auf seiner Inhaltshöhe von 0 px. */
    align-self: stretch;
    width: 3px; height: auto; border-radius: var(--radius-voll);
    background-color: var(--linie-grey);
    background-image: linear-gradient(var(--text-black), var(--text-black));
    background-repeat: no-repeat;
    background-size: 100% calc(var(--p, 0) * 100%);
    -webkit-mask: none; mask: none;
  }

  /* Reihenfolge Mobile: erst D-FORM-Parallax, dann Kundenstimmen.
     Oben nur der kleine Blockabstand — zusammen mit dem padding-bottom
     der Referenzen stünden sonst 75 px zwischen Projekte-Slider und D-Form.
     Keine Mindesthöhe im Fluss-Layout: die D-Form ist hier ein normales
     Flex-Kind, die Sektion trägt ihren Inhalt (66,5rem @14px-Root würden die
     Ein-Testimonial-Sektion sonst um ein paar Pixel aufspannen). */
  .testimonials {
    display: flex; flex-direction: column;
    min-height: 0;
    padding-top: var(--gap-s); padding-bottom: var(--gap-m);
  }
  /* top/translate der Desktop-Zentrierung zurücksetzen — im Fluss (relative)
     würde top:50% die Box verschieben. width+height gesetzt ⇒ aspect-ratio
     der Basisregel bleibt wirkungslos. */
  .dform {
    position: relative; order: -1;
    top: 0; translate: none;
    width: 100%; height: 88vw;
    margin-bottom: var(--gap-m);
  }
  .dform__vektoren { width: 84%; height: 100%; }
  /* Zentrierung (top 50 % / translate) kommt aus der Basisregel. */
  .dform__bild { width: 93%; height: 44%; }
  .testimonials__inhalt > h2 { padding-left: 0; }
  .testi { grid-template-columns: 1fr; gap: 0; }
  .testi > * { grid-column: 1; }
  .testi:first-child { padding-top: var(--gap-s); }
  .testi:last-child { padding-bottom: var(--gap-s); }

  /* Auf Mobile steht die D-Form als normales Flex-Kind im Fluss und ragt nirgends
     heraus — der Desktop-Vorlauf von .team entfällt hier komplett. */
  .team { gap: var(--gap-s); padding-top: 0; padding-bottom: var(--gap-m); }
  /* 2,5 Karten sichtbar: 2,5 × Karte + 2 × Gap = sichtbare Spurbreite. */
  .kopf { flex: 0 0 31vw; width: 31vw; }
  .kopf__bild { width: 100%; height: auto; aspect-ratio: 1; }

  .abbinder { height: 420px; margin-bottom: var(--gap-s); }
  .abbinder__d::before, .abbinder__d::after { width: 620px; height: 654px; }
  .abbinder__d::before { left: calc(50% - 130px); top: 240px; }
  .abbinder__d::after { left: calc(50% - 700px); top: -300px; }
  .abbinder__statement { padding: var(--gap-s) var(--page-pad); min-height: 0; }

  .standorte { gap: var(--gap-s); }
  .standorte__kopf { gap: var(--gap-s); }
  .standorte__grid { grid-template-columns: 1fr; gap: var(--gap-m); }
  /* Ortsnamen auf dem Niveau der Akkordeon-Titel (.t-38-medium). */
  .standort__ort { font-size: 26px; }
  /* Dasselbe Element — Portrait unter dem Standort etwas größer. */
  .standort__portrait { flex-basis: 6.25rem; width: 6.25rem; height: 6.25rem; }
  /* Das Portrait schließt unten mit dem Button ab. Die Größe ist aus den
     Werten der Textspalte gerechnet, nicht geraten: Name + Rolle (je 1,4375rem
     Zeilenhöhe) + 1,25rem Abstand + 3,25rem Buttonhöhe. Kein align-self:stretch —
     ein <img> zieht seine flex-basis aus der intrinsischen Bildbreite, sobald das
     lazy geladene Bild da ist, und wird dann riesig. Nur die Karte, nicht das
     Kontakt-Modal. */
  .standort > .standort__person > .standort__portrait {
    --portraitgroesse: calc(2 * 1.4375rem + 1.25rem + 3.25rem);
    flex-basis: var(--portraitgroesse);
    width: var(--portraitgroesse); height: var(--portraitgroesse);
  }
  /* Auf der Karte stehen nur noch Name und Rolle; Nummer und Mailadresse
     wandern ins Kontakt-Modal hinter den Öffner. */
  .standort__daten > .textlink { display: none; }
  /* Der Öffner steht in der Textspalte unter „Standortleitung", also an der
     Kante von Name und Rolle — nicht an der Kartenkante unter dem Portrait.
     align-self:flex-start hält ihn auf seiner Textbreite, nowrap verhindert den
     Umbruch, wenn die Spalte durch ein größeres Portrait schmaler wird. */
  .standort > .standort__person { align-items: flex-start; }
  .standort__kontakt {
    display: inline-flex; align-self: flex-start;
    margin-top: 1.25rem; white-space: nowrap;   /* = gap von .standort und .standort__person */
  }
  .modal--kontakt .standort__portrait { flex-basis: 5rem; width: 5rem; height: 5rem; }

  .footer { flex-direction: column; gap: var(--gap-s); text-align: center; }
  /* gleiche Schriftgröße wie die Kontaktdaten neben den Profilblöcken. */
  .footer__rechtliches .textlink { font-size: 0.9375rem; }

  /* Modals fullscreen */
  .modal { padding: 0; }
  .modal__panel {
    width: 100%; max-width: none; height: 100dvh; max-height: none;
    padding: 70px 20px 40px;
  }
  /* Video bleibt auch fullscreen bei seinem Seitenverhältnis und sitzt mittig.
     Ohne Padding fällt die Contain-Rechnung aus .modal--video auf die volle
     Bildschirmgröße — hier ist die Breite die engere Grenze. */
  .modal--video { padding: 0; }
  .modal--video .modal__panel { padding: 0; }
  /* Fullscreen scrollt wieder das Panel selbst — das sticky X bleibt dabei oben. */
  .modal--impressum .modal__panel,
  .modal--datenschutz .modal__panel,
  .modal--sprache .modal__panel { display: block; overflow-y: auto; }
  .modal--impressum .modal__inhalt,
  .modal--datenschutz .modal__inhalt,
  .modal--sprache .modal__inhalt { overflow: visible; }
  /* Das X saß per sticky im Textfluss und damit hinter dem padding-top des
     Panels — 70 px unter der Bildschirmkante, während es im Video-Modal auf
     1,5 rem sitzt. position: fixed setzt es auf exakt dieselbe Position und hält
     es beim Scrollen dort; die Modals sind auf Mobile ohnehin fullscreen.
     (Ein Containing Block entsteht nicht: .modal__panel animiert nur opacity.) */
  .modal__close { position: fixed; float: none; right: 1.5rem; top: 1.5rem; }
  .modal--video .modal__close { position: absolute; right: 1.5rem; top: 1.5rem; }

  /* Auf Mobile bekommt das X eine gefüllte Kreisfläche. Nicht nur der Optik
     wegen — bei Impressum und Datenschutz steht das X per fixed über dem
     scrollenden Text, ohne Deckfläche liefen die Zeilen sichtbar durch die
     Striche. Deshalb deckend, nicht transparent. Die Farbe kehrt sich im
     Video-Modal um: dort ist der Grund fast schwarz, ein dunkler Kreis wäre
     unsichtbar. Die 44 px stehen schon oben als min-width/min-height. */
  .modal__close {
    border-radius: 50%;
    background: var(--text-black);
    color: var(--page-bg);
  }
  .modal--video .modal__close {
    background: var(--page-bg);
    color: var(--text-black);
  }

  /* ---- Kontakt-Standort als Bottom-Sheet ----
     Das Kontakt-Modal ist das einzige mit so wenig Inhalt, dass Fullscreen
     übertrieben wirkt. Es sitzt jetzt unten und wächst nach oben mit: height
     auto statt 100dvh, max-height als Deckel für den Fall, dass die Schrift
     hochgestellt ist. Der Scrim wird hell, damit die Seite darüber sichtbar
     bleibt — der Tap darauf schließt weiterhin (Handler in modals()). */
  .modal--kontakt { align-items: flex-end; }
  .modal--kontakt .modal__scrim { background: rgba(246, 244, 236, .75); }
  .modal--kontakt .modal__panel {
    width: 100%; height: auto; max-height: 88dvh;
    overflow-y: auto;
    padding: 4.5rem var(--page-pad) var(--gap-m);
    border-radius: 1.25rem 1.25rem 0 0;
  }
  /* Im Sheet gehört das X ins Panel, nicht an die Bildschirmkante — fixed säße
     sonst oben im leeren Scrim. */
  .modal--kontakt .modal__close { position: absolute; right: var(--page-pad); top: 1.25rem; }
}

/* ============ REDUCED MOTION ============ */
@media (prefers-reduced-motion: reduce) {
  .intro *, .intro { animation: none !important; }
  .marquee__track, .ticker__track, .hslider__track,
  .kslider__track, .kslider__griff { transition: none !important; }

  .hslide__img,
  .dform__vektoren, .dform__bild,
  .abbinder__d, .abbinder__statement { transform: none !important; }

  .modal__scrim, .modal__panel { animation: none; }
  .marquee:hover { transform: none; }
  * { transition-duration: .01ms !important; }
}
