Zum Inhalt
← Typo-Techniken

Text fließt um eine bewegte Form (Experiment)

Ein Ausstellungstext umfließt eine wandernde, ziehbare Skulptur-Form: Pretext bricht jede Zeile pro Bild neu um, ruhende Form mit shape-outside als Fallback.

Eigenprojekte, Ausstellungs- und Festivalseiten von Kulturhäusern, Essays: ein einzelner Textblock darf zur Bühne werden.

Ein Fließtext umfließt eine Form, die sich bewegt oder die man ziehen kann. Jede Zeile bekommt ihre eigene Breite, je nachdem, wo die Form gerade steht. Möglich wird das mit Pretext von Cheng Lou (März 2026): Die Bibliothek misst Text einmal über Canvas und bricht ihn danach rein rechnerisch um, ohne DOM-Layout. So lässt sich der Umbruch in jedem Bild neu berechnen. Fallback und reduzierte Bewegung: CSS shape-outside mit ruhender Form. Experiment, nur für Eigen- und Kulturprojekte.

Text und Bild reagieren aufeinander wie im gesetzten Magazin, nur lebendig. Das zeigt Haltung und Handwerk. Es fällt auf, weil CSS so etwas allein nicht kann.

Wann einsetzen

Ausstellungs- und Festivalseiten, Essays, Jahresberichte von Kulturhäusern, eigene Portfolio- und Experimentseiten. Dort, wo ein einzelner Textblock zur Bühne werden darf.

Wann nicht

Nicht auf normalen Kunden-, Firmen- oder Landingpages: dort bringt es laut Fachpresse keinen messbaren Nutzen. Nicht für lange Lesetexte, Formulare, Rechtliches. Nicht bei sehr schmalen Spalten (Wörter brechen sonst mitten im Wort). Nicht ohne Pause-Knopf, wenn sich die Form länger als 5 Sekunden bewegt.

Varianten

  • Form wandert langsam von selbst (mit Pause-Knopf)
  • Form folgt Ziehen mit Maus, Finger oder Pfeiltasten
  • Ruhende Form mit CSS shape-outside (Fallback, normale Websites)
  • Mehrere Formen oder Bilder mit Umriss (Pretext-Demo dynamic-layout)
  • Zeilen auf Canvas statt im DOM zeichnen

Umsetzung

Pretext 1.0 als ES-Modul laden (import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext'). Erst document.fonts.load() abwarten, dann einmal prepareWithSegments(text, '400 19px "Atkinson Hyperlegible Next"'). Je Bild: Umriss der Form als Polygon berechnen, für jedes Zeilenband (y bis y + Zeilenhöhe) die waagerechte Ausdehnung der Form plus Abstand bestimmen, daraus freie Abschnitte links und rechts bilden und den breitesten nehmen (mindestens ca. 140 px). Mit layoutNextLine(prepared, breite, cursor) die nächste Zeile holen, als absolut positioniertes span setzen (transform, textContent), cursor = line.end. Spans wiederverwenden, Höhe des Containers aus der letzten Zeile setzen. Absätze einzeln vorbereiten, lange Wörter mit ­ trennbar machen. Für Screenreader steht der Text einmal als normaler Absatz im DOM (visuell versteckt), die Zeilen-Spans sind aria-hidden. Fallback ohne Pretext, ohne Intl.Segmenter oder bei reduzierter Bewegung: gleiche Form als float mit shape-outside: circle() bzw. polygon() und shape-margin, Text als normaler Absatz. Ohne Bibliothek geht es auch mit Canvas measureText und eigenem Wortumbruch, dann aber ohne Pretexts Regeln für Trennung, Satzzeichen und Schriftsysteme.

Robust umsetzen

Telefon. Gleiches Prinzip mit kleinerer Form (Radius 62 px) und Schrift 17/27 px. Unter 560 px Spaltenbreite wandert die Form nur am rechten Rand auf und ab, der Text bleibt links, damit kein Abschnitt zu schmal wird. Ziehen mit dem Finger, touch-action: none nur auf der Form.

Ohne Bewegung. Keine Bewegung: Form ruht, Text umfließt sie per CSS shape-outside. Der Inhalt bleibt vollständig.

Barrierefrei. Text einmal als echter Absatz für Screenreader und Suche, die gesetzten Zeilen aria-hidden. Bewegung länger als 5 Sekunden braucht einen sichtbaren Pause-Knopf (WCAG 2.2.2). Form per Tastatur verschiebbar (Pfeiltasten), mit sichtbarem Fokus. Zeilenlänge im Blick behalten (höchstens etwa 68 Zeichen), Lesbarkeit geht vor Wirkung. Text bleibt markierbar.

Code

<!DOCTYPE html>
<!--
  Text fließt um eine bewegte Form (Experiment)
  Fundus · Typo-Techniken · https://cecodesign.de/lab/fundus/typo-techniken/text-um-form/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Text fließt um eine bewegte Form (Experiment) · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<script type="importmap" data-cdn="https://cdn.jsdelivr.net/npm/@chenglou/pretext@1.0.0/dist/layout.js">{"imports":{"@chenglou/pretext":"https://cdn.jsdelivr.net/npm/@chenglou/pretext@1.0.0/dist/layout.js"}}</script>
<style>
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Rezept kultur-programm-haltung: Schwarz auf Off-White, Spartenfarbe Kunst, knappe Out-Kurve ohne Überschwingen */
  --grund: #efede9; --text: #0b0b0b; --leise: #4f4d49; --linie: #0b0b0b; --zart: rgba(11, 11, 11, .14);
  --kunst: #2f7d4f; --musik: #c4421a; --film: #b0186b;
  --kurve: cubic-bezier(0.215, 0.61, 0.355, 1);
  --ui: 250ms;
  --abstand-form: 18px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', Verdana, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; }
a { color: inherit; }
.mono { font: 500 13px/1.4 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 3px solid var(--text); outline-offset: 3px; }

/* ---------- Navigation (HKW: 78 px, 1 px Linie) ---------- */
.nav { position: sticky; top: 0; z-index: 10; height: 78px; display: flex; align-items: center; gap: 32px; padding: 0 clamp(16px, 3vw, 40px); background: var(--grund); border-bottom: 1px solid var(--linie); }
.haus { font: 850 26px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.035em; text-decoration: none; display: flex; align-items: baseline; gap: 10px; }
.haus small { font: 500 12px/1 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; }
.nav ul { list-style: none; display: flex; gap: 26px; font: 600 16px/1 'Schibsted Grotesk', sans-serif; }
.nav ul a { text-decoration: none; padding: 6px 0; border-bottom: 2px solid transparent; transition: border-color var(--ui) var(--kurve); }
.nav ul a:hover { border-bottom-color: currentColor; }
.nav__rechts { margin-left: auto; display: flex; align-items: center; gap: 18px; }
.ticket { display: inline-flex; align-items: center; height: 44px; padding: 0 20px; background: var(--text); color: var(--grund); font: 700 16px/1 'Schibsted Grotesk', sans-serif; text-decoration: none; transition: background-color var(--ui) var(--kurve); }
.ticket:hover { background: var(--kunst); }
.leicht { font: 600 15px/1 'Schibsted Grotesk', sans-serif; text-decoration: underline; text-underline-offset: 4px; }

/* ---------- Kopf der Ausstellung ---------- */
.seite { padding: 0 clamp(16px, 3vw, 40px) 96px; max-width: 1360px; margin: 0 auto; overflow-x: clip; }
.ausstellung { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px 40px; padding: clamp(28px, 5vh, 56px) 0 clamp(20px, 3.4vh, 32px); border-bottom: 1px solid var(--linie); }
.ausstellung__oben { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px 22px; align-items: center; }
.sparte { display: inline-flex; align-items: center; gap: 8px; }
.sparte i { width: 14px; height: 14px; background: var(--kunst); }
.ausstellung h1 { font: 900 clamp(2.75rem, 7vw, 7rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.untertitel { font: 600 clamp(1.25rem, 1rem + 1vw, 1.75rem)/1.2 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; margin-top: 10px; max-width: 30ch; }
.eckdaten { display: grid; grid-template-columns: auto auto; gap: 6px 18px; justify-content: end; text-align: left; }
.eckdaten dt { color: var(--leise); }

/* ---------- Inhalt ---------- */
.inhalt { display: grid; grid-template-columns: minmax(0, 660px) minmax(220px, 280px); justify-content: space-between; gap: 64px; padding-top: clamp(24px, 4vh, 40px); }
.werkzeug { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin-bottom: 22px; }
.pause { display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 16px 0 12px; border: 1.5px solid var(--text); background: transparent; cursor: pointer; font: 600 15px/1 'Schibsted Grotesk', sans-serif; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.pause:hover { background: var(--text); color: var(--grund); }
.pause svg { width: 14px; height: 14px; }
.pause .weiter-ic { display: none; }
.pause[aria-pressed="true"] .halt-ic { display: none; }
.pause[aria-pressed="true"] .weiter-ic { display: block; }
.werkzeug .hinweis { color: var(--leise); font-size: 12px; }
.css-modus .werkzeug { display: none; }

.buehne { position: relative; }
.satz-live { position: relative; }
.zeile { position: absolute; left: 0; top: 0; white-space: pre; will-change: transform; font: 400 19px/30px 'Atkinson Hyperlegible Next', Verdana, sans-serif; }
.zeile.lead { font: 400 23px/34px 'Atkinson Hyperlegible Next', Verdana, sans-serif; }
.schmal .zeile { font-size: 17px; line-height: 27px; }
.schmal .zeile.lead { font-size: 20px; line-height: 29px; }

/* Form: eine Skulptur in der Spartenfarbe, als Knopf ziehbar */
.form-knopf { position: absolute; left: 0; top: 0; border: 0; background: none; padding: 0; cursor: grab; touch-action: none; clip-path: circle(46%); z-index: 2; }
.form-knopf:active, .form-knopf.zieht { cursor: grabbing; }
.form-knopf svg { display: block; width: 100%; height: 100%; overflow: visible; }
.form-knopf:focus-visible { outline: none; }   /* Ersatz: Fokusring im SVG (.fokusring) */
.fokusring { fill: none; stroke: var(--text); stroke-width: 3; stroke-dasharray: 6 6; opacity: 0; }
.form-knopf:focus-visible .fokusring { opacity: 1; }
.koerper { fill: var(--kunst); }
.hoehenlinie { fill: none; stroke: #efede9; stroke-opacity: .32; stroke-width: 1.2; }
.css-modus .form-knopf, .css-modus .satz-live, html:not(.js) .form-knopf { display: none; }

/* Fallback und reduzierte Bewegung: ruhende Form mit shape-outside */
.satz-css p { font: 400 19px/30px 'Atkinson Hyperlegible Next', Verdana, sans-serif; max-width: 68ch; margin-bottom: 18px; }
.satz-css p.vorspann { font-size: 23px; line-height: 34px; margin-bottom: 24px; }
.live-modus .satz-css { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.form-float { float: right; width: 260px; height: 260px; margin: 6px 0 12px var(--abstand-form); shape-outside: circle(48%); shape-margin: var(--abstand-form); }
.form-float.links { float: left; margin: 6px var(--abstand-form) 12px 0; }
.form-float svg { width: 100%; height: 100%; display: block; overflow: visible; }

/* Termine */
.termine { border-top: 1px solid var(--linie); align-self: start; }
.termine h2 { padding: 12px 0; }
.termine ul { list-style: none; }
.termine li { display: grid; gap: 4px; padding: 14px 0 15px; border-top: 1px solid var(--zart); }
.termine li b { font: 700 18px/1.25 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.termine time { color: var(--leise); }
.termine .ticket { margin-top: 18px; width: 100%; justify-content: center; }

/* Pult: Variante und Messwerte (nur Fundus) */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 20; display: grid; gap: 8px; width: 270px; padding: 12px 14px; background: var(--text); color: var(--grund); font: 500 11px/1.45 'IBM Plex Mono', monospace; letter-spacing: .04em; }
.pult .titel { text-transform: uppercase; letter-spacing: .1em; display: flex; justify-content: space-between; }
.pult .titel b { color: #8fd3a6; font-weight: 500; }
.pult [role="group"] { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid rgba(239, 237, 233, .4); }
.pult button { background: none; border: 0; color: inherit; font: inherit; padding: 7px 6px; cursor: pointer; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.pult button + button { border-left: 1px solid rgba(239, 237, 233, .4); }
.pult button[aria-pressed="true"] { background: var(--grund); color: var(--text); }
.pult button:focus-visible { outline: 2px solid #8fd3a6; outline-offset: 2px; }
.pult .werte { color: rgba(239, 237, 233, .7); min-height: 2.9em; }

/* Reduzierte Bewegung: Übergänge aus (die Form ruht ohnehin, siehe Skript) */
.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (hover: none) {
  .pause { height: 44px; }
  .ticket:hover { background: var(--text); }
}
@media (max-width: 1100px) {
  .nav ul { display: none; }
  .inhalt { grid-template-columns: minmax(0, 1fr); }
  .termine { max-width: 700px; }
}
@media (max-width: 720px) {
  .ausstellung { grid-template-columns: 1fr; }
  .eckdaten { justify-content: start; }
  .leicht { display: none; }
  .satz-css p { font-size: 17px; line-height: 27px; }
  .satz-css p.vorspann { font-size: 20px; line-height: 29px; }
  .form-float { width: 150px; height: 150px; }
}
@media (max-width: 380px) {
  .nav { gap: 12px; }
  .ticket { padding: 0 14px; }
}

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .termine, .vorschau .werkzeug, .vorschau .untertitel, .vorschau .leicht { display: none; }
.vorschau .ausstellung { padding: 22px 0 18px; }
.vorschau .eckdaten { display: none; }
.vorschau .inhalt { grid-template-columns: minmax(0, 1fr); padding-top: 22px; }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
  .form-knopf:focus-visible { outline: 2px solid Highlight; }
}
</style>
</head>
<body>
<header class="nav">
  <a class="haus" href="#">Eckhaus <small>Gotha</small></a>
  <nav aria-label="Hauptnavigation"><ul><li><a href="#">Programm</a></li><li><a href="#">Ausstellungen</a></li><li><a href="#">Besuch</a></li><li><a href="#">Vermittlung</a></li></ul></nav>
  <div class="nav__rechts"><a class="leicht" href="#">Leichte Sprache</a><a class="ticket" href="#">Tickets</a></div>
</header>

<div class="seite">
  <section class="ausstellung" aria-labelledby="titel">
    <div class="ausstellung__oben mono"><span class="sparte"><i aria-hidden="true"></i>Kunst</span><span>Ausstellung · Halle 2</span></div>
    <div>
      <h1 id="titel">Langsame Körper</h1>
      <p class="untertitel">Ilse Brenner. Skulptur zwischen Ruhe und Bewegung</p>
    </div>
    <dl class="eckdaten mono">
      <dt>Dauer</dt><dd><time datetime="2026-11-21">21.11.2026</time> – <time datetime="2027-03-07">07.03.2027</time></dd>
      <dt>Offen</dt><dd>Di – So 10:00 – 18:00</dd>
      <dt>Eintritt</dt><dd>8 € · ermäßigt 5 €</dd>
    </dl>
  </section>

  <div class="inhalt">
    <article class="essay" aria-label="Text zur Ausstellung">
      <div class="werkzeug">
        <button class="pause" type="button" aria-pressed="false" data-pause>
          <svg class="halt-ic" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 2h3v10H3zM8 2h3v10H8z" fill="currentColor"/></svg>
          <svg class="weiter-ic" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 1.5v11L12 7z" fill="currentColor"/></svg>
          <span data-pause-text>Bewegung anhalten</span>
        </button>
        <span class="mono hinweis" id="form-hinweis">Form ziehen oder mit Pfeiltasten verschieben</span>
      </div>

      <div class="buehne" data-buehne>
        <div class="satz-live" aria-hidden="true" data-live></div>
        <button class="form-knopf" type="button" aria-label="Skulptur verschieben" aria-describedby="form-hinweis" data-form>
          <svg data-form-svg aria-hidden="true"><path class="koerper" data-koerper/><g data-linien></g><circle class="fokusring" data-fokusring/></svg>
        </button>
        <div class="satz-css" data-css>
          <div class="form-float" aria-hidden="true" data-float><svg data-float-svg><path class="koerper" data-float-koerper/><g data-float-linien></g></svg></div>
          <p class="vorspann">Ilse Brenner formt Körper, die sich kaum zu bewegen scheinen. Erst wer bleibt, merkt, dass sie es doch tun.</p>
          <p>Für die Halle 2 hat die Bild&shy;hauerin aus Gotha zwölf Arbeiten der letzten fünf Jahre zusammen&shy;gestellt, dazu drei neue Stücke, die eigens für diesen Raum entstanden sind. Gips, gebrannter Ton und Kalkstein aus dem Thüringer Becken: Brenner arbeitet mit Materialien, die Gewicht haben und Zeit speichern. Ihre Ober&shy;flächen tragen die Spuren der Hand, des Werkzeugs und des Trocknens.</p>
          <p>Die Ausstellung fragt, wie viel Bewegung in etwas Ruhendem steckt. Manche Skulpturen hängen an Seilen und drehen sich mit der Luft im Raum, so langsam, dass man es nur am Schatten ablesen kann. Andere stehen auf dem Boden und verändern sich mit dem Licht der Ober&shy;lichter, je nach Tageszeit und Wetter.</p>
          <p>Wer mag, setzt sich auf eine der Bänke und bleibt eine Viertel&shy;stunde. Dafür ist die Halle eingerichtet: wenige Werke, viel Abstand, keine Texte an den Wänden. Alles zu den einzelnen Arbeiten steht im Heft am Eingang, auch in Leichter Sprache und als Audio&shy;führung.</p>
          <p>Diese Seite spielt mit derselben Idee. Der Text weicht der Form aus, die langsam durch ihn wandert. Ziehen Sie die Form an eine andere Stelle oder halten Sie die Bewegung an.</p>
        </div>
      </div>
    </article>

    <aside class="termine" aria-labelledby="termine-titel">
      <h2 class="mono" id="termine-titel">Im Programm</h2>
      <ul>
        <li><time class="mono" datetime="2026-11-20T19:00">Fr 20.11. · 19:00</time><b>Eröffnung mit der Künstlerin</b></li>
        <li><time class="mono" datetime="2026-12-06T15:00">So 06.12. · 15:00</time><b>Führung: Gewicht und Zeit</b></li>
        <li><time class="mono" datetime="2027-01-16T11:00">Sa 16.01. · 11:00</time><b>Tastführung für blinde und sehbehinderte Gäste</b></li>
        <li><time class="mono" datetime="2027-02-04T18:00">Do 04.02. · 18:00 – 22:00</time><b>Langer Abend in Halle 2</b></li>
      </ul>
      <a class="ticket" href="#">Zeitfenster buchen</a>
    </aside>
  </div>
</div>

<div class="pult">
  <p class="titel"><span>Experiment</span><b data-motor>Pretext 1.0</b></p>
  <div role="group" aria-label="Variante">
    <button type="button" data-v="pretext" aria-pressed="true">Live-Umfluss</button>
    <button type="button" data-v="css" aria-pressed="false">shape-outside</button>
  </div>
  <p class="werte" data-werte>lädt …</p>
</div>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const buehne = $('[data-buehne]'), live = $('[data-live]'), cssSatz = $('[data-css]');
  const formKnopf = $('[data-form]'), formSvg = $('[data-form-svg]'), koerper = $('[data-koerper]'), linien = $('[data-linien]'), fokusring = $('[data-fokusring]');
  const pauseKnopf = $('[data-pause]'), pauseText = $('[data-pause-text]');
  const werte = $('[data-werte]'), motor = $('[data-motor]');
  const essay = $('.essay');
  const NS = 'http://www.w3.org/2000/svg';
  const ABSTAND = 18, MIN_ABSCHNITT = 140, PUNKTE = 72, UMLAUF = 0.035;   // Umdrehungen je Sekunde
  const HOEHENLINIEN = [0.8, 0.6, 0.4, 0.2];

  /* ---------- Die Form: ein Kiesel, der sich langsam verformt ---------- */
  function radius(theta, R, u) {
    return R * (1 + 0.07 * Math.sin(3 * theta + 0.6 * u) + 0.05 * Math.cos(2 * theta - 0.9 * u) + 0.025 * Math.sin(5 * theta + 1.3 * u));
  }
  function umriss(cx, cy, R, u, faktor = 1, versatz = 0) {
    const p = [];
    for (let i = 0; i < PUNKTE; i++) {
      const th = i / PUNKTE * Math.PI * 2 + 0.15 * u;
      const r = radius(th, R, u) * faktor;
      p.push([cx + Math.cos(th) * r + versatz * R * .18, cy + Math.sin(th) * r * 0.92 - versatz * R * .12]);
    }
    return p;
  }
  const pfad = pts => 'M' + pts.map(q => q[0].toFixed(1) + ' ' + q[1].toFixed(1)).join('L') + 'Z';
  function zeichneForm(svgKoerper, svgLinien, R, u) {
    svgKoerper.setAttribute('d', pfad(umriss(0, 0, R, u)));
    if (svgLinien.childNodes.length !== HOEHENLINIEN.length) {
      svgLinien.innerHTML = '';
      HOEHENLINIEN.forEach(() => { const l = document.createElementNS(NS, 'path'); l.setAttribute('class', 'hoehenlinie'); svgLinien.appendChild(l); });
    }
    HOEHENLINIEN.forEach((f, i) => svgLinien.childNodes[i].setAttribute('d', pfad(umriss(0, 0, R, u * 1.1 + i * .4, f, 1 - f))));
  }

  /* ---------- Freie Abschnitte je Zeilenband ---------- */
  function abschnitt(poly, y0, y1, W) {
    let min = Infinity, max = -Infinity;
    for (let i = 0; i < poly.length; i++) {
      const a = poly[i], b = poly[(i + 1) % poly.length];
      if ((a[1] < y0 && b[1] < y0) || (a[1] > y1 && b[1] > y1)) continue;
      // Kante auf das Band zuschneiden
      const enden = [a, b].map(p => p[1] >= y0 && p[1] <= y1 ? p[0] : null);
      for (const yy of [y0, y1]) if ((a[1] - yy) * (b[1] - yy) < 0) enden.push(a[0] + (b[0] - a[0]) * (yy - a[1]) / (b[1] - a[1]));
      for (const x of enden) if (x !== null) { if (x < min) min = x; if (x > max) max = x; }
    }
    if (min === Infinity) return [0, W];
    const links = [0, Math.min(W, min - ABSTAND)], rechts = [Math.max(0, max + ABSTAND), W];
    const bl = links[1] - links[0], br = rechts[1] - rechts[0];
    return bl >= br ? links : rechts;
  }

  /* ---------- Satz mit Pretext ---------- */
  let P = null, vorb = null, schmal = null, zeilen = [], formZustand = { x: 0, y: 0, R: 120, u: 0 }, hoehe = 900, letzteMs = 0;
  const absaetze = [...cssSatz.querySelectorAll('p')].map(p => ({ text: p.textContent, lead: p.classList.contains('vorspann') }));
  function schriften() {
    const s = buehne.clientWidth < 560;
    return {
      schmal: s,
      lead: { font: `400 ${s ? 20 : 23}px "Atkinson Hyperlegible Next"`, lh: s ? 29 : 34 },
      body: { font: `400 ${s ? 17 : 19}px "Atkinson Hyperlegible Next"`, lh: s ? 27 : 30 }
    };
  }
  function vorbereiten() {
    const f = schriften();
    schmal = f.schmal;
    buehne.classList.toggle('schmal', schmal);
    vorb = absaetze.map(a => {
      const s = a.lead ? f.lead : f.body;
      return { prep: P.prepareWithSegments(a.text, s.font), lh: s.lh, klasse: a.lead ? 'zeile lead' : 'zeile', abstand: a.lead ? 24 : 18 };
    });
  }
  function zeile(i, text, x, y, klasse) {
    let el = zeilen[i];
    if (!el) { el = zeilen[i] = document.createElement('span'); live.appendChild(el); }
    if (el._t !== text) { el.textContent = text; el._t = text; }
    if (el.className !== klasse) el.className = klasse;
    el.style.transform = `translate(${x.toFixed(1)}px, ${y}px)`;
    el.hidden = false;
  }
  function setzen() {
    const t0 = performance.now();
    const W = buehne.clientWidth, { x, y: fy, R, u } = formZustand;
    const poly = umriss(x, fy, R, u);
    let y = 0, n = 0;
    for (const a of vorb) {
      let cursor, schutz = 0;
      while (schutz++ < 400) {
        const [l, r] = abschnitt(poly, y, y + a.lh, W);
        if (r - l < MIN_ABSCHNITT) { y += a.lh; continue; }
        const z = P.layoutNextLine(a.prep, r - l, cursor);
        if (!z) break;
        // Zerbräche ein Wort in einem schmalen Abschnitt, lieber die Zeile freilassen
        if (z.end.graphemeIndex > 0 && r - l < W - 1) { y += a.lh; continue; }
        zeile(n++, z.text, l, y, a.klasse);
        cursor = z.end; y += a.lh;
      }
      y += a.abstand;
    }
    for (let i = n; i < zeilen.length; i++) zeilen[i].hidden = true;
    hoehe = Math.max(y, fy + R * 1.1);
    live.style.height = hoehe + 'px';
    // Form-Knopf über die Form legen
    const g = R * 2.5;
    formKnopf.style.width = formKnopf.style.height = g + 'px';
    formKnopf.style.transform = `translate(${(x - g / 2).toFixed(1)}px, ${(fy - g / 2).toFixed(1)}px)`;
    formSvg.setAttribute('viewBox', `${-g / 2} ${-g / 2} ${g} ${g}`);
    fokusring.setAttribute('r', R * 1.12);
    zeichneForm(koerper, linien, R, u);
    letzteMs = letzteMs * .9 + (performance.now() - t0) * .1;
    return n;
  }

  /* ---------- Bahn der Form ---------- */
  let modus = 'css', laeuft = true, zieht = null, raf = 0, zeit = 0, vorher = 0, tempo = 1, sichtHoehe = 560;
  function bahn() {
    const W = buehne.clientWidth, R = formZustand.R, w = 2 * Math.PI * UMLAUF * zeit;
    if (schmal) {   // schmale Spalte: am rechten Rand auf und ab, Text bleibt links
      formZustand.x = W - R * 0.55 + R * 0.22 * Math.sin(w * 1.3);
      formZustand.y = R * 0.9 + (Math.min(hoehe, sichtHoehe) - R * 1.8) * (0.5 - 0.5 * Math.cos(w));
    } else {
      formZustand.x = W / 2 + (W / 2 - R * 0.7) * Math.sin(w);
      formZustand.y = R * 0.85 + (Math.min(hoehe, sichtHoehe) - R * 1.7) * (0.5 - 0.5 * Math.cos(w * 0.5 + 0.6));
    }
    formZustand.u = w * 2.2;
  }
  function bild(jetzt) {
    raf = 0;
    const dt = Math.min(0.05, (jetzt - vorher) / 1000); vorher = jetzt;
    if (laeuft && !zieht) { zeit += dt * tempo; bahn(); }
    const n = setzen();
    werte.textContent = `${n} Zeilen · Umbruch ${letzteMs.toFixed(2).replace('.', ',')} ms je Bild · Abstand ${ABSTAND} px`;
    if (modus === 'live' && (laeuft || zieht)) raf = requestAnimationFrame(bild);
  }
  function anstossen() { if (!raf && modus === 'live') { vorher = performance.now(); raf = requestAnimationFrame(bild); } }

  /* ---------- Ziehen und Tastatur ---------- */
  function anhalten(an) {
    laeuft = !an;
    pauseKnopf.setAttribute('aria-pressed', String(an));
    pauseText.textContent = an ? 'Bewegung fortsetzen' : 'Bewegung anhalten';
    anstossen();
  }
  pauseKnopf.addEventListener('click', () => anhalten(laeuft));
  function begrenzen() {
    const W = buehne.clientWidth, R = formZustand.R;
    formZustand.x = Math.max(-R * 0.3, Math.min(W + R * 0.3, formZustand.x));
    formZustand.y = Math.max(R * 0.4, Math.min(hoehe - R * 0.4, formZustand.y));
  }
  formKnopf.addEventListener('pointerdown', e => {
    if (modus !== 'live') return;
    const r = buehne.getBoundingClientRect();
    zieht = { dx: formZustand.x - (e.clientX - r.left), dy: formZustand.y - (e.clientY - r.top) };
    formKnopf.setPointerCapture(e.pointerId); formKnopf.classList.add('zieht');
    if (laeuft) anhalten(true);
    anstossen();
  });
  formKnopf.addEventListener('pointermove', e => {
    if (!zieht) return;
    const r = buehne.getBoundingClientRect();
    formZustand.x = e.clientX - r.left + zieht.dx; formZustand.y = e.clientY - r.top + zieht.dy;
    begrenzen();
  });
  const loslassen = () => { if (!zieht) return; zieht = null; formKnopf.classList.remove('zieht'); anstossen(); };
  formKnopf.addEventListener('pointerup', loslassen);
  formKnopf.addEventListener('pointercancel', loslassen);
  formKnopf.addEventListener('keydown', e => {
    const s = e.shiftKey ? 64 : 16, d = { ArrowLeft: [-s, 0], ArrowRight: [s, 0], ArrowUp: [0, -s], ArrowDown: [0, s] }[e.key];
    if (!d) return;
    e.preventDefault();
    if (laeuft) anhalten(true);
    formZustand.x += d[0]; formZustand.y += d[1]; begrenzen();
    if (!raf) bild(performance.now());
  });

  /* ---------- Fallback: ruhende Form mit shape-outside (Polygon aus derselben Form) ---------- */
  const float = $('[data-float]');
  function ruhendeForm() {
    const g = float.clientWidth, R = g * 0.42;
    const svg = $('[data-float-svg]');
    svg.setAttribute('viewBox', `${-g / 2} ${-g / 2} ${g} ${g}`);
    zeichneForm($('[data-float-koerper]'), $('[data-float-linien]'), R, 0);
    const pts = umriss(g / 2, g / 2, R, 0).filter((_, i) => i % 3 === 0);
    float.style.shapeOutside = 'polygon(' + pts.map(q => `${(q[0] / g * 100).toFixed(1)}% ${(q[1] / g * 100).toFixed(1)}%`).join(', ') + ')';
  }

  /* ---------- Modus und Varianten ---------- */
  const VARIANTEN = ['pretext', 'css'];
  let variante = VARIANTEN.includes(new URLSearchParams(location.search).get('v')) ? new URLSearchParams(location.search).get('v') : 'pretext';
  let pretextFehlt = false;
  function modusSetzen() {
    const willLive = variante === 'pretext' && P && !reduziert();
    modus = willLive ? 'live' : 'css';
    essay.classList.toggle('live-modus', modus === 'live');
    essay.classList.toggle('css-modus', modus === 'css');
    document.querySelectorAll('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === variante)));
    if (modus === 'live') {
      vorbereiten(); formZustand.R = schmal ? 62 : Math.min(128, buehne.clientWidth * 0.19);
      const oben = buehne.getBoundingClientRect().top + scrollY;
      sichtHoehe = Math.max(formZustand.R * 2.4, Math.min(620, innerHeight - oben - 24));
      bahn(); bild(performance.now()); anstossen();
      motor.textContent = 'Pretext 1.0';
    } else {
      cancelAnimationFrame(raf); raf = 0;
      ruhendeForm();
      motor.textContent = 'CSS';
      werte.textContent = pretextFehlt ? 'Pretext nicht geladen: Fallback mit shape-outside, ruhende Form.'
        : reduziert() && variante === 'pretext' ? 'Bewegung reduziert: ruhende Form mit shape-outside.'
        : `shape-outside: polygon() aus derselben Form · shape-margin ${ABSTAND} px`;
    }
  }
  document.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => {
    variante = b.dataset.v;
    const q = new URLSearchParams(location.search); q.set('v', variante);
    history.replaceState(null, '', location.pathname + '?' + q.toString() + location.hash);
    modusSetzen();
  }));
  let breiteAlt = 0;
  new ResizeObserver(() => {
    const w = buehne.clientWidth; if (Math.abs(w - breiteAlt) < 1) return; breiteAlt = w;
    if (modus === 'live') modusSetzen(); else ruhendeForm();
  }).observe(buehne);
  // .reduziert kann der Rahmen nachträglich setzen (Detailseite)
  let warReduziert = reduziert();
  new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); modusSetzen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });

  ruhendeForm(); modusSetzen();
  const bereit = (async () => {
    try {
      if (!('Segmenter' in Intl)) throw new Error('Intl.Segmenter fehlt');
      const mod = await import('@chenglou/pretext');
      await Promise.all(['400 19px "Atkinson Hyperlegible Next"', '400 23px "Atkinson Hyperlegible Next"'].map(f => document.fonts.load(f)));
      await document.fonts.ready;
      P = mod;
    } catch (e) { pretextFehlt = true; }
    modusSetzen();
  })();

  /* ---------- Vorführung ---------- */
  let wechselZeit = 0, seite = false;
  function vorfuehren() {
    neustart();
    html.classList.add('vorfuehrung');
    tempo = 2.4;                     // Galerie: Bahn schneller, damit der Umfluss sofort sichtbar wird
    bereit.then(() => { if (modus === 'live') { anhalten(false); anstossen(); } });
    // Variante CSS: Form wechselt die Seite, der Text umfließt sie neu
    wechselStarten();
  }
  function pause() { clearInterval(wechselZeit); if (modus === 'live' && laeuft) anhalten(true); }
  function wechselStarten() {
    clearInterval(wechselZeit);
    wechselZeit = setInterval(() => {
      if (modus !== 'css' || reduziert()) return;
      seite = !seite; float.classList.toggle('links', seite);
    }, 2600);
  }
  function weiter() { if (modus === 'live') anhalten(false); if (html.classList.contains('vorfuehrung')) wechselStarten(); }
  function neustart() {
    clearInterval(wechselZeit); html.classList.remove('vorfuehrung');
    tempo = 1; zeit = 0; seite = false; float.classList.remove('links');
    zieht = null; anhalten(false);
    if (modus === 'live') { bahn(); bild(performance.now()); }
  }

})();
</script>
</body>
</html>