Zum Inhalt
← Schrift-Animationen

Schreibmaschine mit natürlichem Takt

Ein Suchfeld tippt Beispielsuchen im menschlichen Takt, löscht nur bis zur ersten Abweichung und schreibt weiter.

Text erscheint Zeichen für Zeichen, am Ende steht ein Cursor als Balken oder Block. Der Takt ist nicht gleichmäßig: Jedes Zeichen bekommt etwas Zufall, an Wortgrenzen und nach Satzzeichen entstehen kurze Pausen. Wechselt der Inhalt, wird nur bis zur ersten Abweichung zurückgelöscht und dann neu getippt. Eine moderne Variante ist der Streaming-Look aus KI-Chats: Wörter erscheinen in kleinen Brocken mit kurzem Fade.

Der unregelmäßige Takt wirkt menschlich, ein gleichmäßiger wirkt maschinell. Durch KI-Oberflächen ist tippender oder streamender Text 2026 ein vertrautes Zeichen für: Hier denkt oder antwortet etwas. Im Suchfeld zeigt er nebenbei, wie man formulieren darf.

Wann einsetzen

Hero mit wechselnden Begriffen, Suchfelder mit Beispielanfragen, Chat- und KI-Produktdemos, Terminal-Inszenierungen.

Wann nicht

Für Kernaussagen, die sofort lesbar sein müssen. Für lange Texte, weil das Warten nervt.

Varianten

  • Gleichmäßiger Takt (GSAP TextPlugin mit delimiter '' und ease 'none')
  • Natürlicher Takt mit Zufall, Wort- und Satzzeichenpausen (Motion+ Typewriter: speed, variance, backspace 'character' | 'word' | 'all')
  • Wortweises Streaming mit Fade (KI-Chat-Look)
  • Diff-Modus: nur geänderte Zeichen löschen und tippen (TextPlugin type: 'diff')
  • An Sichtbarkeit oder Scroll gekoppelt: play und pause, sobald das Element im Viewport ist

Umsetzung

Eigenbau mit rekursivem setTimeout oder async/await: delay = takt × (1 + (Math.random() × 2 − 1) × varianz), plus Wortpause nach Leerzeichen, plus Satzzeichenpause nach . , ! ? : ; ' " - ( ). Vor dem Wechsel gemeinsamen Anfang ermitteln und nur den Rest mit 0,2 × Takt löschen. Cursor als eigenes Element mit @keyframes und steps(1); beim Tippen nicht blinken lassen. Gegen springendes Layout alle möglichen Endtexte unsichtbar (visibility: hidden) ins selbe Grid-Feld legen. Als Platzhalter im Suchfeld: echtes Feld darunter, Tipptext als aria-hidden-Overlay, das bei Fokus oder Eingabe ausblendet. Mit GSAP: gsap.to(el, { text: { value: '…', delimiter: '' }, ease: 'none', duration }).

Robust umsetzen

Telefon. Unverändert. Lange Beispieltexte umbrechen lassen und die Höhe über Geister-Text sichern.

Ohne Bewegung. Endtext sofort anzeigen, Cursor nicht blinken lassen.

Barrierefrei. Den vollständigen Text als aria-label oder visuell versteckt anbieten, den sichtbaren Tipptext auf aria-hidden setzen. Keine aria-live-Region, die jedes Zeichen ansagt. Im Suchfeld beschreibt das aria-label ein Beispiel, der tippende Platzhalter bleibt stumm. Tippen pausieren, wenn das Feld Fokus hat oder nicht sichtbar ist.

Code

<!DOCTYPE html>
<!--
  Schreibmaschine mit natürlichem Takt
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/typewriter-natural/
  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>Schreibmaschine mit natürlichem Takt · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Libre Caslon Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/libre-caslon-display@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --grund: #fbf9f4; --sand: #efe8df; --sand-tief: #e3d9cb; --papier: #fffdf9;
  --marke: #15344b; --text: #313131; --mittel: #5c5751; --leise: #69645e; --linie: #d9d2c8; --linie-stark: #b5b2af;
  --akzent: #b05a17;                                   /* nur der Bewertungs-CTA */
  --ui: cubic-bezier(0.165, 0.84, 0.44, 1);            /* easeOutQuart, UI 400 ms */
  --takt: 75ms;                                        /* Motion+ speed normal */
  --blink: 500ms;                                      /* Cursor: je Phase */
  --rand: clamp(16px, 4vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Hanken Grotesk', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { font-size: 1.0625rem; line-height: 1.55; }
a { color: inherit; }
button { font: inherit; color: inherit; }
.label { font-size: .75rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--leise); line-height: 1.3; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.ziffern { font-variant-numeric: tabular-nums; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Seitenkopf ---------- */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px var(--rand); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--marke); }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { display: block; font-weight: 600; font-size: 15px; letter-spacing: .2em; text-transform: uppercase; line-height: 1.1; }
.marke small { display: block; font-size: 11px; letter-spacing: .06em; color: var(--leise); line-height: 1.3; }
.nav { display: flex; gap: clamp(18px, 2.4vw, 34px); list-style: none; padding: 0; font-size: 14px; font-weight: 500; }
.nav a { text-decoration: none; color: var(--mittel); transition: color 200ms var(--ui); }
.nav a:hover { color: var(--marke); }
.kopf-rechts { display: flex; align-items: center; gap: 20px; font-size: 14px; }
.tel { color: var(--mittel); text-decoration: none; }
.cta { display: inline-flex; align-items: center; height: 40px; padding: 0 16px; border-radius: 3px; background: var(--akzent); color: #fff; font-size: 14px; font-weight: 600; text-decoration: none; transition: background 200ms var(--ui); }
.cta:hover { background: #9a4e13; }
.menue { display: none; height: 40px; padding: 0 14px; border: 1px solid var(--linie-stark); border-radius: 3px; background: none; font-size: 14px; font-weight: 500; }

/* ---------- Bühne ---------- */
.buehne { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; }
.hero { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: clamp(28px, 5vw, 80px); align-items: center; padding: clamp(28px, 5vh, 64px) var(--rand); }
.links { display: grid; gap: clamp(16px, 2.4vh, 26px); max-width: 620px; }
h1 { font-family: 'Libre Caslon Display', 'Times New Roman', serif; font-weight: 400; font-size: clamp(2.25rem, 1.4rem + 3.2vw, 4.5rem); line-height: 1.08; letter-spacing: -.005em; color: var(--marke); text-wrap: balance; }
.lead { color: var(--mittel); max-width: 44ch; }

/* ---------- Suchfeld mit Tippeffekt ---------- */
.suche { background: var(--papier); border: 1px solid var(--linie); border-radius: 4px; box-shadow: 0 1px 0 rgba(21, 52, 75, .04), 0 22px 44px -28px rgba(21, 52, 75, .35); transition: border-color 400ms var(--ui), box-shadow 400ms var(--ui); }
.suche:focus-within { border-color: var(--marke); box-shadow: 0 0 0 3px rgba(21, 52, 75, .1), 0 22px 44px -28px rgba(21, 52, 75, .35); }
.reiter { display: flex; align-items: center; gap: 22px; padding: 0 20px; border-bottom: 1px solid var(--linie); }
.reiter button { position: relative; height: 44px; padding: 0; border: 0; background: none; font-size: .75rem; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--leise); cursor: pointer; transition: color 200ms var(--ui); }
.reiter button::after { content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--marke); transform: scaleX(0); transform-origin: left; transition: transform 400ms var(--ui); }
.reiter button[aria-selected='true'] { color: var(--marke); }
.reiter button[aria-selected='true']::after { transform: scaleX(1); }
.reiter .hinweis-worte { margin-left: auto; font-size: 12px; color: var(--leise); }

.feld { display: grid; padding: 18px 20px 6px; font-size: clamp(19px, 1rem + .55vw, 24px); line-height: 1.4; }
.feld > * { grid-area: 1 / 1; }
.feld textarea { font: inherit; color: var(--text); background: transparent; border: 0; padding: 0; resize: none; overflow: hidden; outline: none; min-height: 2.8em; }
.geister { display: grid; visibility: hidden; pointer-events: none; }
.geister > span { grid-area: 1 / 1; }
.tipp { pointer-events: none; color: var(--mittel); transition: opacity 200ms var(--ui); }
.feld textarea:focus ~ .tipp, .feld textarea:not(:placeholder-shown) ~ .tipp { opacity: 0; }
.tipp-text { transition: opacity 200ms linear; }
.tipp-text.weg { opacity: 0; }
.brocken { animation: einstroemen 400ms var(--ui) both; }
@keyframes einstroemen { from { opacity: 0; filter: blur(3px); } to { opacity: 1; filter: blur(0); } }

/* Cursor: Balken, blinkt nur im Leerlauf, beim Tippen steht er */
.cursor { display: inline-block; width: 2px; height: 1.05em; margin-left: 1px; vertical-align: -.16em; background: var(--marke); animation: blinken calc(var(--blink) * 2) steps(1) infinite; }
.cursor.tippt { animation: none; opacity: 1; }
@keyframes blinken { 0% { opacity: 1; } 50% { opacity: 0; } }

.fuss-suche { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 12px 12px 20px; }
.treffer { font-size: 14px; color: var(--mittel); transition: opacity 400ms var(--ui); min-width: 0; }
.treffer b { color: var(--marke); font-weight: 600; }
.treffer.wartet { opacity: .86; }
.los { flex: none; display: inline-flex; align-items: center; gap: 10px; height: 44px; padding: 0 18px; border: 0; border-radius: 3px; background: var(--marke); color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 200ms var(--ui); }
.los:hover { background: #0c2537; }
.los svg { width: 16px; height: 16px; }
.zweiter-weg { font-size: 15px; color: var(--mittel); }
.zweiter-weg a { color: var(--marke); font-weight: 600; text-underline-offset: .22em; text-decoration-thickness: 1px; }

/* ---------- Objektbild (eigene SVG-Szenen) ---------- */
.objekt { display: grid; gap: 14px; justify-self: end; width: 100%; max-width: 560px; }
.rahmen { position: relative; width: 100%; aspect-ratio: 5 / 6; max-height: calc(100svh - 300px); min-height: 300px; overflow: hidden; background: var(--sand); border-radius: 2px; }
.szene { position: absolute; inset: 0; visibility: hidden; overflow: hidden; }
.szene svg { width: 100%; height: 100%; display: block; }
.szene.vorher { visibility: visible; z-index: 1; }
.szene.an { visibility: visible; z-index: 2; }
.szene.auf { animation: aufdecken 1000ms var(--ui) both; }
.szene.auf svg { animation: landen 1000ms var(--ui) both; }
@keyframes aufdecken { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes landen { from { transform: scale(1.06); } to { transform: scale(1); } }
.marke-bild { position: absolute; left: 14px; top: 14px; z-index: 3; padding: 6px 10px; background: rgba(255, 253, 249, .88); border-radius: 2px; font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--marke); }
.bildtext { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; border-top: 1px solid var(--linie); padding-top: 12px; transition: opacity 400ms var(--ui); }
.bildtext.wechsel { opacity: 0; }
.bildtext strong { display: block; font-size: 15px; font-weight: 600; color: var(--marke); }
.bildtext span { font-size: 14px; color: var(--mittel); }
.bildtext .preis { font-size: 16px; font-weight: 600; color: var(--marke); white-space: nowrap; }

/* ---------- Demo-Leiste (Umschalter, Werte, Taktspur) ---------- */
.demo { display: flex; align-items: center; gap: 14px 24px; flex-wrap: wrap; padding: 12px var(--rand) 16px; border-top: 1px solid var(--linie); }
.modi { display: flex; flex-wrap: wrap; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: color 150ms, background 150ms, border-color 150ms; }
.modi button:hover { color: var(--marke); border-color: var(--linie-stark); }
.modi button[aria-pressed='true'] { background: var(--marke); color: var(--grund); border-color: var(--marke); }
.spur { display: flex; align-items: flex-end; gap: 2px; height: 22px; width: 150px; flex: none; }
.spur i { flex: 1; min-height: 1px; height: 4%; background: var(--linie-stark); border-radius: 1px; transition: height 150ms linear; }
.spur i:last-child { background: var(--marke); }
:root.reduziert .spur { display: none; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; flex: 1 1 360px; }
.werte b { color: var(--marke); font-weight: 500; }

/* ---------- Weiter unten: Eigentümer ---------- */
.eigentuemer { background: var(--sand); padding: clamp(64px, 12vh, 140px) var(--rand); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(32px, 6vw, 96px); align-items: center; }
.eigentuemer h2 { font-family: 'Libre Caslon Display', 'Times New Roman', serif; font-weight: 400; font-size: clamp(2rem, 1.2rem + 2.4vw, 3.5rem); line-height: 1.1; color: var(--marke); margin: 14px 0 20px; text-wrap: balance; }
.eigentuemer p { color: var(--mittel); max-width: 46ch; }
.fakten { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 8px 36px; margin-top: 32px; padding: 0; list-style: none; }
.fakten b { display: block; font-size: 28px; font-weight: 500; color: var(--marke); line-height: 1.1; }
.fakten span { font-size: 13px; color: var(--mittel); }
.nachricht { background: var(--papier); border: 1px solid var(--linie); border-radius: 4px; padding: 22px 24px 20px; box-shadow: 0 30px 60px -40px rgba(21, 52, 75, .4); max-width: 520px; }
.nachricht header { display: flex; align-items: center; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--linie); margin-bottom: 16px; }
.nachricht .kuerzel { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--marke); color: #fff; font-size: 14px; font-weight: 600; letter-spacing: .04em; }
.nachricht header strong { display: block; font-size: 15px; color: var(--marke); }
.nachricht header span { font-size: 13px; color: var(--leise); }
.status { margin-left: auto; font-size: 12px; color: var(--leise); }
.status::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--linie-stark); margin-right: 7px; vertical-align: 1px; }
.status.schreibt::before { background: var(--akzent); }
.brief { display: grid; font-size: 17px; line-height: 1.55; color: var(--text); }
.brief > * { grid-area: 1 / 1; }
.brief .geist { visibility: hidden; }
.nachricht footer { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; font-size: 13px; color: var(--leise); }
.nachricht footer a { color: var(--marke); font-weight: 600; text-underline-offset: .22em; }
.recht { padding: 22px var(--rand) 28px; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; font-size: 13px; color: var(--leise); border-top: 1px solid var(--linie); }

/* ---------- Reduzierte Bewegung ---------- */
:root.reduziert .cursor { animation: none; opacity: .55; }
:root.reduziert .szene.auf, :root.reduziert .szene.auf svg, :root.reduziert .brocken { animation: none; }
:root.reduziert * { transition-duration: .01ms !important; }

/* ---------- Galerie-Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .eigentuemer, .vorschau .recht { display: none; }
.vorschau .buehne { height: 100svh; }
.vorschau .rahmen { max-height: calc(100svh - 300px); }
.vorschau .lead { display: none; }
.vorschau .hero { padding-block: 28px; }
.vorschau .werte span:nth-child(n+4) { display: none; }

/* ---------- Schmal ---------- */
@media (max-width: 1080px) { .nav { display: none; } .menue { display: inline-flex; align-items: center; } }
@media (max-width: 860px) {
  .hero { grid-template-columns: 1fr; padding-top: 32px; }
  .objekt { justify-self: stretch; max-width: none; }
  .rahmen { aspect-ratio: 4 / 3; max-height: none; min-height: 0; }
  .eigentuemer { grid-template-columns: 1fr; }
  .lead { display: none; }
}
@media (max-width: 560px) {
  .tel, .kopf .cta { display: none; }
  .marke small { display: none; }
  .reiter .hinweis-worte { display: none; }
  .feld { padding: 14px 16px 4px; }
  .fuss-suche { padding: 8px 10px 10px 16px; }
  .los span { display: none; }
  .los { padding: 0 14px; }
  .bildtext { flex-wrap: wrap; }
  .fakten { grid-template-columns: repeat(2, auto); }
  .spur { width: 110px; }
}
@media (max-width: 360px) { .kopf { gap: 12px; } .marke b { letter-spacing: .14em; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
@media (hover: none) {
  /* Touch: Tippen ins Feld hält den Effekt an und übernimmt den Vorschlag als Startpunkt */
  .los { height: 48px; }
  .reiter button { height: 48px; }
}
</style>
</head>
<body>
<div class="buehne">
  <header class="kopf">
    <a class="marke" href="#" aria-label="Saalkontor Immobilien, Startseite">
      <svg viewBox="0 0 30 30" aria-hidden="true"><rect x=".75" y=".75" width="28.5" height="28.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 24V13.5a7 7 0 0 1 14 0V24" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M15 6.5V24M8 17h14" stroke="currentColor" stroke-width="1"/></svg>
      <span><b>Saalkontor</b><small>Immobilien · Halle (Saale)</small></span>
    </a>
    <ul class="nav">
      <li><a href="#">Kaufen</a></li><li><a href="#">Mieten</a></li><li><a href="#">Verkaufen</a></li><li><a href="#">Bewerten</a></li><li><a href="#">Hallesche Notizen</a></li>
    </ul>
    <div class="kopf-rechts">
      <a class="tel ziffern" href="#">0345 470 18 20</a>
      <a class="cta" href="#eigentuemer">Bewertung anfragen</a>
      <button class="menue" type="button">Menü</button>
    </div>
  </header>

  <main class="hero">
    <div class="links">
      <p class="label">Kaufen und Mieten in Halle und im Saalekreis</p>
      <h1>Beschreiben Sie Ihr nächstes Zuhause.</h1>
      <p class="lead">Wir kennen jedes Viertel zwischen Giebichenstein und Südstadt. Schreiben Sie in eigenen Worten, was Sie suchen. Den Abgleich übernehmen wir.</p>

      <form class="suche" data-suche>
        <div class="reiter" role="tablist" aria-label="Angebotsart">
          <button type="button" role="tab" aria-selected="true" data-art="kaufen">Kaufen</button>
          <button type="button" role="tab" aria-selected="false" data-art="mieten">Mieten</button>
          <span class="hinweis-worte">In eigenen Worten</span>
        </div>
        <div class="feld">
          <textarea id="suchtext" rows="2" placeholder=" " aria-label="Was suchen Sie? In eigenen Worten, zum Beispiel: Altbauwohnung im Paulusviertel mit Balkon"></textarea>
          <span class="geister" aria-hidden="true" data-geister></span>
          <span class="tipp" aria-hidden="true"><span class="tipp-text" data-tipptext></span><span class="cursor" data-cursor></span></span>
        </div>
        <div class="fuss-suche">
          <p class="treffer ziffern wartet" data-treffer>Ort, Größe, Budget: alles in einem Satz</p>
          <button class="los" type="submit"><span>Suchen</span><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1 8h13M9 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
        </div>
      </form>
      <p class="zweiter-weg">Sie möchten verkaufen? <a href="#eigentuemer">Was ist meine Immobilie wert?</a></p>
    </div>

    <figure class="objekt">
      <div class="rahmen">
        <span class="marke-bild" data-viertel>Paulusviertel</span>
        <div class="szene an" data-szene="altbau" role="img" aria-label="Zeichnung: Gründerzeithaus mit fünf Geschossen in der Abenddämmerung">
          <svg viewBox="0 0 500 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
            <defs>
              <linearGradient id="a-himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd6db"/><stop offset=".55" stop-color="#e9e1d6"/><stop offset="1" stop-color="#f1dcc4"/></linearGradient>
              <linearGradient id="a-glas" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3b4b57"/><stop offset="1" stop-color="#22313c"/></linearGradient>
              <linearGradient id="a-licht" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f0c27d"/><stop offset="1" stop-color="#d99a52"/></linearGradient>
              <linearGradient id="a-schatten" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity=".1"/><stop offset=".35" stop-color="#000" stop-opacity="0"/></linearGradient>
              <g id="a-fenster"><rect x="-2" y="-2" width="40" height="70" fill="#f6f0e6"/><rect width="36" height="66" fill="url(#a-glas)"/><path d="M4 66 L30 0 H36 L12 66 Z" fill="#fff" opacity=".07"/><path d="M18 0v66M0 22h36" stroke="#f6f0e6" stroke-width="2"/><rect x="-5" y="66" width="46" height="5" fill="#d8cbb7"/></g>
              <g id="a-fenster-l"><rect x="-2" y="-2" width="40" height="70" fill="#f6f0e6"/><rect width="36" height="66" fill="url(#a-licht)"/><path d="M18 0v66M0 22h36" stroke="#f6f0e6" stroke-width="2"/><rect x="-5" y="66" width="46" height="5" fill="#d8cbb7"/></g>
              <path id="a-giebel" d="M-7 -6 L18 -22 L43 -6 Z" fill="#dccfba"/>
            </defs>
            <rect width="500" height="600" fill="url(#a-himmel)"/>
            <!-- Nachbarhäuser -->
            <rect x="-10" y="150" width="78" height="450" fill="#d6c9b6"/>
            <rect x="10" y="190" width="22" height="44" fill="#9aa3a8" opacity=".55"/><rect x="10" y="280" width="22" height="44" fill="#9aa3a8" opacity=".55"/><rect x="10" y="370" width="22" height="44" fill="#9aa3a8" opacity=".55"/>
            <rect x="432" y="176" width="90" height="424" fill="#cbbfae"/>
            <rect x="452" y="210" width="24" height="46" fill="#9aa3a8" opacity=".5"/><rect x="452" y="300" width="24" height="46" fill="#e7b977" opacity=".75"/><rect x="452" y="390" width="24" height="46" fill="#9aa3a8" opacity=".5"/>
            <!-- Dach -->
            <path d="M80 112 L100 64 H400 L420 112 Z" fill="#7d7a78"/>
            <path d="M100 64 H400" stroke="#6a6766" stroke-width="2"/>
            <rect x="150" y="76" width="34" height="30" fill="#e9e1d6"/><rect x="155" y="81" width="24" height="25" fill="url(#a-glas)"/>
            <rect x="316" y="76" width="34" height="30" fill="#e9e1d6"/><rect x="321" y="81" width="24" height="25" fill="url(#a-licht)"/>
            <rect x="240" y="46" width="16" height="26" fill="#8b6f5f"/>
            <!-- Fassade -->
            <rect x="68" y="112" width="364" height="488" fill="#ebe1d1"/>
            <rect x="196" y="112" width="108" height="488" fill="#e4d8c5"/>
            <rect x="60" y="106" width="380" height="12" fill="#d7c9b3"/><rect x="64" y="118" width="372" height="5" fill="#cdbea7"/>
            <g stroke="#d9ccb8" stroke-width="2"><path d="M68 218 H432"/><path d="M68 322 H432"/><path d="M68 426 H432"/></g>
            <rect x="64" y="482" width="372" height="8" fill="#d7c9b3"/>
            <!-- Fenster: vier Obergeschosse, fünf Achsen -->
            <g>
              <use href="#a-fenster" x="92" y="140"/><use href="#a-fenster" x="152" y="140"/><use href="#a-fenster-l" x="232" y="140"/><use href="#a-fenster" x="312" y="140"/><use href="#a-fenster" x="372" y="140"/>
              <use href="#a-fenster" x="92" y="244"/><use href="#a-fenster-l" x="152" y="244"/><use href="#a-fenster" x="232" y="244"/><use href="#a-fenster" x="312" y="244"/><use href="#a-fenster" x="372" y="244"/>
              <use href="#a-fenster" x="92" y="348"/><use href="#a-fenster" x="152" y="348"/><use href="#a-fenster" x="232" y="348"/><use href="#a-fenster-l" x="312" y="348"/><use href="#a-fenster" x="372" y="348"/>
              <use href="#a-giebel" x="92" y="348"/><use href="#a-giebel" x="152" y="348"/><use href="#a-giebel" x="232" y="348"/><use href="#a-giebel" x="312" y="348"/><use href="#a-giebel" x="372" y="348"/>
            </g>
            <!-- Balkon im 2. OG -->
            <rect x="206" y="310" width="88" height="6" fill="#cdbea7"/>
            <g stroke="#3a3f43" stroke-width="1.4"><path d="M208 288 H292 M208 288 V310 M292 288 V310"/><path d="M216 288v22M224 288v22M232 288v22M240 288v22M248 288v22M256 288v22M264 288v22M272 288v22M280 288v22"/></g>
            <!-- Erdgeschoss mit Rustika und Portal -->
            <rect x="68" y="490" width="364" height="110" fill="#e2d6c3"/>
            <g stroke="#d2c4ae" stroke-width="1.5"><path d="M68 512 H432 M68 534 H432 M68 556 H432 M68 578 H432"/></g>
            <path d="M226 600 V540 a24 24 0 0 1 48 0 V600 Z" fill="#2a3640"/>
            <path d="M226 540 a24 24 0 0 1 48 0" fill="none" stroke="#f6f0e6" stroke-width="3"/>
            <rect x="96" y="514" width="40" height="56" fill="url(#a-glas)"/><rect x="156" y="514" width="40" height="56" fill="url(#a-glas)"/>
            <rect x="304" y="514" width="40" height="56" fill="url(#a-licht)"/><rect x="364" y="514" width="40" height="56" fill="url(#a-glas)"/>
            <rect x="68" y="112" width="364" height="488" fill="url(#a-schatten)"/>
            <!-- Straßenbaum -->
            <g fill="#8e977f" opacity=".92"><circle cx="24" cy="430" r="58"/><circle cx="70" cy="470" r="44"/><circle cx="-6" cy="490" r="50"/></g>
            <rect x="22" y="480" width="6" height="120" fill="#6d6458"/>
            <rect y="588" width="500" height="12" fill="#cfc4b4"/>
          </svg>
        </div>
        <div class="szene" data-szene="haus" role="img" aria-label="Zeichnung: Einfamilienhaus mit Walmdach und Garten unter Kiefern">
          <svg viewBox="0 0 500 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
            <defs>
              <linearGradient id="h-himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c9d3d6"/><stop offset=".6" stop-color="#ebe3d6"/><stop offset="1" stop-color="#f3dcc0"/></linearGradient>
              <linearGradient id="h-rasen" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b9b896"/><stop offset="1" stop-color="#9da07f"/></linearGradient>
              <linearGradient id="h-glas" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#3d4c56"/><stop offset="1" stop-color="#24323b"/></linearGradient>
              <linearGradient id="h-licht" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f1c582"/><stop offset="1" stop-color="#d69650"/></linearGradient>
            </defs>
            <rect width="500" height="600" fill="url(#h-himmel)"/>
            <ellipse cx="250" cy="470" rx="320" ry="90" fill="#f6e2c6" opacity=".55"/>
            <!-- Baumreihe am Horizont -->
            <path d="M0 470 C30 440 60 452 90 438 C120 424 150 446 180 436 C220 424 260 440 300 430 C340 420 380 444 420 432 C450 424 480 440 500 434 V480 H0 Z" fill="#8f987f" opacity=".75"/>
            <!-- Kiefern -->
            <g>
              <path d="M58 470 L62 170 L66 470 Z" fill="#6a5f53"/>
              <g fill="#6b7664"><ellipse cx="62" cy="168" rx="56" ry="24"/><ellipse cx="36" cy="200" rx="40" ry="16"/><ellipse cx="92" cy="222" rx="36" ry="14"/><ellipse cx="58" cy="252" rx="28" ry="11"/></g>
              <g fill="#7c8774"><ellipse cx="74" cy="160" rx="34" ry="13"/><ellipse cx="44" cy="194" rx="22" ry="8"/></g>
              <path d="M444 470 L448 140 L452 470 Z" fill="#6a5f53"/>
              <g fill="#6b7664"><ellipse cx="448" cy="138" rx="62" ry="26"/><ellipse cx="478" cy="182" rx="40" ry="16"/><ellipse cx="414" cy="204" rx="38" ry="15"/><ellipse cx="456" cy="240" rx="30" ry="11"/></g>
              <g fill="#7c8774"><ellipse cx="434" cy="130" rx="36" ry="13"/><ellipse cx="482" cy="176" rx="22" ry="8"/></g>
            </g>
            <!-- Garten -->
            <rect y="470" width="500" height="130" fill="url(#h-rasen)"/>
            <ellipse cx="250" cy="482" rx="190" ry="10" fill="#5f6650" opacity=".25"/>
            <!-- Haus, Walmdach, 1930er -->
            <path d="M78 300 L150 214 H350 L422 300 Z" fill="#7b5b4d"/>
            <path d="M150 214 H350" stroke="#69493c" stroke-width="2"/>
            <g stroke="#6d4e41" stroke-width="1" opacity=".5"><path d="M100 274 H400 M122 248 H378 M140 226 H360"/></g>
            <rect x="300" y="190" width="20" height="40" fill="#8a7468"/><rect x="296" y="186" width="28" height="6" fill="#77625a"/>
            <rect x="214" y="250" width="72" height="44" fill="#7b5b4d"/><path d="M206 254 L250 230 L294 254 Z" fill="#6f5043"/><rect x="226" y="258" width="48" height="30" fill="#f7f1e7"/><rect x="230" y="262" width="40" height="26" fill="url(#h-glas)"/>
            <rect x="94" y="300" width="312" height="180" fill="#eee5d6"/>
            <rect x="90" y="296" width="320" height="8" fill="#ddd0bc"/>
            <rect x="94" y="300" width="60" height="180" fill="#000" opacity=".04"/>
            <g>
              <rect x="118" y="322" width="46" height="52" fill="#f7f1e7"/><rect x="122" y="326" width="38" height="44" fill="url(#h-glas)"/><path d="M141 326v44" stroke="#f7f1e7" stroke-width="2"/>
              <rect x="190" y="322" width="46" height="52" fill="#f7f1e7"/><rect x="194" y="326" width="38" height="44" fill="url(#h-licht)"/><path d="M213 326v44" stroke="#f7f1e7" stroke-width="2"/>
              <rect x="264" y="322" width="46" height="52" fill="#f7f1e7"/><rect x="268" y="326" width="38" height="44" fill="url(#h-glas)"/><path d="M287 326v44" stroke="#f7f1e7" stroke-width="2"/>
              <rect x="336" y="322" width="46" height="52" fill="#f7f1e7"/><rect x="340" y="326" width="38" height="44" fill="url(#h-glas)"/><path d="M359 326v44" stroke="#f7f1e7" stroke-width="2"/>
              <rect x="114" y="398" width="124" height="82" fill="#f7f1e7"/><rect x="118" y="402" width="116" height="78" fill="url(#h-licht)"/><path d="M147 402v78M176 402v78M205 402v78" stroke="#f7f1e7" stroke-width="3"/>
              <rect x="294" y="394" width="44" height="86" fill="#3a4148"/><circle cx="330" cy="440" r="2" fill="#d9c7a8"/>
              <path d="M284 388 H348 L344 380 H288 Z" fill="#cbbca5"/>
              <rect x="358" y="402" width="30" height="40" fill="#f7f1e7"/><rect x="361" y="405" width="24" height="34" fill="url(#h-glas)"/>
            </g>
            <rect x="90" y="476" width="320" height="6" fill="#d4c6b0"/>
            <path d="M298 482 L262 600 H352 L334 482 Z" fill="#d8ccb9"/>
            <g fill="#7f8a6f"><rect x="-10" y="462" width="104" height="26" rx="12"/><rect x="408" y="460" width="104" height="28" rx="13"/></g>
            <g fill="#a3a985" opacity=".9"><ellipse cx="80" cy="560" rx="44" ry="22"/><ellipse cx="452" cy="572" rx="56" ry="26"/></g>
          </svg>
        </div>
      </div>
      <figcaption class="bildtext" data-bildtext>
        <div><strong data-objekt>Exposé SK-0417 · Paulusviertel</strong><span class="ziffern" data-eckdaten>Altbau, Bj. 1904 · 3 Zi. · 96 m²</span></div>
        <span class="preis ziffern" data-preis>329.000 €</span>
      </figcaption>
    </figure>
  </main>

  <div class="demo mono">
    <div class="modi" role="group" aria-label="Takt">
      <button type="button" data-modus="natuerlich" aria-pressed="true">Natürlich</button>
      <button type="button" data-modus="gleichmaessig" aria-pressed="false">Gleichmäßig</button>
      <button type="button" data-modus="streaming" aria-pressed="false">Streaming</button>
    </div>
    <div class="spur" data-spur aria-hidden="true" title="Pause vor jedem Zeichen"></div>
    <p class="werte" data-werte></p>
  </div>
</div>

<section class="eigentuemer" id="eigentuemer">
  <div>
    <p class="label">Für Eigentümer</p>
    <h2>Eine Einschätzung von Menschen, die Ihre Straße kennen.</h2>
    <p>Kein Rechner, kein Formular ohne Antwort. Sie schicken uns Grundriss und Eckdaten, eine Kollegin aus Ihrem Viertel meldet sich persönlich.</p>
    <ul class="fakten ziffern">
      <li><b>1998</b><span>gegründet in Halle</span></li>
      <li><b>412</b><span>vermittelte Objekte</span></li>
      <li><b>48 h</b><span>bis zur Ersteinschätzung</span></li>
    </ul>
  </div>
  <article class="nachricht" aria-label="Beispiel einer Antwort">
    <header>
      <span class="kuerzel" aria-hidden="true">KV</span>
      <div><strong>Katrin Voss</strong><span>Bewertung, Paulusviertel</span></div>
      <span class="status" data-status>gelesen</span>
    </header>
    <p class="brief" data-brief>
      <span class="vh" data-brief-voll></span>
      <span class="geist" aria-hidden="true" data-brief-geist></span>
      <span aria-hidden="true"><span data-brief-text></span><span class="cursor" data-brief-cursor></span></span>
    </p>
    <footer><span>Antwort per Mail, Beispiel</span><a href="#">Termin vereinbaren</a></footer>
  </article>
</section>
<footer class="recht">
  <span>Saalkontor Immobilien GmbH · Halle (Saale) · Erlaubnis nach § 34c GewO</span>
  <span>Käuferprovision 3,57 % inkl. MwSt. · Energieausweis-Daten im Exposé</span>
</footer>

<script>
(() => {
  const H = document.documentElement;
  const reduziert = () => H.classList.contains('reduziert');
  const $ = s => document.querySelector(s);

  /* ---- Werte (Motion+ Typewriter, live bei clerk.com ausgeliefert) ---- */
  const TAKT = 75;                 // ms je Zeichen, speed 'normal' (slow 130, fast 30)
  const VARIANZ = 0.5;             // ±50 % Zufall je Zeichen ('natural')
  const WORTPAUSE = 60;            // ms zusätzlich vor einem neuen Wort
  const SATZPAUSE = 225;           // ms zusätzlich nach Satzzeichen (3 × Takt)
  const LANGWORT = 1.2;            // Faktor in Wörtern über 8 Zeichen
  const LOESCHEN = 0.2;            // Backspace = 0,2 × Takt = 15 ms je Zeichen
  const HALTEN = 2200;             // ms Lesezeit, bevor gelöscht wird
  const SATZZEICHEN = /[.,!?:;'"\-()]/;
  const STROM = { min: 90, max: 160, fade: 400 };   // Streaming: Brocken 1–3 Wörter

  const LISTEN = {
    kaufen: [
      { text: 'Altbauwohnung im Paulusviertel, 3 Zimmer, gern mit Balkon', szene: 'altbau', viertel: 'Paulusviertel', zahl: 14, objekt: 'Exposé SK-0417 · Paulusviertel', daten: 'Altbau, Bj. 1904 · 3 Zi. · 96 m²', preis: '329.000 €' },
      { text: 'Altbauwohnung am Giebichenstein, mit Blick auf die Saale', szene: 'altbau', viertel: 'Giebichenstein', zahl: 6, objekt: 'Exposé SK-0388 · Giebichenstein', daten: 'Altbau, Bj. 1911 · 4 Zi. · 124 m²', preis: '465.000 €' },
      { text: 'Einfamilienhaus in Kröllwitz, Garten nach Süden', szene: 'haus', viertel: 'Kröllwitz', zahl: 9, objekt: 'Exposé SK-0452 · Kröllwitz', daten: 'Einfamilienhaus, Bj. 1936 · 5 Zi. · 162 m²', preis: '589.000 €' },
      { text: 'Einfamilienhaus in Dölau, nah an der Heide, bis 650.000 €', szene: 'haus', viertel: 'Dölau', zahl: 4, objekt: 'Exposé SK-0461 · Dölau', daten: 'Einfamilienhaus, Bj. 1938 · 6 Zi. · 178 m²', preis: '635.000 €' },
    ],
    mieten: [
      { text: '2-Zimmer-Wohnung in der Altstadt, nah am Marktplatz', szene: 'altbau', viertel: 'Altstadt', zahl: 11, objekt: 'Exposé SK-M112 · Altstadt', daten: 'Altbau, 2. OG · 2 Zi. · 64 m²', preis: '780 € kalt' },
      { text: '2-Zimmer-Wohnung in Heide-Süd, mit Stellplatz', szene: 'haus', viertel: 'Heide-Süd', zahl: 7, objekt: 'Exposé SK-M131 · Heide-Süd', daten: 'Stadtvilla, saniert 2018 · 2 Zi. · 58 m²', preis: '690 € kalt' },
      { text: 'Dachgeschoss im Paulusviertel, ab Juni, bis 1.100 € warm', szene: 'altbau', viertel: 'Paulusviertel', zahl: 5, objekt: 'Exposé SK-M147 · Paulusviertel', daten: 'Altbau, DG · 3 Zi. · 88 m²', preis: '940 € kalt' },
    ],
  };
  const BRIEF = 'Danke für Ihre Unterlagen, Herr Lindner. Ich melde mich bis Donnerstag mit einer ersten Preisspanne, gern auch telefonisch.';

  /* ---- Uhr: Wartezeiten, die sich anhalten und verwerfen lassen ---- */
  class Uhr {
    constructor() { this.jobs = new Set(); this.gruende = new Set(); }
    get steht() { return this.gruende.size > 0; }
    warte(ms) { return new Promise(res => { const j = { res, rest: ms }; this.jobs.add(j); if (!this.steht) this.los(j); }); }
    los(j) { j.t0 = performance.now(); j.id = setTimeout(() => { this.jobs.delete(j); j.res(); }, j.rest); }
    halt(grund) { const war = this.steht; this.gruende.add(grund); if (war) return; const n = performance.now(); this.jobs.forEach(j => { clearTimeout(j.id); j.rest = Math.max(0, j.rest - (n - j.t0)); }); }
    frei(grund) { if (!this.gruende.delete(grund) || this.steht) return; this.jobs.forEach(j => this.los(j)); }
    leeren() { this.jobs.forEach(j => clearTimeout(j.id)); this.jobs.clear(); }   // offene Abläufe laufen ins Leere
  }

  /* ---- Schreiber: tippt, löscht bis zur ersten Abweichung, streamt ---- */
  class Schreiber {
    constructor(textEl, cursorEl, takt, melde) { Object.assign(this, { textEl, cursorEl, takt, melde }); this.ist = ''; this.uhr = new Uhr(); }
    abbrechen() { this.uhr.leeren(); this.cursorEl.classList.remove('tippt'); }
    setze(t) { this.ist = t; this.textEl.classList.remove('weg'); this.textEl.textContent = t; }
    pauseVor(ziel, i, modus) {
      if (modus === 'gleichmaessig') return this.takt;
      const vor = ziel[i - 1] || '';
      const wort = (ziel.slice(0, i + 1).match(/\S*$/) || [''])[0] + (ziel.slice(i + 1).match(/^\S*/) || [''])[0];
      let d = this.takt * (1 + (Math.random() * 2 - 1) * VARIANZ);
      if (wort.length > 8) d *= LANGWORT;
      if (vor === ' ') d += WORTPAUSE;
      if (SATZZEICHEN.test(vor)) d += SATZPAUSE;
      return Math.round(d);
    }
    async schreibe(ziel, modus) {
      if (modus === 'streaming') return this.stroeme(ziel);
      if (this.textEl.querySelector('.brocken')) this.setze(this.ist);
      this.cursorEl.classList.add('tippt');
      // Natürlich: nur bis zur ersten Abweichung löschen. Gleichmäßig: alles löschen.
      let p = 0;
      if (modus === 'natuerlich') while (p < this.ist.length && this.ist[p] === ziel[p]) p++;
      while (this.ist.length > p) { await this.uhr.warte(this.takt * LOESCHEN); this.setze(this.ist.slice(0, -1)); }
      for (let i = p; i < ziel.length; i++) {
        const d = this.pauseVor(ziel, i, modus);
        await this.uhr.warte(d);
        this.setze(this.ist + ziel[i]);
        this.melde && this.melde(d);
      }
      this.cursorEl.classList.remove('tippt');
    }
    async stroeme(ziel) {
      if (this.ist) { this.textEl.classList.add('weg'); await this.uhr.warte(220); }
      this.ist = ''; this.textEl.textContent = ''; this.textEl.classList.remove('weg');
      const woerter = ziel.match(/\S+\s*/g) || [];
      for (let i = 0; i < woerter.length;) {
        const n = 1 + Math.floor(Math.random() * 3);
        const stueck = woerter.slice(i, i + n).join(''); i += n;
        const d = Math.round(STROM.min + Math.random() * (STROM.max - STROM.min));
        await this.uhr.warte(d);
        const s = document.createElement('span'); s.className = 'brocken'; s.textContent = stueck;
        this.textEl.append(s); this.ist += stueck;
        this.melde && this.melde(d);
      }
    }
  }

  /* ---- Taktspur: Pause vor jedem Zeichen als Balken ---- */
  const spur = $('[data-spur]');
  const balken = Array.from({ length: 44 }, () => spur.appendChild(document.createElement('i')));
  const pausen = [];
  function melde(d) {
    pausen.push(d); if (pausen.length > balken.length) pausen.shift();
    const off = balken.length - pausen.length;
    balken.forEach((b, i) => { const v = pausen[i - off]; b.style.height = v ? Math.min(100, 6 + v / 3.6) + '%' : '4%'; });
  }
  function spurLeeren() { pausen.length = 0; balken.forEach(b => (b.style.height = '4%')); }

  /* ---- Elemente ---- */
  const feld = $('#suchtext'), geister = $('[data-geister]'), treffer = $('[data-treffer]');
  const held = new Schreiber($('[data-tipptext]'), $('[data-cursor]'), TAKT, melde);
  const werte = $('[data-werte]');
  let art = 'kaufen', modus = 'natuerlich', index = 0, lauf = 0, vorfuehrung = false, aktuell = null;

  function zeigeWerte() {
    const W = {
      natuerlich: `<span>Takt <b>75 ms</b></span><span>Varianz <b>±50 %</b></span><span>Wortgrenze <b>+60 ms</b></span><span>Satzzeichen <b>+225 ms</b></span><span>Löschen <b>15 ms · bis Abweichung</b></span>`,
      gleichmaessig: `<span>Takt <b>75 ms</b></span><span>Kurve <b>linear</b></span><span>Varianz <b>0</b></span><span>Löschen <b>15 ms · alles</b></span>`,
      streaming: `<span>Brocken <b>1–3 Wörter</b></span><span>Abstand <b>90–160 ms</b></span><span>Fade <b>400 ms easeOutQuart</b></span>`,
    };
    werte.innerHTML = reduziert() ? '<span>Reduzierte Bewegung: <b>Endtext sofort, Cursor ruht</b></span>' : W[modus] + '<span>Cursor <b>500 ms</b></span>';
  }

  function geisterSetzen() {
    geister.innerHTML = '';
    LISTEN[art].forEach(q => { const s = document.createElement('span'); s.textContent = q.text + ' '; geister.append(s); });
  }

  // Objektbild mit langer Ausklang-Kurve aufdecken (Rezept: clip-path + scale 1.06 → 1)
  let szeneAktiv = 'altbau', bildUhr = null;
  function zeigeObjekt(q) {
    treffer.classList.remove('wartet');
    treffer.innerHTML = `<b>${q.zahl}</b> passende Angebote · ${q.viertel}`;
    const bt = $('[data-bildtext]');
    bt.classList.add('wechsel');
    clearTimeout(bildUhr);
    bildUhr = setTimeout(() => {
      $('[data-objekt]').textContent = q.objekt; $('[data-eckdaten]').textContent = q.daten; $('[data-preis]').textContent = q.preis;
      $('[data-viertel]').textContent = q.viertel;
      bt.classList.remove('wechsel');
    }, reduziert() ? 0 : 200);
    if (q.szene === szeneAktiv) return;
    document.querySelectorAll('.szene').forEach(s => { s.classList.remove('vorher', 'an', 'auf'); });
    $(`[data-szene="${szeneAktiv}"]`).classList.add('vorher');
    const neu = $(`[data-szene="${q.szene}"]`);
    void neu.offsetWidth;
    neu.classList.add('an', 'auf');
    szeneAktiv = q.szene;
  }

  async function schleife() {
    const nr = ++lauf;
    let zaehler = 0;
    while (nr === lauf) {
      const liste = LISTEN[art], q = liste[index % liste.length];
      aktuell = q;
      treffer.classList.add('wartet');
      if (reduziert()) {
        held.setze(q.text);
        zeigeObjekt(q);
        if (!vorfuehrung) return;          // ohne Vorführung: Endtext bleibt stehen
        await held.uhr.warte(3200);
      } else {
        await held.schreibe(q.text, modus);
        zeigeObjekt(q);
        await held.uhr.warte(HALTEN);
      }
      index = (index + 1) % liste.length;
      zaehler++;
      if (vorfuehrung && zaehler % 2 === 0 && !reduziert()) {
        const reihe = ['natuerlich', 'gleichmaessig', 'streaming'];
        setzeModus(reihe[(reihe.indexOf(modus) + 1) % reihe.length]);
      }
    }
  }

  function starte() { held.abbrechen(); schleife(); }

  function setzeModus(m) {
    modus = m;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
    zeigeWerte();
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]'); if (!b) return;
    vorfuehrung = false;
    setzeModus(b.dataset.modus); spurLeeren(); starte();
  });
  document.querySelector('.reiter').addEventListener('click', e => {
    const b = e.target.closest('[data-art]'); if (!b || b.dataset.art === art) return;
    art = b.dataset.art; index = 0;
    document.querySelectorAll('[data-art]').forEach(x => x.setAttribute('aria-selected', String(x === b)));
    geisterSetzen(); starte();
  });

  // Fokus: Vorschlag ruht, solange jemand selbst schreibt
  feld.addEventListener('focus', () => held.uhr.halt('fokus'));
  feld.addEventListener('blur', () => { if (!feld.value) held.uhr.frei('fokus'); });
  feld.addEventListener('input', () => { feld.style.height = 'auto'; feld.style.height = feld.scrollHeight + 'px'; });
  $('[data-suche]').addEventListener('submit', e => {
    e.preventDefault();
    if (!feld.value.trim() && aktuell) { feld.value = aktuell.text; feld.dispatchEvent(new Event('input')); }
    treffer.innerHTML = 'Demo: Die Suche ist hier nicht angebunden.';
  });

  // Nur tippen, wenn sichtbar (spart Arbeit, und niemand verpasst den Anfang)
  new IntersectionObserver(([e]) => e.isIntersecting ? held.uhr.frei('sicht') : held.uhr.halt('sicht'), { threshold: 0.2 }).observe($('.suche'));
  document.addEventListener('visibilitychange', () => document.hidden ? held.uhr.halt('tab') : held.uhr.frei('tab'));

  /* ---- Weiter unten: Antwort einer Maklerin, tippt bei Sichtbarkeit (speed 'fast') ---- */
  const brief = new Schreiber($('[data-brief-text]'), $('[data-brief-cursor]'), 30, null);
  const status = $('[data-status]');
  $('[data-brief-voll]').textContent = BRIEF;
  $('[data-brief-geist]').textContent = BRIEF + ' ';
  let briefFertig = false;
  async function briefSchreiben() {
    brief.abbrechen(); brief.setze(''); briefFertig = false;
    if (reduziert()) { brief.setze(BRIEF); status.textContent = 'gesendet 09:42'; briefFertig = true; return; }
    status.textContent = 'schreibt …'; status.classList.add('schreibt');
    await brief.uhr.warte(500);
    await brief.schreibe(BRIEF, 'natuerlich');
    status.textContent = 'gesendet 09:42'; status.classList.remove('schreibt'); briefFertig = true;
  }
  brief.uhr.halt('sicht');
  new IntersectionObserver(([e]) => e.isIntersecting ? brief.uhr.frei('sicht') : brief.uhr.halt('sicht'), { threshold: 0.6 }).observe($('.nachricht'));

  /* ---- Öffentliche Steuerung ---- */
  function neustart() {
    vorfuehrung = false;
    index = 0; held.setze(''); spurLeeren(); zeigeWerte();
    starte(); briefSchreiben();
  }
  function vorfuehren() {
    vorfuehrung = true;
    index = 0; held.abbrechen(); held.setze(''); spurLeeren();
    setzeModus('natuerlich');
    schleife();
  }
  function pause() { held.uhr.halt('api'); brief.uhr.halt('api'); document.querySelectorAll('.cursor').forEach(c => (c.style.animationPlayState = 'paused')); }
  function weiter() { held.uhr.frei('api'); brief.uhr.frei('api'); document.querySelectorAll('.cursor').forEach(c => (c.style.animationPlayState = '')); }

  geisterSetzen(); zeigeWerte();
  document.fonts.ready.then(() => { if (!vorfuehrung) { starte(); briefSchreiben(); } });

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