/* ==========================================================================
   PROJECT-FAN
   Zweiter, eigenständiger Projekt-Slider auf der Startseite (s.
   project-fan-section.php). Zwei kombinierte Mechaniken für Desktop:

   1) Makro-Navigation: assets/js/project-fan.js verschiebt
      .project-fan__track per transform: translate3d(...) abhängig von der
      Maus-X-Position im Viewport (kein natives Scrollen) — skaliert auch
      mit vielen Projekten, da .project-fan__viewport nur ein Sichtfenster
      ist und die Trackbreite frei mit der Projektanzahl wächst.
   2) Mikro-Interaktion: einzelne Karten wachsen per :hover/:focus-within
      über CSS flex-grow (reines CSS, kein JS nötig). Bewusste Ausnahme von
      der sonstigen "nur transform/opacity"-Konvention (s. components.css) —
      flex-grow verändert echtes Layout, das ist hier Kern des gewünschten
      Effekts ("Nachbarkarten werden verdrängt"), nicht optional.

   Auf Touch-Geräten (Handy UND Tablet, s. html[data-device="touch"]-Block
   am Dateiende) wird die Sektion stattdessen ein normaler Swipe-Slider mit
   3:2-Karten und Caption unter dem Bild: kein JS-Scrub, kein Hover-Expand,
   kein Tooltip.
   ========================================================================== */

.project-fan {
  padding-block: var(--space-2xl);
  /* Section füllt mindestens den Viewport (schwarzer Hintergrund, s. Vorgabe
     "Section id=work"). min- statt height: sobald oberhalb des Sliders die
     geplanten zwei Vorschau-Projekte ergänzt werden, wächst die Section
     automatisch über 100vh hinaus statt den zusätzlichen Inhalt zu clippen. */
  min-height: 100vh;
  background: var(--color-section-dark-background);
  display: flex;
  flex-direction: column;
  /* Fester Abstand zwischen .wrapper (Heading + Lead) und
     .project-fan__viewport. Bewusst KEIN margin-top:auto mehr auf dem
     Viewport (früherer Ansatz, bottom-pinning in der 100vh-Section): das
     hätte den kompletten Freiraum im Flex-Container zusätzlich zum gap
     aufgesaugt, wodurch der sichtbare Abstand fast immer deutlich größer
     als beabsichtigt ausgefallen wäre statt dem exakten Wert unten zu
     entsprechen.
     KONVENTION (s. --section-inner-gap in tokens.css): Abstand zwischen
     Objekten INNERHALB einer Section ist immer 20% kleiner als
     --section-gap – der volle --section-gap-Wert bleibt dem Abstand
     ZWISCHEN Sections vorbehalten. Beide Tokens liegen jetzt in tokens.css
     (wird von html-start.php immer geladen), der frühere duplizierte
     Fallback gegen das nie geladene index.css ist damit hinfällig. */
  gap: var(--section-inner-gap);
  /* Kartenabstand als eigene Custom Property: wird sowohl vom Track (gap)
     als auch von der Kartenhöhen-Formel unten referenziert — bei einer
     Änderung bleiben beide automatisch synchron. */
  --project-fan-gap: calc(var(--space-sm) * 1.3);
  /* Ruhe-Kartengröße: festes HOCHKANT-Seitenverhältnis 3,5:6 (B:H),
     Zielhöhe 50vh. Harte Nebenbedingung: mindestens 3,5 Karten müssen
     gleichzeitig in die Reihe passen (3,5 Kartenbreiten + 3 sichtbare
     Lücken + linkes Track-Padding ≤ 100vw). Aufgelöst nach der Höhe:
       H = W * 6/3,5  und  W_max = (100vw - Padding - 3*Gap) / 3,5
       ⇒ H_max = (100vw - Padding - 3*Gap) / 3,5 * 6/3,5
     min() nimmt davon und den gewünschten 50vh immer den kleineren Wert —
     im Hochkant-Format gewinnen auf üblichen Desktop-Formaten die 50vh
     (die schmalen Karten passen locker 3,5-fach nebeneinander), erst auf
     schmalen Fenstern greift die 3,5-Karten-Bedingung und skaliert die
     Karten herunter. */
  --project-fan-card-height: min(
    50vh,
    calc((100vw - var(--container-padding) - 3 * var(--project-fan-gap)) / 3.5 * (6 / 3.5))
  );
  --project-fan-rest-width: calc(var(--project-fan-card-height) * (3.5 / 6));
  /* Einzige Quelle für die Hover-Breite: von .project-fan__item-wrap:hover
     (Layout) UND .project-fan__media (Bildgröße) referenziert, damit beide
     immer zusammen skalieren statt bei Änderungen auseinanderzulaufen.
     An die Kartenhöhe gekoppelt statt an vw: die aufgeklappte Karte hat
     dadurch IMMER exakt das Querformat 3:2 (B:H) — die Höhe bleibt beim
     Hover konstant (s. .project-fan__item-wrap), nur die Breite wächst von
     der Hochkant-Ruhe-Breite (3,5:6) auf Höhe * 3/2. */
  /* Faktor kommt aus --project-media-ratio (tokens.css) — geteilte Quelle
     mit .project-card__media (components.css), damit beide "aufgeklappten"
     Formate (Fan-Hover-Karte / Project-Card) immer synchron bleiben. */
  --project-fan-hover-width: calc(var(--project-fan-card-height) * var(--project-media-ratio));
}

/* Optik von .project-fan__heading kommt von .section-heading (components.css)
   — Klasse steht im Markup neben dieser BEM-Klasse, die hier nur als Hook
   für künftige section-spezifische Overrides erhalten bleibt. */

/* .project-fan ist jetzt eine Flex-Column (s. o.) — als Flex-Kind mit
   margin-inline:auto würde .wrapper sonst auf Inhaltsbreite schrumpfen und
   von den auto-Margins mittig zentriert (auto-Cross-Margins schalten das
   Default-stretch aus), die Überschrift stünde dadurch mittig statt links.
   width:100% stellt die normale Block-Geometrie wieder her: volle Breite
   bis max-width, die auto-Margins zentrieren nur die BOX (gleiches
   Problem/gleiche Lösung wie im Tablet-Block am Dateiende).
   Gleiches 2-Spalten-Grid wie .services__row (services.css: 1fr 1fr,
   --space-lg-Spaltenabstand) für Heading/Lead (Reihe 1) und den
   linksbündigen Zusatztext (Reihe 2, s. .project-fan__subtext unten) —
   dadurch bleibt die Breiten-/Abstandslogik zwischen beiden Sections
   konsistent, statt sie hier unabhängig neu zu erfinden. */
.project-fan > .wrapper {
  width: 100%;
}

.project-fan__intro {
  display: grid;
  /* minmax(0, 1fr) statt reinem 1fr: 1fr ist Kurzform für minmax(auto, 1fr)
     — die "auto"-Minimalgröße lässt Chrome/Firefox die Spalte trotz
     min-width:0 auf dem Item (.project-fan__lead) bis zu dessen expliziter
     width (65vw) aufblähen, sobald overflow:visible ist (Default). Das zieht
     die ganze Spalte (und mit ihr Wrapper/Section/Body) breiter als den
     Viewport — klassisches Grid-"Blowout". minmax(0, 1fr) deckelt die
     Spalten-Minimalgröße hart auf 0 und erzwingt den echten 50/50-Split;
     .project-fan__lead darf danach wie vorgesehen über die eigene
     Spaltengrenze hinaus überlaufen (justify-self:end + width:65vw dort),
     ohne die Spalte selbst mitzuziehen. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: var(--space-lg);
  /* row-gap bewusst 0 (gleiche Begründung wie .services__intro,
     services.css): Der Abstand unter Heading/Lead kommt allein aus deren
     margin-bottom (--space-lg via .section-heading bzw. .heading-gap) —
     ein zusätzlicher row-gap machte den realen Abstand hier größer als in
     den anderen Sections. */
  row-gap: 0;
  align-items: start;
}

.project-fan__heading {
  grid-column: 1;
  grid-row: 1;
}

/* Übersichtstext der Work-Übersichtsseite (Feld "lead", writer-Feld,
   s. site/blueprints/pages/work.yml) — rechte Spalte des Intro-Grids,
   rechtsbündig, über dem Slider. Trägt zusätzlich die geteilte
   .intro-heading-Utility (components.css) auf dem Wrapper-Div
   (s. project-fan-section.php) — Schriftfamilie/-größe/-stil, Zeilenhöhe
   und Trennungsverhalten damit identisch zu .services__heading
   (services.css), beide erben vom gleichen Stil. Bewusst KEIN eigenes
   font-size mehr hier (s. Kommentar an .intro-heading, components.css) —
   Hier nur, was section-spezifisch bleibt.
   width bewusst NICHT mehr die 1fr-Spaltenbreite des Intro-Grids (stretch,
   Default bei Grid-Items): eine reine vw-Einheit statt %/fr macht die Breite
   zu einer festen Funktion allein der Viewport-Breite – unabhängig von der
   Breite der Heading-Spalte daneben oder vom Textinhalt selbst. Die ganze
   Box wächst/schrumpft dadurch proportional mit der Fenstergröße mit.
   justify-self:end nötig, weil das Grid-Item sonst (Default: stretch) auf
   volle Spaltenbreite gezogen würde und die explizite width wirkungslos
   bliebe – hält die Box weiterhin rechtsbündig an der Spaltenkante.
   min-width:0 als generelle Absicherung gegen Grid-Items' Default
   min-width:auto (Content-Minimum) – verhindert, dass ein einzelnes
   unbrechbares Wort (z.B. eine lange URL) die 1fr-Spalte über die
   eigentliche Breite hinaus aufbläht. */
.project-fan__lead {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  /* 48vw (~volle Spaltenbreite des 1fr-1fr-Intro-Grids, s. .project-fan__intro)
     reichte bei --fs-h1-Größe nicht aus, um gut zu laufen — der Text bricht
     zu oft/zu kurzzeilig. .project-fan__heading (Spalte 1) bleibt trotz
     gleicher Zeile schmal genug, dass die Box hier problemlos über die
     Spaltengrenze hinaus nach links wachsen kann. 65vw statt der vollen
     70vw vom Tablet-Breakpoint unten: bei Desktop-Breite bereits genug
     Platz zum guten Laufen, ohne die Box unnötig breiter als nötig zu
     machen. */
  width: 65vw;
  min-width: 0;
  text-align: right;
  color: var(--color-font-bright);
}

/* Das writer-Feld gibt den Text in <p>-Tags aus — Gewicht hier daher auf die
   <p>-Kinder gesetzt; Schrift, -größe und Zeilenhöhe kommen von der
   geteilten .intro-heading-Utility auf dem Eltern-Wrapper (s. o., dort
   font-size: var(--fs-intro-heading)). Vererbung allein reicht dafür nicht
   mehr: der globale "p"-Selektor (base.css) setzt inzwischen eine feste
   Fließtext-Größe direkt auf <p>, die jede geerbte Größe schlagen würde –
   .intro-heading p (components.css) setzt deshalb explizit
   font-size:inherit zurück, wovon dieses <p> hier mitprofitiert.
   BEWUSST KEIN white-space:nowrap (früherer Versuch, s. Git-Historie): jeder
   Absatz (= ein <p> pro Enter im Panel-Writer-Feld) ist als Block-Element
   ohnehin schon automatisch eine eigene Zeile – die Kontrolle über
   Zeilenumbrüche im Panel funktioniert damit bereits ohne nowrap. nowrap
   hätte zusätzlich JEDEN einzelnen Absatz komplett am Umbrechen gehindert:
   ein einzelner langer Satz (wie der aktuelle Lead-Text, noch ohne manuelle
   Absätze) lief dadurch weit über die Box hinaus und zerstörte die
   rechtsbündige Ausrichtung. Normales Wrapping bleibt hier als Fallback
   aktiv, falls ein einzelner Absatz trotz manueller Umbrüche zu lang für die
   Box ist. */
.project-fan__lead p {
  font-weight: var(--fw-regular);
}

/* Zusatztext der Work-Übersichtsseite (Feld "subtext", textarea-Feld,
   s. site/blueprints/pages/work.yml) — linke Spalte, Reihe 2 des
   Intro-Grids, direkt unter der Headline. Trägt zusätzlich die geteilte
   .section-introduction-Utility (components.css: Schriftfamilie + -größe) —
   hier nur Grid-Platzierung, Breite und die helle Schriftfarbe für den
   schwarzen Section-Hintergrund (die Utility setzt bewusst keine Farbe, s.
   dort).
   width bewusst als vw-Einheit statt der Default-Stretch-Breite (100% der
   1fr-Spalte) — gleicher Grund wie bei .services__intro-text (services.css):
   .wrapper deckelt bei var(--container-max) (base.css), auf sehr breiten
   Desktop-Viewports jenseits dieser Grenze wächst die Spalte selbst nicht
   mehr mit. Kein explizites justify-self nötig: Spalte 1 ist bereits die
   erste Spalte des Grids, Default-Ausrichtung (start) hält die Box damit
   von selbst linksbündig am Wrapper-Rand, während sie per width:50vw nach
   rechts in Richtung Mitte wächst — spiegelbildlich zum justify-self:end-
   Anker von .services__intro-text. */
.project-fan__subtext {
  grid-column: 1;
  grid-row: 2;
  width: 50vw;
  color: var(--color-font-bright);
}

/* bp: --bp-tablet (1024px) — Lead und Subtext nehmen hier 70vw statt der
   Desktop-Breiten (48vw bzw. die Spaltenbreite des Intro-Grids) ein.
   Bewusst als eigener Block VOR dem bp-mobile-Block unten: unterhalb von
   640px überschreibt dessen width:100% auf .project-fan__lead diese 70vw
   wieder (spätere Regel gewinnt bei gleicher Spezifität) — dort gilt
   weiterhin die volle Breite statt 70vw.
   Lead bewusst NICHT mehr in Spalte 2 neben dem Heading (wie im
   Desktop-Grid): eine 70vw-breite Box ist dort breiter als die 1fr-Spalte
   selbst — der Browser weicht bei justify-self:end dann auf eine
   start-Ausrichtung aus, um das Heading in Spalte 1 nicht zu verdecken
   (Overflow-Sicherheit der Grid-Alignment-Spec), wodurch die Box rechts
   über den Viewport hinausragt statt bündig am rechten Rand zu enden.
   Lösung: Heading spannt hier die volle Breite, Lead rutscht darunter in
   eine eigene Zeile (gleiche Reihen-Aufteilung wie im bp-mobile-Block),
   dort hat justify-self:end die volle Wrapper-Breite als Bezugsrahmen und
   die 70vw-Box passt bequem hinein. */
@media (max-width: 1024px) {
  .project-fan__heading {
    grid-column: 1 / -1;
  }

  .project-fan__lead {
    grid-column: 1 / -1;
    grid-row: 2;
    width: 70vw;
    /* Abstand zum Subtext darunter kommt von der wiederverwendbaren
       .heading-gap-Utility (components.css, per Klasse in
       project-fan-section.php gesetzt) statt einer eigenen Deklaration
       hier — die halbiert sich dort automatisch am bp-mobile-Breakpoint. */
  }

  .project-fan__subtext {
    grid-row: 3;
    width: 70vw;
  }
}

/* bp: --bp-mobile (640px) — Heading/Lead/Subtext untereinander statt im
   2-Spalten-Grid (gleiche Umstellung wie .services__intro, services.css). */
@media (max-width: 640px) {
  .project-fan {
    /* Gleiche Herleitung wie --project-fan-card-height oben (Basis-Regel
       .project-fan), nur auf 2,5 statt 3,5 gleichzeitig sichtbare
       Ruhe-Karten umgerechnet: 2,5 Kartenbreiten + 2 sichtbare Lücken
       (statt 3) sollen in 100vw passen.
         W_max = (100vw - Padding - 2*Gap) / 2,5
         H_max = W_max * 6/3,5
       min() mit 50vh bleibt wie in der Basis-Regel — nur auf ungewöhnlich
       hohen Mobile-Viewports greift dort weiterhin die Höhenbegrenzung
       statt der Breitenrechnung. */
    --project-fan-card-height: min(
      50vh,
      calc((100vw - var(--container-padding) - 2 * var(--project-fan-gap)) / 2.5 * (6 / 3.5))
    );
  }

  .project-fan__intro {
    grid-template-columns: 1fr;
  }

  .project-fan__lead {
    grid-column: 1;
    grid-row: 2;
    justify-self: start;
    width: 100%;
    text-align: left;
  }

  .project-fan__subtext {
    grid-row: 3;
    /* Eigener width-Reset nötig (anders als früher, als die Breite über die
       geteilte .services__row-text-Klasse kam): seit .project-fan__subtext
       keine Layout-Klasse mehr mit services.css teilt, überschreibt nur noch
       diese Regel das width:70vw aus dem Tablet-Block oben (max-width:1024px). */
    width: 100%;
  }

  /* Auf sehr schmalen Viewports füllt der (per --project-fan-card-height
     oben verkleinerte) Slider die 100vh-Mindesthöhe der Section (s.
     .project-fan) nicht mehr aus. margin-top:auto auf dem Viewport saugt
     den entstehenden Freiraum im Flex-Column-Container VOR sich selbst auf
     — die Lücke entsteht dadurch zwischen Intro (Subtext) und Slider statt
     danach (Default: Freiraum bliebe sonst unten in der Section, nach dem
     Slider). Bewusst nur hier (bp-mobile) statt global auf .project-fan__
     viewport: außerhalb dieses Breakpoints füllt die 50vh-Kartenhöhe die
     Section i. d. R. bereits aus, s. Kommentar an --project-fan-gap oben
     zum früher verworfenen globalen margin-top:auto-Ansatz. */
  .project-fan__viewport {
    margin-top: auto;
  }
}

/* Full-bleed: bricht aus .wrapper aus, damit Randkarten wirklich bis zum
   Viewport-Rand reichen (und dort vom overflow:hidden angeschnitten werden
   können), s. Kommentar-Block oben. */
.project-fan__viewport {
  position: relative;
  width: 100vw;
  margin-inline: calc(50% - 50vw);
  overflow: hidden;
  /* min- statt fixer Höhe: die Karten (s. .project-fan__item-wrap) bringen
     über --project-fan-card-height bereits ihre eigene definite Höhe mit,
     das Viewport übernimmt diese automatisch als Content-Höhe. */
  min-height: var(--project-fan-card-height);
}

.project-fan__track {
  display: flex;
  gap: var(--project-fan-gap);
  padding-inline: var(--container-padding);
  /* Wird pro Frame von project-fan.js gesetzt (Maus-X → Scroll-Position im
     Track). Nur transform, nie left/top (s. .custom-cursor-Konvention in
     animations.css). */
  will-change: transform;
}

.project-fan__item-wrap {
  position: relative;
  /* grow:0 shrink:0 — Karten haben IMMER eine feste Ruhe-Breite
     (--project-fan-rest-width = Kartenhöhe * 3,5/6, s. .project-fan) und
     feste Höhe, werden nie automatisch verkleinert, auch nicht während eine
     Nachbarkarte wächst. Wächst eine Karte per Hover (s. u.), bleibt die
     Breite aller anderen unverändert — sie werden dadurch rein positionell
     zur Seite verdrängt und können dabei auch komplett aus dem
     (overflow:hidden-)Viewport laufen, statt selbst schmaler zu werden.
     Breite und Höhe bewusst BEIDE explizit (statt aspect-ratio): die Höhe
     muss beim Hover konstant bleiben, während nur die flex-basis auf
     var(--project-fan-hover-width) wächst — die hochkante Karte klappt
     also seitlich ins Querformat auf; ein aspect-ratio würde sie beim
     Aufklappen stattdessen mit in die Höhe ziehen. Das 3,5:6-Format steckt
     komplett in der --project-fan-rest-width-Formel. */
  flex: 0 0 var(--project-fan-rest-width);
  height: var(--project-fan-card-height);
  overflow: hidden;
  /* Radius identisch zum Hover-Zustand (s. u.) — Ruhe- und Hover-Karten
     sollen gleich stark gerundet sein, kein Umschalten mehr nötig. */
  border-radius: var(--radius-sm);
  transition: flex-basis var(--duration-slow) var(--ease-standard);
}

.project-fan__item {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  width: 100%;
  height: 100%;
  color: var(--color-font-bright);
}

/* Feste Breite (= var(--project-fan-hover-width), s. .project-fan__item-wrap
   :hover unten) statt inset:0/100%: verhindert das "Reinzoomen" beim
   Aufklappen. Würde .project-fan__media mit der wachsenden flex-basis des
   Elternrahmens mitwachsen, müsste object-fit:cover das Bild bei jeder
   Breite neu hochskalieren, um die veränderte Boxform zu füllen — sichtbar
   als Zoom. Bei konstanter Breite bleibt der Maßstab des Bildes immer
   gleich; das Aufklappen zeigt lediglich mehr von der (mittig verankerten)
   Fläche, weil .project-fan__item-wrap selbst als overflow:hidden-Fenster
   darüber sitzt und wächst. left:50% + translateX(-50%) statt left:0: hält
   den Bildausschnitt horizontal zentriert zum jeweils aktuellen (animierten)
   Kartenrahmen — im Ruhezustand (--project-fan-rest-width) ist damit die
   Bildmitte sichtbar statt der linken Kante; beim Hover wächst der Rahmen exakt auf
   var(--project-fan-hover-width), wodurch die Zentrierung automatisch zu
   Offset 0 konvergiert und das komplette Bild deckungsgleich zu liegen
   kommt. */
.project-fan__media {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: var(--project-fan-hover-width);
  height: 100%;
}

.project-fan__media img,
.project-fan__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Zwei-Slot-Bild (Ruhe: PreviewThumbnail / Aufgeklappt: PreviewProjectHero,
   s. project-fan-card.php) — nur gerendert, wenn beide Felder gesetzt UND
   unterschiedlich sind, sonst bleibt es beim einzelnen Bild ohne diese
   Klassen (unverändertes Verhalten). Beide Slots liegen deckungsgleich
   übereinander (inset:0 im ohnehin schon absolut positionierten
   .project-fan__media, s. o.) und werden per Opacity gekreuzblendet statt
   das Element auszutauschen — kein Nachladen/Layout-Sprung beim Umschalten,
   dieselbe --duration-slow wie die flex-basis-Aufklapp-Transition
   (.project-fan__item-wrap, s. u.), damit beide Bewegungen synchron wirken. */
.project-fan__media-item {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--duration-slow) var(--ease-standard);
}

.project-fan__media-item--thumb {
  opacity: 1;
}

/* --- Variante "Zentriert mit Rand" (Panel-Toggle project-fan.imageStyle) -
   Alternative zur vollflächigen Variante oben: Bildgröße bleibt fest auf die
   RUHE-Breite der Karte (--project-fan-rest-width) bezogen (wächst NICHT mit, wenn die Karte
   beim Hover auf var(--project-fan-hover-width) aufklappt — Hover-Mechanik
   selbst unverändert, s.
   .project-fan__item-wrap weiter unten). Die POSITION dagegen ist wie bei
   der vollflächigen Variante oben per left:50% + translateX(-50%) relativ
   zur jeweils aktuellen (animierten) Kartenbreite zentriert, nicht per
   festem left-Offset — sonst würde das Bild beim Aufklappen (Wachstum nur
   nach rechts) optisch nach links aus der Kartenmitte rutschen, weil links
   nur noch der Ruhe-Rand, rechts aber der komplette Zuwachs entsteht. So
   bleibt der Rand auf beiden Seiten des Bildes gleich groß, während die
   Karte selbst weiterhin einseitig (nach rechts) wächst. */
.project-fan--framed {
  --project-fan-frame-padding: var(--space-md);
}

.project-fan--framed .project-fan__media {
  top: var(--project-fan-frame-padding);
  left: 50%;
  transform: translateX(-50%);
  width: calc(var(--project-fan-rest-width) - (var(--project-fan-frame-padding) * 2));
  height: calc(100% - (var(--project-fan-frame-padding) * 2));
  border-radius: var(--radius-sm);
}

.project-fan--framed .project-fan__media .placeholder-box {
  border-radius: var(--radius-sm);
}

/* object-fit:contain statt cover: Bild wird nicht mehr beschnitten, sondern
   im Original-Seitenverhältnis in den gepolsterten Rahmen eingepasst und
   dabei per default object-position (50% 50%) zentriert. Ergänzt die
   PHP-Seite (project-fan-card.php liefert bei $framed=true ein per
   resize() nur verkleinertes statt per crop() beschnittenes Bild). */
.project-fan--framed .project-fan__media img,
.project-fan--framed .project-fan__media video {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* .placeholder-box (components.css) ist normalerweise aspect-ratio-basiert
   dimensioniert — hier soll sie stattdessen den absolut positionierten
   .project-fan__media-Rahmen komplett ausfüllen. */
.project-fan__media .placeholder-box {
  position: static;
  width: 100%;
  height: 100%;
  aspect-ratio: auto;
  border-radius: 0;
  /* Karten ohne hochgeladenes Foto: sichtbar dunkelgrau (zum Testen) statt
     der globalen, fast transparenten Placeholder-Farbe (components.css) —
     die wäre auf dem schwarzen Section-Hintergrund unsichtbar. Gleicher
     Grauton wie .tag--dark (tokens.css). */
  background: var(--color-badge-background-dark);
}

.project-fan__body {
  position: relative;
  z-index: 1;
  height: 100%;
  padding: var(--space-md);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  gap: var(--space-2xs);
}

/* Titel mittig im Bild, nur Touch (Tablet UND Smartphone, s.
   [data-device="touch"]-Block am Dateiende) — standardmäßig unsichtbar,
   Desktop zeigt stattdessen nur das echte Cursor-Tooltip. */
.project-fan__media-title {
  display: none;
}

/* Tags nicht Teil des unten verankerten Body-Flows: aus dem Flow gelöst und
   unten rechts auf der Karte platziert, in einer (bei vielen Tags
   umbrechenden) Reihe, rechtsbündig, mit Abstand vom unteren und rechten
   Kartenrand. Selektor bewusst geteilt mit .project-card__media (components/
   project-card.php, .project-card__media braucht dafür position:relative,
   s. components.css) statt eigener Regeln dort — dieselbe Hover-Tag-Optik
   wie im Fan-Slider, ohne den Deklarationsblock zu duplizieren. */
.project-fan__body .tag-list,
.project-card__media .tag-list {
  position: absolute;
  right: 0;
  bottom: 0;
  margin: var(--space-md);
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: calc(var(--space-2xs) * 0.7);
}

/* Jedes Tag einzeln von unten (translateY) einblenden, gestaffelt von links
   nach rechts (DOM-Reihenfolge = Reihenfolge in der flex-wrap-Zeile) über
   --tag-stagger (per :nth-child unten gesetzt). transition-delay steht
   bewusst IMMER auf var(--tag-stagger) (nicht erst im Hover-Zustand) —
   greift dadurch sowohl beim Ein- als auch beim Ausblenden. Budget: 6
   Stufen à 220ms + 400ms Übergangsdauer = max. 1500ms Gesamtdauer bei bis
   zu 6 Tags.
   Glas-Look wie die Hero-Tags (.hero__email/.hero__elevator-item, s.
   hero.css) statt der fast transparenten .tag-Basis (components.css) — die
   wäre auf den wechselnden, oft dunklen Kartenbildern hier kaum lesbar,
   exakt das Problem, das die Hero-Tags schon lösen. Blur/Sättigung/Schatten
   kommen aus denselben site-weiten --glass-*-Tokens (tokens.css). Geteilt
   mit .project-card__media .tag-list (s. o.) — gleiche Optik/Timing für
   beide Kartentypen aus einer Quelle. */
.project-fan__body .tag-list .tag,
.project-card__media .tag-list .tag {
  font-size: calc(var(--fs-tag) * 0.8);
  opacity: 0;
  transform: translateY(0.6rem);
  /* "ease" statt var(--ease-standard): sanfterer, langsam anlaufender
     Verlauf für dieses feine Slide-up — passender als der site-weite,
     schärfere Standard-Bezier für kleine Text-Elemente. */
  transition: opacity 400ms ease, transform 400ms ease;
  transition-delay: var(--tag-stagger, 0ms);
  background: color-mix(in srgb, var(--color-badge-background) 75%, transparent);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  box-shadow: var(--glass-shadow);
}

.project-fan__body .tag-list .tag:nth-child(1),
.project-card__media .tag-list .tag:nth-child(1) { --tag-stagger: 100ms; }
.project-fan__body .tag-list .tag:nth-child(2),
.project-card__media .tag-list .tag:nth-child(2) { --tag-stagger: 220ms; }
.project-fan__body .tag-list .tag:nth-child(3),
.project-card__media .tag-list .tag:nth-child(3) { --tag-stagger: 440ms; }
.project-fan__body .tag-list .tag:nth-child(4),
.project-card__media .tag-list .tag:nth-child(4) { --tag-stagger: 660ms; }
.project-fan__body .tag-list .tag:nth-child(5),
.project-card__media .tag-list .tag:nth-child(5) { --tag-stagger: 880ms; }
.project-fan__body .tag-list .tag:nth-child(6),
.project-card__media .tag-list .tag:nth-child(6) { --tag-stagger: 1100ms; }

.project-fan__item-wrap:hover .project-fan__body .tag-list .tag,
.project-fan__item-wrap:focus-within .project-fan__body .tag-list .tag,
.project-fan__item-wrap--initial .project-fan__body .tag-list .tag,
.project-fan__item-wrap--expanded .project-fan__body .tag-list .tag,
/* .project-card hat keine --initial/--expanded-Entsprechung (kein
   JS-gesteuerter "bleibt offen"-Zustand wie im Fan-Slider) — Hover/
   Focus-within genügen hier für dieselbe Einblend-Optik. */
.project-card:hover .project-card__media .tag-list .tag,
.project-card:focus-within .project-card__media .tag-list .tag {
  opacity: 1;
  transform: translateY(0);
}

/* Geräte ohne echten Hover (s. gleiche @media-Weiche weiter unten für den
   Fan-Slider): .project-card hat kein eigenes Touch-Reveal-System (anders
   als der Fan-Slider, s. html[data-device="touch"]-Block am Dateiende) —
   Tags bleiben hier stattdessen dauerhaft sichtbar statt für immer
   verborgen zu bleiben. */
@media not all and (hover: hover) and (pointer: fine) {
  .project-card__media .tag-list .tag {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Touch: Position + Optik der Tags an die Fan-Slider-Touch-Version angleichen
   (html[data-device="touch"] .project-fan__body .tag-list(.tag) weiter unten)
   — unten LINKS statt unten rechts, plus die deckende (statt Glas-)Optik,
   die auf den wechselnden Kartenbildern besser lesbar ist als der
   halbtransparente Blur-Look vom Desktop-Hover. */
html[data-device="touch"] .project-card__media .tag-list {
  right: auto;
  left: 0;
  justify-content: flex-start;
}

html[data-device="touch"] .project-card__media .tag-list .tag {
  font-size: var(--fs-tag);
  background: var(--color-badge-background-dark);
  color: var(--color-badge-font-dark);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
}

/* --initial: Default-Aufklapp-Zustand der ERSTEN Karte (Modifier steht im
   Markup, s. project-fan-section.php) — optisch identisch zu :hover/
   :focus-within, aber ohne echten Hover ausgelöst. project-fan.js entfernt
   die Klasse permanent, sobald der Mauszeiger den Fan-Viewport erreicht
   (danach verhält sich die Karte nur noch wie jede andere, rein
   :hover-gesteuerte Karte).
   --expanded: JS-gesetztes Pendant für die "zuletzt geöffnete Karte bleibt
   offen"-Regel (s. project-fan.js) — sitzt immer auf genau der Karte, die
   zuletzt per mouseenter aktiv wurde, bleibt beim mouseleave aber (anders
   als :hover) bestehen, bis eine ANDERE Karte übernimmt. */
.project-fan__item-wrap:hover,
.project-fan__item-wrap:focus-within,
.project-fan__item-wrap--initial,
.project-fan__item-wrap--expanded {
  flex-basis: var(--project-fan-hover-width);
}

/* Gleicher Zustand (Hover/Focus/--initial/--expanded) steuert zusätzlich die
   Bild-Kreuzblende oben (.project-fan__media-item) — dieselbe Selektorliste
   wie hier und wie bei der Tag-Einblendung (.project-fan__body .tag-list
   .tag weiter oben), damit alle drei Effekte exakt synchron ein-/
   ausgelöst werden. */
.project-fan__item-wrap:hover .project-fan__media-item--hero,
.project-fan__item-wrap:focus-within .project-fan__media-item--hero,
.project-fan__item-wrap--initial .project-fan__media-item--hero,
.project-fan__item-wrap--expanded .project-fan__media-item--hero {
  opacity: 1;
}

.project-fan__item-wrap:hover .project-fan__media-item--thumb,
.project-fan__item-wrap:focus-within .project-fan__media-item--thumb,
.project-fan__item-wrap--initial .project-fan__media-item--thumb,
.project-fan__item-wrap--expanded .project-fan__media-item--thumb {
  opacity: 0;
}

/* --- Cursor-folgendes Titel-Tooltip --------------------------------------
   Zwei Elemente statt einem: .project-fan-tooltip (dieser Block) trägt
   NUR die Positionierung (Custom Properties --tooltip-x/-y, von
   project-fan.js pro Frame OHNE Transition gesetzt, analog zur
   .custom-cursor-Konvention in animations.css: nur transform, nie
   top/left) — eine Transition hier würde die Cursor-Verfolgung spürbar
   hinterherhinken lassen. Das eigentliche "Pillen"-Aussehen samt
   Einblend-/Breiten-Animation sitzt auf dem Kind .project-fan-tooltip__pill
   (s. u.), dessen Transitions davon unberührt bleiben. */
.project-fan-tooltip {
  position: fixed;
  top: 0;
  left: 0;
  --tooltip-x: -999px;
  --tooltip-y: -999px;
  --tooltip-offset: 1rem;
  transform: translate3d(var(--tooltip-x), var(--tooltip-y), 0) translateY(-50%);
  pointer-events: none;
  will-change: transform;
  z-index: 999;
}

/* Mauszeiger links im Viewport → Tooltip erscheint rechts vom Cursor;
   Mauszeiger rechts → Tooltip erscheint links vom Cursor (project-fan.js
   setzt die passende Modifier-Klasse pro mousemove-Frame). */
.project-fan-tooltip--right {
  transform: translate3d(calc(var(--tooltip-x) + var(--tooltip-offset)), var(--tooltip-y), 0)
    translateY(-50%);
}

.project-fan-tooltip--left {
  transform: translate3d(calc(var(--tooltip-x) - var(--tooltip-offset)), var(--tooltip-y), 0)
    translateY(-50%) translateX(-100%);
}

@media (pointer: coarse), (prefers-reduced-motion: reduce) {
  .project-fan-tooltip {
    display: none;
  }
}

/* Größe, Padding und Uppercase 1:1 von .site-header .btn (header.css)
   übernommen. Farben bewusst umgekehrt zu den Nav-Buttons (heller
   Hintergrund, dunkle Schrift) und stärker gerundet als --radius-md.
   Hintergrund/Blur/Schatten: gleicher Glas-Look wie die Hero-Tags
   (.hero__email/.hero__elevator-item, hero.css) und die Fan-Card-Tags
   (.project-fan__body .tag-list .tag oben) — ein Blur-Effekt braucht
   ohnehin einen unruhigen Untergrund (hier: die wechselnden Kartenbilder
   direkt unterm Tooltip), um sichtbar zu wirken, macht das Tooltip damit
   dem Rest der Glas-Flächen der Seite konsistent statt einer isolierten
   Sonderfarbe. */
.project-fan-tooltip__pill {
  padding-inline: calc(1rem / 1.5);
  padding-block: calc(1.29em / 1.5);
  border-radius: calc(var(--radius-md) * 1.5);
  background: color-mix(in srgb, var(--color-badge-background) 75%, transparent);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  box-shadow: var(--glass-shadow);
  /* --font-pixel-grotesk wie ALLE anderen UI-Chrome-Elemente (Buttons,
     Tags, Headings) — vorher versehentlich die Serif-Pixelschrift, obwohl
     das Tooltip laut Vorgabe das Design der Header-Nav-Buttons spiegelt. */
  font-family: var(--font-pixel-grotesk);
  font-size: var(--fs-nav-btn);
  text-transform: uppercase;
  color: var(--color-font-dark);
  white-space: nowrap;
  overflow: hidden;
  display: inline-block;
  /* Einblenden exakt wie .fade-in-up (animations.css) beim Scroll-Reveal:
     gleiche Startposition (translateY(2rem)) und gleiche Dauer/Easing
     (--duration-slow), nur hier über die is-active-Klasse statt per
     IntersectionObserver getriggert — der Tooltip soll einmal auftauchen,
     sobald der Cursor den Fan erreicht (s. viewport-mouseenter,
     project-fan.js), nicht bei jedem Kartenwechsel neu.
     width bewusst eigenständig und deutlich schneller (--duration-fast statt
     -slow): scramble.js legt den echten Zieltext sofort in der vollen neuen
     Breite an (nur das Rausch-Overlay läuft optisch nach) — bliebe die
     Pillen-Breite genauso langsam wie der Fade, stünden die Buchstaben
     währenddessen sichtbar über die noch schmalere Pille hinaus. overflow:
     hidden oben fängt den kurzen Rest dieses Versatzes zusätzlich ab. */
  opacity: 0;
  transform: translateY(2rem);
  transition: opacity var(--duration-slow) var(--ease-standard),
    transform var(--duration-slow) var(--ease-standard),
    width var(--duration-fast) var(--ease-standard);
  will-change: transform;
}

/* Verschiebt die (unsichtbare) Ruheposition der Pille von unten nach oben —
   in beide Richtungen genutzt, je nachdem woher/wohin der Cursor den Fan
   gerade quert (project-fan.js):
   - Exit: Cursor verlässt den Fan nach oben → Pille verschwindet ebenfalls
     nach oben statt in die sonst übliche fade-in-up-Richtung.
   - Entrance: Cursor betritt den Fan von oben → Pille kommt ebenfalls von
     oben statt von unten. Dafür wird dieser Modifier einen Frame VOR
     .is-active gesetzt (s. dort), damit der Browser die "oben"-Position
     erst rendert, bevor die Transition zur Zielposition losläuft. */
.project-fan-tooltip__pill--offset-up {
  transform: translateY(-2rem);
}

.project-fan-tooltip__pill.is-active {
  opacity: 1;
  transform: translateY(0);
}

/* Hover-Expand nur auf Geräten mit echtem Hover + feiner Zeigersteuerung —
   ohne die bleibt jede Karte in ihrer Ruhe-Größe. Sicherheitsnetz für
   seltene Geräte OHNE data-device="touch"-Attribut (z. B. JS deaktiviert
   oder Mischformen wie hover:hover + pointer:coarse); auf normalen
   Touch-Geräten überstimmt ohnehin der spezifischere
   html[data-device="touch"]-Block am Dateiende. */
@media not all and (hover: hover) and (pointer: fine) {
  .project-fan__item-wrap:hover,
  .project-fan__item-wrap:focus-within,
  /* --initial (s. o.) hier ebenfalls zurückgesetzt: project-fan.js läuft
     auf Geräten ohne echten Hover gar nicht (früher Return, s. dort) und
     würde die Klasse sonst nie wieder entfernen — die erste Karte bliebe
     dauerhaft aufgeklappt statt wie alle anderen in Ruhe-Größe zu starten. */
  .project-fan__item-wrap--initial {
    /* Ruhe-Breite statt Hover-Breite: Karte bleibt exakt so groß wie im
       Nicht-Hover-Zustand (s. Basis-Regel). */
    flex-basis: var(--project-fan-rest-width);
  }
  .project-fan__item-wrap:hover .project-fan__body .tag-list .tag,
  .project-fan__item-wrap:focus-within .project-fan__body .tag-list .tag,
  .project-fan__item-wrap--initial .project-fan__body .tag-list .tag {
    opacity: 0;
    transform: translateY(0.6rem);
  }
  /* Gleicher Grund wie bei den Tags oben: --initial bleibt hier dauerhaft
     gesetzt (project-fan.js läuft nicht), die Bild-Kreuzblende soll aber
     trotzdem bei der Ruhe-Vorschau (Thumbnail) bleiben statt dauerhaft das
     Hero-Bild zu zeigen. */
  .project-fan__item-wrap:hover .project-fan__media-item--hero,
  .project-fan__item-wrap:focus-within .project-fan__media-item--hero,
  .project-fan__item-wrap--initial .project-fan__media-item--hero {
    opacity: 0;
  }
  .project-fan__item-wrap:hover .project-fan__media-item--thumb,
  .project-fan__item-wrap:focus-within .project-fan__media-item--thumb,
  .project-fan__item-wrap--initial .project-fan__media-item--thumb {
    opacity: 1;
  }
  /* Karte wächst hier nie über --project-fan-rest-width hinaus (s. o.) — die
     feste var(--project-fan-hover-width)-Breite von .project-fan__media (für
     den Zoom-freien Desktop-Hover gedacht) würde sonst dauerhaft einen Teil
     des Bildes ungenutzt abschneiden. */
  .project-fan__media {
    width: var(--project-fan-rest-width);
  }
}

/* --- Touch (Handy UND Tablet): normaler Swipe-Slider ---------------------
   Eine gemeinsame Variante für alle Touch-Geräte (ersetzt die frühere
   Zweiteilung "gestapelte Mobile-Liste + eigene Tablet-Variante").
   Geräteabfrage (hover:none + pointer:coarse, s. html-start.php) statt
   reiner Breiten-Media-Query — ein am Computer verkleinertes Browserfenster
   hat weiterhin hover:hover/pointer:fine, bekommt das Attribut nie und
   behält die normale Desktop-Fan-Version (Hover/Scrub/Tooltip).
   project-fan.js bricht auf Touch komplett ab (kein Scrub-Loop, kein
   Tooltip-Element), Swipe kommt allein aus nativem overflow-x + scroll-snap
   (kostenlos, kein JS nötig, analog zum Kommentar in slider.js).
   Layout pro Karte: Projektbild im festen Querformat 3:2 (B:H), darunter
   im normalen Fluss die Caption — Titel linksbündig in der Pixel-Schrift
   (--font-pixel-grotesk), Produktart-Tag rechtsbündig.
   Alle Selektoren sind bewusst mit "html[data-device=touch]" (statt nur
   Klasse) verschärft, um zuverlässig sowohl die Basis-Regeln als auch die
   :hover/:focus-within- und @media-not-(hover:hover)-Regeln weiter oben in
   dieser Datei zu überstimmen, unabhängig von der Quellreihenfolge. */
html[data-device="touch"] .project-fan__viewport {
  /* Natives Swipe-Scrolling statt JS-Scrub: overflow-x wird vom
     Basis-overflow:hidden auf auto gedreht, der Rest (Full-bleed-Breite,
     margin-top:auto für die Position am unteren Sektionsrand) kommt
     unverändert aus der Basis-Regel. */
  overflow-x: auto;
  overflow-y: hidden;
  /* Basis-min-height (Desktop-Kartenhöhen-Formel) zurücksetzen: die Höhe
     kommt hier komplett aus dem Karteninhalt (Bild 3:2 + Caption) — eine
     größere min-height würde die Karten sonst per align-items:stretch
     verzerren (beide Dimensionen fest, aspect-ratio wirkungslos). */
  min-height: 0;
  -webkit-overflow-scrolling: touch;
  scroll-snap-type: x mandatory;
  /* Wie bei den anderen zwei Touch-Slidern (slider.css, testimonials.css):
     "pan-x pan-y" statt nur "pan-x" — reines pan-x ließ den Browser
     vertikale Gesten, die AUF dem Fan starten, komplett unbehandelt (kein
     natives Scroll-Handling dafür mehr), wodurch Page-Scroll dort nur noch
     vom Viewport-Rand aus funktionierte. "pan-x pan-y" behält die native
     Richtungs-Arbitrierung: eine primär horizontale Geste bleibt beim
     Slider (Snap-Scroll), eine primär vertikale scrollt normal die Seite —
     nur Pinch-Zoom/Doubletap-Zoom bleiben unterbunden. */
  touch-action: pan-x pan-y;
  /* Snap-Ziel "start" (s. Karte unten) rastet bündig mit dem linken
     Track-Padding ein — die aktuelle Karte sitzt dadurch am Containerrand,
     rechts lugt die nächste als Swipe-Hinweis herein. */
  scroll-padding-inline: var(--container-padding);
}

html[data-device="touch"] .project-fan__item-wrap {
  /* Breite führt (statt Höhe wie im Desktop-Fan): normale Slider-Karte,
     die den Großteil des Viewports einnimmt, mit Peek der nächsten Karte;
     rem-Cap verhindert Riesen-Karten auf großen Tablets. Die Höhe ergibt
     sich aus Bild (3:2, s. .project-fan__media unten) + Caption — deshalb
     height:auto statt der festen Basis-Kartenhöhe. */
  flex: 0 0 auto;
  width: min(80vw, 40rem);
  height: auto;
  min-width: 0;
  /* Rundung/Clipping übernimmt das Bild selbst (s. u.) — der Wrap enthält
     jetzt auch die Caption unter dem Bild, ein overflow:hidden + radius
     hier würde deren Ecken mit beschneiden. */
  overflow: visible;
  border-radius: 0;
  scroll-snap-align: start;
}

/* Bild nimmt echten Platz im Fluss ein statt die Karte per position:absolute
   zu überdecken — die Caption (s. u.) folgt darunter. transform muss
   explizit zurückgesetzt werden: die geerbte translateX(-50%) (für die
   absolute Zentrierung des Desktop-Hover-Falls gedacht) wirkt auch bei
   position:relative weiter und würde das Bild um die halbe eigene Breite
   nach links verschieben. Festes 3:2-Querformat über aspect-ratio —
   width:100% der Kartenbreite + height:auto lassen der Ratio die nötige
   freie Dimension. position:relative (statt static) macht das Bild zum
   Bezugsrahmen für den zentrierten Titel-Pill (.project-fan__media-title,
   s. u.). */
html[data-device="touch"] .project-fan__media {
  position: relative;
  top: auto;
  left: auto;
  transform: none;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  border-radius: var(--radius-sm);
  overflow: hidden;
}

/* Kein Aufklappen auf Touch (kein Hover-Konzept dort) — die Karte zeigt
   immer nur das Thumbnail, das per Kreuzblende vorgesehene Hero-Bild bleibt
   komplett ungenutzt. display:none statt der Opacity-Regeln oben: eindeutig
   unsichtbar unabhängig von --initial/--expanded, kein Spezifitäts-Wettlauf
   mit deren Klassen-Selektoren nötig. */
html[data-device="touch"] .project-fan__media-item--hero {
  display: none;
}

html[data-device="touch"] .project-fan__body {
  position: static;
  height: auto;
  /* Basis-Regel setzt "padding: var(--space-md)" (alle Seiten) — hier
     inline-Padding explizit auf 0, damit die Tag-Liste (s. u.) bündig mit
     dem linken bzw. rechten Bildrand abschließt. */
  padding-block: var(--space-sm);
  padding-inline: 0;
  align-items: stretch;
  text-align: left;
}

/* Titel mittig im Bild, exakt dasselbe Pillen-Design wie
   .project-fan-tooltip__pill (Desktop-Cursor-Tooltip, s. o.): gleiche Werte
   1:1 übernommen statt die Klasse selbst zu teilen — der Tooltip trägt
   zusätzlich fixed-Positionierung, JS-getriebene --tooltip-x/-y-Koordinaten
   und eine Enter/Exit-Animation (opacity/transform), die hier nicht greifen
   sollen.
   Stattdessen eine kleine, dauerhafte Kreisbewegung um die Zentrumsposition
   (project-fan-title-float, s. u.): Desktop bewegt dieselbe Glas-Pille aktiv
   mit dem Cursor mit (project-fan.js) — auf Touch gibt es keinen Cursor
   dafür, das Kreisen imitiert diese Lebendigkeit stattdessen autonom.
   Nebeneffekt: die Position über dem Bild wandert dabei minimal, wodurch der
   Blur/Glas-Effekt (backdrop-filter) sichtbar unterschiedliche Bildbereiche
   durchscheinen lässt statt an einer toten Stelle einzufrieren. transform in
   jedem Keyframe bewusst komplett (inkl. translate(-50%,-50%)) statt nur des
   Bewegungsanteils: eine @keyframes-Animation ersetzt den transform-Wert des
   Elements vollständig, ein additives "on top" der statischen Basis-Regel
   oben gibt es nicht — ohne die Zentrierung in jedem Schritt erneut
   mitzuführen, würde die Pille beim Animationsstart aus der Bildmitte
   linear statt ease-artiger Kurven: eine echte Kreisbahn braucht konstante
   Winkelgeschwindigkeit (Kreis-Umfang wird pro Zeiteinheit gleichmäßig
   zurückgelegt) — jede Beschleunigung/Verzögerung würde die Bewegung
   stattdessen elliptisch/ungleichmäßig wirken lassen.
   animation-duration/-delay pro Karte leicht versetzt (s. u.): ohne Versatz
   liefen alle Karten exakt synchron, was eher mechanisch als organisch/
   zufällig wirkt.
   PERFORMANCE: läuft potenziell auf vielen .project-fan__item-wrap
   gleichzeitig — und ausgerechnet backdrop-filter (Blur/Saturate, s. u.)
   gehört zu den teuersten CSS-Eigenschaften überhaupt, weil der Browser bei
   JEDER Positionsänderung die Fläche darunter neu abtasten/neu rastern muss
   (anders als reines transform/opacity, das komplett auf dem Compositor
   laufen kann). Deshalb zwei gezielte Bremsen statt eines pauschalen
   will-change auf allen Karten:
   1) animation-play-state:paused per Default, erst .project-fan__item-wrap
      --in-view (IntersectionObserver, project-fan.js — dieselbe Klasse, die
      bereits den Tag-Scroll-Reveal steuert, s. u.) schaltet auf running —
      Karten außerhalb des Viewports kosten dadurch überhaupt keine
      Rechenzeit mehr, statt unsichtbar im Hintergrund weiterzulaufen.
   2) will-change:transform sitzt aus demselben Grund NICHT hier in der
      Basis-Regel, sondern ebenfalls nur in der --in-view-Regel (s. u.) —
      ein dauerhaft gesetztes will-change auf jeder Karte würde für JEDE
      einen eigenen Compositor-Layer reservieren, auch für die, die gerade
      gar nicht sichtbar/animiert sind (Speicher-Overhead ohne Nutzen). */
html[data-device="touch"] .project-fan__media-title {
  display: inline-block;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  animation: project-fan-title-float 12s linear infinite paused;
  padding-inline: calc(1rem / 1.5);
  padding-block: calc(1.29em / 1.5);
  border-radius: calc(var(--radius-md) * 1.5);
  background: color-mix(in srgb, var(--color-badge-background) 75%, transparent);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  box-shadow: var(--glass-shadow);
  font-family: var(--font-pixel-grotesk);
  font-size: var(--fs-nav-btn);
  text-transform: uppercase;
  color: var(--color-font-dark);
  white-space: nowrap;
  overflow: hidden;
  pointer-events: none;
}

/* 12 Punkte auf einem Kreis (Radius 6px, alle 30°), per linear (s. o.)
   verbunden — genug Stützpunkte, dass die Kreisform trotz linearer
   Interpolation zwischen ihnen glatt statt eckig wirkt (ein Zwölfeck ist bei
   6px Radius optisch kaum von einem echten Kreis zu unterscheiden). Start
   und Ende (0%/100%) sind identisch (0°), die Schleife schließt dadurch ohne
   Sprung. */
@keyframes project-fan-title-float {
  0% {
    transform: translate(-50%, -50%) translate3d(6px, 0, 0);
  }
  8.33% {
    transform: translate(-50%, -50%) translate3d(5.2px, 3px, 0);
  }
  16.67% {
    transform: translate(-50%, -50%) translate3d(3px, 5.2px, 0);
  }
  25% {
    transform: translate(-50%, -50%) translate3d(0, 6px, 0);
  }
  33.33% {
    transform: translate(-50%, -50%) translate3d(-3px, 5.2px, 0);
  }
  41.67% {
    transform: translate(-50%, -50%) translate3d(-5.2px, 3px, 0);
  }
  50% {
    transform: translate(-50%, -50%) translate3d(-6px, 0, 0);
  }
  58.33% {
    transform: translate(-50%, -50%) translate3d(-5.2px, -3px, 0);
  }
  66.67% {
    transform: translate(-50%, -50%) translate3d(-3px, -5.2px, 0);
  }
  75% {
    transform: translate(-50%, -50%) translate3d(0, -6px, 0);
  }
  83.33% {
    transform: translate(-50%, -50%) translate3d(3px, -5.2px, 0);
  }
  91.67% {
    transform: translate(-50%, -50%) translate3d(5.2px, -3px, 0);
  }
  100% {
    transform: translate(-50%, -50%) translate3d(6px, 0, 0);
  }
}

/* Versatz in Dauer + negativer Delay (startet die Animation gedanklich schon
   mitten im Zyklus statt bei 0%) pro Karte, nach Position im Track zyklisch
   über 5 Varianten verteilt (nth-child(5n+…)) — läuft dadurch unabhängig von
   der tatsächlichen Kartenanzahl. */
html[data-device="touch"] .project-fan__item-wrap:nth-child(5n + 2) .project-fan__media-title {
  animation-duration: 13.8s;
  animation-delay: -3s;
}

html[data-device="touch"] .project-fan__item-wrap:nth-child(5n + 3) .project-fan__media-title {
  animation-duration: 9.9s;
  animation-delay: -6.6s;
}

html[data-device="touch"] .project-fan__item-wrap:nth-child(5n + 4) .project-fan__media-title {
  animation-duration: 12.9s;
  animation-delay: -1.8s;
}

html[data-device="touch"] .project-fan__item-wrap:nth-child(5n + 5) .project-fan__media-title {
  animation-duration: 11.1s;
  animation-delay: -8.4s;
}

/* Größe/Padding zurück auf die site-weite .tag-Basis (components.css) —
   genau die Maße, die z.B. auch die Services-Tags nutzen, ohne eigene
   Overrides. Die Basis-Regel oben (font-size *0.8 + Glas-Hintergrund/Blur/
   Schatten) ist bewusst fürs DESKTOP-Overlay auf dem Kartenbild gedacht
   (kleiner + Blur nötig, damit die Tags auf wechselnden Fotos lesbar
   bleiben, s. Kommentar dort). Auf Touch (Tablet UND Smartphone) liegt die
   Liste seit der Umstellung auf position:static (s. u.) aber gar nicht mehr
   über dem Bild, sondern im normalen Fluss vor dunklem Section-Hintergrund
   — der Grund für Größe/Blur entfällt damit, nicht aber für die Farbgebung:
   die Services-Tags nutzen die UNGEFÄRBTE .tag-Basis (dunkler Text auf fast
   transparentem, für HELLE Sections gedachtem Untergrund, s.
   --color-heading-badge-background), das wäre auf dem schwarzen
   Project-Fan-Hintergrund unlesbar (identisches Problem wie beim vormaligen
   Produktart-Chip in project-fan-card.php, daher dort schon .tag--dark).
   Hier deshalb explizit dessen Werte übernommen (heller Text auf dunklem,
   deckendem Chip) statt der Klasse selbst – Größe/Padding bleiben dabei auf
   --fs-tag stehen (kommen weiterhin in em von .tag selbst und skalieren
   automatisch mit).
   opacity/transform selbst NICHT hier überschrieben — die Basis-Regel oben
   hält sie standardmäßig unsichtbar (opacity:0/translateY(0.6rem)), genau
   wie im Desktop-Hover-Zustand vor dem Einblenden. Das Einblenden übernimmt
   project-fan.js hier per Scroll-Reveal (--in-view-Modifier, s. u.) statt
   per :hover, den es auf Touch nicht gibt — dieselbe Transition/Stagger
   (transition/transition-delay: var(--tag-stagger), Basis-Regel) läuft
   dadurch identisch zur Desktop-Hover-Animation. */
html[data-device="touch"] .project-fan__body .tag-list .tag {
  font-size: var(--fs-tag);
  background: var(--color-badge-background-dark);
  color: var(--color-badge-font-dark);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
}

html[data-device="touch"] .project-fan__item-wrap--in-view .project-fan__body .tag-list .tag {
  opacity: 1;
  transform: translateY(0);
}

/* Setzt den Titel-Kreis (project-fan-title-float, s. o.) erst hier in
   Bewegung, statt in der Basis-Regel — s. den PERFORMANCE-Absatz dort:
   pausiert/ohne Compositor-Layer für jede Karte außerhalb des Viewports. */
html[data-device="touch"] .project-fan__item-wrap--in-view .project-fan__media-title {
  animation-play-state: running;
  will-change: transform;
}

/* position:absolute (Basis-Regel) verankert die Liste sonst am unteren/
   rechten Rand von .project-fan__body — auf Touch ist .project-fan__body
   aber bereits position:static (s. o.) und die Caption verschwunden (s.
   o.), wodurch "bottom:0" ohnehin nur noch den unteren Bildrand selbst
   treffen würde. Trotzdem normaler Flex-Fluss statt weiterhin absolut: die
   Liste reiht sich (mit dem bestehenden gap von .project-fan__body,
   Basis-Regel) direkt unter dem Bild ein. justify-content:flex-start statt
   dem rechtsbündigen Desktop-Original (Basis-Regel oben): linksbündig am
   Kartenrand, wie auf Touch angefragt. */
html[data-device="touch"] .project-fan__body .tag-list {
  position: static;
  margin: 0;
  justify-content: flex-start;
}

/* --- Smartphone (Touch UND schmal): gestapelte Liste statt Slider --------
   Reines Layout — Tags/Titel sind bereits weiter oben für ALLE Touch-Geräte
   (Tablet UND Smartphone) identisch geregelt. Überschreibt hier nur
   zusätzlich für den schmalen Viewport: Karten stehen untereinander in
   voller Breite statt in einer horizontal scrollenden Reihe. Tablet (Touch,
   aber >640px) bleibt beim Slider oben unberührt, da dieser Block
   zusätzlich an --bp-mobile gebunden ist.
   Zunächst ist nur Seite 1 sichtbar (die ersten $fanPerPage=3 Karten, s.
   data-fan-page in project-fan-section.php) — der Rest blendet
   project-fan.js beim Klick auf den "Mehr laden"-Button ein (Klasse
   .is-revealed), s. u. */
@media (max-width: 640px) {
  html[data-device="touch"] .project-fan__viewport {
    /* Kein Full-Bleed/Scroll mehr: normale Blockbreite innerhalb des
       Container-Paddings, Karten laufen im natürlichen Fluss untereinander
       statt in einem eigenen Scroll-Container. */
    width: 100%;
    margin-inline: 0;
    overflow: visible;
    scroll-snap-type: none;
  }

  html[data-device="touch"] .project-fan__track {
    flex-direction: column;
    /* Halb so groß wie ein regulärer --space-lg-Abstand (s. Anfrage). */
    gap: calc(var(--space-lg) / 2);
  }

  html[data-device="touch"] .project-fan__item-wrap {
    width: 100%;
  }

  /* :not(.is-revealed) statt eines Zeigers auf eine feste Seitenzahl —
     funktioniert dadurch unabhängig davon, wie viele Seiten es insgesamt
     gibt (project-fan.js ergänzt .is-revealed pauschal auf ALLEN
     ausgeblendeten Karten in einem Klick, s. dort: "alle weiteren laden",
     nicht Seite für Seite). */
  html[data-device="touch"] .project-fan__item-wrap[data-fan-page]:not([data-fan-page="1"]):not(.is-revealed) {
    display: none;
  }

  html[data-device="touch"] .project-fan__load-more-wrap {
    display: block;
  }
}

/* Barrierefreiheit: gleiche Konvention wie .fade-in-up (animations.css) —
   Inhalte stehen bei Bewegungsreduktion sofort an ihrer Endposition, keine
   Transform-/Opacity-Animation. project-fan.js startet den Scroll-Reveal-
   Observer bei prefers-reduced-motion erst gar nicht (s. dort), ohne diese
   Regel blieben die Tags für diese Nutzer:innen dadurch dauerhaft
   unsichtbar (kein --in-view, das sie einblenden würde). Ohne
   max-width-Deckel (anders als vorher): die Tags sind jetzt auf Tablet UND
   Smartphone sichtbar (s. o.), der Fallback muss dieselbe Reichweite haben.
   Titel-Float (project-fan-title-float, s. o.) ebenfalls deaktiviert — fällt
   dabei automatisch auf die statische transform:translate(-50%,-50%) der
   Basis-Regel zurück (bleibt zentriert, bewegt sich nur nicht mehr). */
@media (prefers-reduced-motion: reduce) {
  html[data-device="touch"] .project-fan__body .tag-list .tag {
    opacity: 1;
    transform: translateY(0);
    transition: none;
  }

  html[data-device="touch"] .project-fan__media-title {
    animation: none;
  }
}

/* Wrapper unsichtbar, außer im Smartphone-Block oben (Tablet/Desktop zeigen
   ohnehin bereits die komplette Liste, brauchen also nie einen Button).
   margin-block knapper als vorher (--space-lg oben, kein unten): der Button
   trägt jetzt selbst schon sichtbares Padding (Pill-Design, s. u.), zusätzlich
   üppige Wrapper-Margins wirkten dadurch doppelt gemoppelt. Nochmals halbiert
   auf --space-sm/2 (s. Anfrage). Abstand nach oben (zur letzten Karte)
   nochmals separat halbiert (s. Anfrage) – unten bleibt beim --space-sm/2-Wert,
   da dort kein "rutschen" gefordert war. */
.project-fan__load-more-wrap {
  display: none;
  margin-top: calc(var(--space-sm) / 4);
  margin-bottom: calc(var(--space-sm) / 2);
  text-align: center;
}

/* Gleiches Design wie .next-project__overview (project.css, "Projektübersicht"-
   Button auf der Projektseite) — Form/Mechanik 1:1 übernommen statt die
   Klasse zu teilen (dort zusätzlich an ein <a>/href statt <button> gebunden).
   Farbgebung hier bewusst die HELLE Variante statt des dortigen schwarzen
   Looks: der Project-Fan-Slider hat einen dunklen Section-Hintergrund
   (.section-heading--dark, s.o.) – ein schwarzer Button würde darauf kaum
   Kontrast haben. Gleiche Rezeptur wie der generische, inaktive .btn
   (components.css) – --color-button-background statt der dunklen Nav-
   Active-Fläche, dunkler --color-font-dark-Text statt --color-font-bright.
   Kein Farbwechsel beim Hover: die Klasse "scramble" (Markup) übernimmt das
   Hover-Feedback stattdessen über den Text-Scramble-Effekt (scramble.js). */
.project-fan__load-more {
  display: inline-block;
  padding-inline: calc(1rem / 1.5);
  /* Asymmetrisch statt eines einzelnen Werts — s. --font-pixel-grotesk-nudge
     (tokens.css): schiebt die Zeichentinte des Pixel-Grotesk-Fonts sichtbar
     nach unten in die Box, exakt dasselbe Rezept wie beim padding-block von
     .btn (components.css). */
  padding-block: calc(1.29em / 1.5 - var(--font-pixel-grotesk-nudge)) calc(1.29em / 1.5 + var(--font-pixel-grotesk-nudge));
  border: none;
  /* Ruhezustand identisch zu .btn (components.css) statt der vollen Pille
     der Vorgängerfassung — echter Button, soll daher auch dessen
     Standard-Radius tragen. */
  border-radius: var(--radius-sm);
  background: var(--color-button-background);
  box-shadow: var(--glass-shadow);
  font-family: var(--font-pixel-grotesk);
  font-size: var(--fs-nav-btn);
  text-transform: uppercase;
  letter-spacing: var(--ls-btn-tag);
  color: var(--color-font-dark);
  white-space: nowrap;
  cursor: pointer;
  /* Gleiche Mechanik wie .btn/.btn:hover (components.css): Radius läuft beim
     Hover sichtbar "nach", 4x langsamer als eine reine transform/opacity-
     Transition. */
  transition: border-radius calc(var(--duration-fast) * 4) var(--ease-standard);
}

.project-fan__load-more:hover,
.project-fan__load-more:focus-visible {
  /* 1:1 .btn:hover (components.css). */
  border-radius: var(--radius-md);
}
