Zum Inhalt
← Zugang & Robustheit

Sofortige Seitenwechsel durch Vorladen

Eine Architekturseite mit echten Speculation Rules: prefetch und prerender nach 200 ms Hover, klare Ausschlüsse, Statistik erst bei prerenderingchange; ein Monitor zeigt Slots, Ausschlussgrund und Wechselzeit.

Jede Seite mit mehreren Dokumenten und klaren Hauptwegen (Projekte, Termin, Kontakt), eigene Ausschlüsse gepflegt.

Speculation Rules für Seiten mit mehreren Dokumenten. Ein <script type="speculationrules"> lädt interne Links per prefetch mit eagerness moderate vor, sobald der Zeiger kurz auf dem Link ruht. Ein bis drei Hauptwege (zum Beispiel Projekte und Kontakt) werden per prerender komplett im Hintergrund aufgebaut. Ausgeschlossen sind Warenkorb, Abmelden, Formular-Ergebnisse, Login-Bereiche, Links mit Parametern und alles, was eine Einwilligung auslöst. Zusammen mit Cross-Document View Transitions wirkt der Seitenwechsel sofort. Die Statistik zählt einen Aufruf erst, wenn die vorgerenderte Seite wirklich angezeigt wird.

Die Wartezeit zwischen Klick und neuer Seite ist der Moment, in dem eine Seite träge wirkt. Eine vorgerenderte Seite erscheint ohne Ladezeit, auch ohne Single-Page-Framework. Der Aufwand ist ein JSON-Block im Kopf. Browser ohne Unterstützung ignorieren ihn folgenlos, die Seite funktioniert dort wie bisher. WordPress lädt seit 6.8 standardmäßig vor, das Muster ist damit bei vielen KMU-Seiten schon halb da und wird hier bewusst eingestellt.

Wann einsetzen

Bei jeder Seite mit mehreren echten Seiten: Praxis, Kanzlei, Architektur, Handwerk, Immobilien, Kultur. Besonders bei klaren Hauptwegen (Termin, Projekte, Kontakt) und bei Seiten mit Cross-Document View Transitions.

Wann nicht

Nicht bei Onepagern oder Single-Page-Apps mit eigenem Router. Kein prerender für Seiten mit Nebenwirkungen beim Laden (Abmelden, Warenkorb ändern, Formular absenden, Zähler, Consent-Dialoge, Bezahlseiten) und nicht für sehr schwere Seiten. Kein immediate für alle Links: das kostet Datenvolumen und Server-Last. Nicht als Ersatz für eine schnelle Seite; LCP, INP und CLS bleiben die Prüfstufe.

Varianten

  • Empfehlung: prefetch moderate für alle internen Links, prerender moderate für 1 bis 3 Hauptwege, klare Ausschlüsse
  • WordPress-Standard seit 6.8: nur prefetch mit conservative (beim Drücken)
  • Mit Cross-Document View Transitions (@view-transition { navigation: auto })
  • Regeln per HTTP-Kopfzeile Speculation-Rules statt Inline-Skript (strenge CSP)

Umsetzung

Im Kopf jeder Seite ein <script type="speculationrules"> mit zwei Document Rules (siehe werte.regeln). prerender nur für 1 bis 3 Hauptwege, prefetch für alle übrigen internen Links, beide mit eagerness moderate. Ausschlüsse über not: Login-Bereich, Abmelden, Danke-Seiten nach Formularen, PDFs, Links mit Parametern (selector a[href*='?']), nofollow, Consent-Links (data-consent) und eine Klasse .kein-vorladen für Einzelfälle. Chrome hält bei moderate je 2 prefetch und 2 prerender und verwirft das älteste. Statistik, Autoplay und Chat-Widgets erst nach prerenderingchange starten (document.prerendering prüfen). Für den Übergang @view-transition { navigation: auto } in @media (prefers-reduced-motion: no-preference). Bei strenger CSP die Regeln als JSON-Datei über die Kopfzeile Speculation-Rules (application/speculationrules+json). Die Demo simuliert die Seitenwechsel in einer Datei; ihr echter Regelblock steht im Kopf, wird von der Simulation ausgewertet und greift auf der Demo-Seite selbst nicht, weil alle Demo-Links Parameter tragen. Prüfen in den DevTools unter Application → Speculative loads; LCP, INP und CLS messen.

Robust umsetzen

Telefon. Ohne Hover lädt moderate in Chrome mobil nach einer Viewport-Heuristik vor (500 ms nach Scroll-Ende, für große Links in der Nähe), sonst beim Antippen. Datensparmodus und Energiesparmodus schalten das Vorladen ab, ohne dass die Seite etwas tun muss.

Ohne Bewegung. Vorladen bleibt aktiv, denn es spart nur Zeit. Die View Transition steht in @media (prefers-reduced-motion: no-preference); bei reduzierter Bewegung wechselt die Seite hart, aber genauso schnell.

Barrierefrei. Schnelle Seitenwechsel helfen allen, besonders bei langsamen Geräten. 2.4.3 Fokusreihenfolge (A) und 2.4.1 Bereiche überspringen (A): nach dem Wechsel startet der Fokus wie bei jeder neuen Seite oben, Sprunglink und Seitentitel bleiben. Vorgerenderte Seiten dürfen beim Anzeigen nichts automatisch vorlesen oder abspielen, was beim Vorladen schon gestartet wäre (Autoplay erst nach prerenderingchange). 2.3.3 Animation durch Interaktion (AAA) und prefers-reduced-motion: Übergang abschaltbar. Links bleiben echte <a href>, ohne JS-Router. Die Chrome-Doku nennt für moderate Hover und pointerdown als Auslöser, Tastaturfokus nicht; wer per Tastatur navigiert, bekommt dann einen normal schnellen Wechsel, nie einen schlechteren.

Code

<!DOCTYPE html>
<!--
  Sofortige Seitenwechsel durch Vorladen
  Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/sofort-navigation-vorladen/
  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>Sofortige Seitenwechsel durch Vorladen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<!-- Speculation Rules, wie sie im Kopf jeder Seite stehen. Chromium lädt danach vor, Firefox und Safari ignorieren den Block.
     In dieser Demo zeigen alle Links auf ?seite=…; die Regel gegen Links mit Parametern verhindert, dass Chrome hier echt vorlädt.
     Die Simulation unten wertet genau diesen Block aus. -->
<script type="speculationrules" id="regeln">
{
  "prerender": [{
    "source": "document",
    "where": { "and": [
      { "href_matches": ["/projekte/", "/kontakt/"] },
      { "not": { "selector_matches": ["a[href*='?']", "[data-consent]", ".kein-vorladen"] } }
    ] },
    "eagerness": "moderate"
  }],
  "prefetch": [{
    "source": "document",
    "where": { "and": [
      { "href_matches": "/*" },
      { "not": { "href_matches": ["/projektraum/*", "/kontakt/danke/", "/*.pdf"] } },
      { "not": { "selector_matches": ["a[href*='?']", "[rel~='nofollow']", "[data-consent]", ".kein-vorladen"] } }
    ] },
    "eagerness": "moderate"
  }]
}
</script>
<style>
@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: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; 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; }

/* Echter Seitenwechsel zwischen Dokumenten: nur ohne Wunsch nach reduzierter Bewegung */
@media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; } }

:root {
  /* Rezept architektur-ruhiges-raster: eine Familie, Weiß, kühles Hellgrau, warmes Fast-Schwarz, ein Interaktionston */
  --grund: #ffffff; --flaeche: #e1e3e3; --flaeche-hell: #f1f2f2; --text: #231f20; --leise: #6b6b70; --linie: rgba(35, 31, 32, .14);
  --dunkel: #1f2323; --dunkel-text: #e9ebeb; --dunkel-leise: #a3aaaa; --dunkel-linie: rgba(233, 235, 235, .14);
  --blau: #0022ff; --blau-hell: #8fa0ff; --warn: #ffb648; --nein: #ff8f7a;
  --kurve: cubic-bezier(0.1, 0.6, 0.4, 1);        /* Default (Chipperfield, KAAN) */
  --kurve-auf: cubic-bezier(0.1, 0.9, 0.3, 1);    /* Öffnen, Einblenden */
  --ui: 250ms;
  --rand: clamp(16px, 2.4vw, 32px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--flaeche-hell); color: var(--text); font-family: 'Hanken Grotesk', system-ui, sans-serif; font-weight: 450; -webkit-font-smoothing: antialiased; letter-spacing: -0.012em; }
body { min-height: 100svh; font-size: 1.0625rem; line-height: 1.45; }
button { font: inherit; color: inherit; letter-spacing: inherit; }
a { color: inherit; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.label { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
:focus-visible { outline: 2px solid var(--blau); outline-offset: 3px; }

/* ---------- Kopf der Demo ---------- */
.kopf { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 10px 32px; padding: 20px var(--rand) 14px; }
.kopf h1 { font-size: clamp(1.5rem, 1.1rem + 1.5vw, 2.25rem); font-weight: 450; letter-spacing: -0.02em; line-height: 1.1; margin-top: 6px; }
.kopf h1 i { color: var(--blau); }
.kopf p:not(.label) { max-width: 60ch; color: var(--leise); font-size: .9375rem; margin-top: 6px; }

/* ---------- Bühne ---------- */
.buehne { display: grid; gap: 14px; padding: 0 var(--rand) 22px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) { .buehne { grid-template-columns: minmax(0, 1.42fr) minmax(330px, 1fr); height: max(520px, calc(100svh - 150px)); } }

/* Browser-Rahmen */
.browser { position: relative; min-width: 0; display: flex; flex-direction: column; background: var(--grund); border: 1px solid var(--linie); border-radius: 12px; overflow: hidden; }
.browser__leiste { display: flex; align-items: center; gap: 12px; padding: 9px 14px; background: var(--flaeche); border-bottom: 1px solid var(--linie); font: 500 12px/1.2 'Geist Mono', monospace; color: #4a4a4f; }
.browser__punkte { display: flex; gap: 5px; }
.browser__punkte i { width: 9px; height: 9px; border-radius: 50%; background: #c3c6c6; }
.browser__url { flex: 1; min-width: 0; padding: 5px 10px; border-radius: 6px; background: var(--grund); overflow-wrap: anywhere; }
.browser__url b { font-weight: 500; color: var(--text); }
.ladebalken { position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--blau); transform-origin: left; transform: scaleX(0); opacity: 0; }
.ladebalken.an { opacity: 1; }
.fenster { position: relative; flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; }
@media (max-width: 899px) { .fenster { max-height: 72svh; } }

/* ---------- Die simulierte Architekturseite ---------- */
.site-kopf { position: sticky; top: 0; z-index: 3; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 24px; padding: 16px clamp(16px, 3vw, 36px); background: rgba(255, 255, 255, .94); }
.wortmarke { text-decoration: none; font-weight: 560; font-size: 1.0625rem; letter-spacing: -0.02em; }
.wortmarke span { font-weight: 450; color: var(--leise); }
.site-nav ul { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 22px; font-size: .9375rem; }
.site-nav a, .site a.weg { position: relative; text-decoration: none; padding: 2px 0; }
.site-nav a[aria-current="page"] { text-decoration: underline; text-underline-offset: 6px; text-decoration-thickness: 1px; }
/* Hover-Absicht: Linie wächst in 200 ms (Schwelle von moderate) */
a[data-pfad][data-zustand]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1.5px; background: var(--blau); transform-origin: left; }
a[data-pfad][data-zustand="wartet"]::after { animation: absicht 200ms linear both; }
@keyframes absicht { from { transform: scaleX(0); } to { transform: scaleX(1); } }
a[data-pfad][data-zustand="prefetch"]::after { background: repeating-linear-gradient(90deg, var(--blau) 0 4px, transparent 4px 7px); }
a[data-pfad][data-zustand="aus"]::after { background: repeating-linear-gradient(90deg, #b3261e 0 2px, transparent 2px 5px); }
.site { padding: 0 clamp(16px, 3vw, 36px) 40px; }
.seite-inhalt { view-transition-name: seite; display: grid; gap: 28px; padding-top: clamp(20px, 4vw, 48px); }
.site h2 { font-size: clamp(1.75rem, 1.2rem + 2.2vw, 3.05rem); font-weight: 450; letter-spacing: -0.025em; line-height: 1.05; max-width: 18ch; }
.site h2:focus { outline: none; }
.site h2:focus-visible { outline: 2px solid var(--blau); outline-offset: 6px; }
.site h2 i { font-style: italic; }
.site p { max-width: 50ch; }
.meta { font-size: .8125rem; color: var(--leise); }
.liste { list-style: none; padding: 0; border-top: 1px solid var(--linie); }
.liste li { border-bottom: 1px solid var(--linie); }
.liste a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 20px; align-items: baseline; padding: 13px 0; text-decoration: none; }
.liste a:hover .p-titel { font-style: italic; }
.p-titel { font-size: 1.125rem; }
.p-meta { font-size: .8125rem; color: var(--leise); font-variant-numeric: tabular-nums; }
.filter { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: .875rem; }
.filter a { text-decoration: none; color: var(--leise); position: relative; }
.filter a[aria-current] { color: var(--text); text-decoration: underline; text-underline-offset: 5px; }
.bild { aspect-ratio: 16 / 9; background: var(--flaeche); border-radius: 2px; display: grid; place-items: center; overflow: hidden; }
.bild svg { width: 78%; height: auto; }
.daten { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 20px; font-size: .9375rem; }
.daten dt { color: var(--leise); }
.zweispaltig { display: grid; gap: 22px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }
.site-fuss { display: flex; flex-wrap: wrap; gap: 6px 22px; padding: 22px clamp(16px, 3vw, 36px) 26px; border-top: 1px solid var(--linie); font-size: .8125rem; color: var(--leise); }
.site-fuss a { position: relative; text-decoration: none; }
.knopf { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 18px; border: 1px solid var(--text); border-radius: 0; background: var(--text); color: #fff; text-decoration: none; cursor: pointer; font-size: .9375rem; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.knopf:hover { background: var(--grund); color: var(--text); }
.feld { display: grid; gap: 6px; max-width: 40ch; font-size: .9375rem; }
.feld textarea { font: inherit; padding: 10px; border: 1px solid #8a8a8f; border-radius: 0; min-height: 5.5em; resize: vertical; }
.zeiger { position: absolute; z-index: 9; left: 0; top: 0; width: 20px; height: 20px; pointer-events: none; opacity: 0; transition: transform 700ms var(--kurve), opacity 200ms; }
.vorfuehrung .zeiger { opacity: 1; }
.zeiger.drueckt svg { transform: scale(.86); }

/* ---------- Monitor ---------- */
.monitor { min-width: 0; display: flex; flex-direction: column; gap: 0; background: var(--dunkel); color: var(--dunkel-text); border-radius: 12px; overflow: auto; scrollbar-width: thin; }
.monitor .label { color: var(--dunkel-leise); }
.m-block { padding: 14px 16px; border-bottom: 1px solid var(--dunkel-linie); }
.m-block:last-child { border-bottom: 0; }
.m-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.m-kopf h2 { font-size: 1rem; font-weight: 560; letter-spacing: -0.01em; }
.m-kopf .label { white-space: nowrap; }
.wahl { display: flex; gap: 3px; padding: 3px; margin-top: 10px; border: 1px solid var(--dunkel-linie); border-radius: 8px; }
.wahl button { flex: 1; min-height: 34px; padding: 4px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--dunkel-text); font-size: .8125rem; cursor: pointer; transition: background-color var(--ui) var(--kurve); }
.wahl button:hover { background: rgba(233, 235, 235, .08); }
.wahl button[aria-pressed="true"] { background: var(--dunkel-text); color: var(--dunkel); font-weight: 560; }
.wahl-info { margin-top: 8px; font-size: .8125rem; color: var(--dunkel-leise); }
.monitor :focus-visible { outline-color: var(--blau-hell); }

.slots { display: grid; gap: 8px; margin-top: 10px; }
.slot-reihe { display: grid; grid-template-columns: 6.6em minmax(0, 1fr) minmax(0, 1fr); gap: 6px; align-items: stretch; }
.slot-reihe > span { font: 500 10.5px/1.2 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--dunkel-leise); align-self: center; }
.slot { position: relative; min-height: 46px; display: flex; flex-direction: column; justify-content: center; padding: 6px 8px; border-radius: 6px; border: 1px dashed var(--dunkel-linie); font: 500 11.5px/1.35 'Geist Mono', monospace; overflow: hidden; }
.slot b { display: block; font-weight: 500; overflow-wrap: anywhere; }
.slot small { display: block; font-size: 10.5px; color: var(--dunkel-leise); }
.slot:not([data-z]) small { color: #8d9494; }
.slot[data-z] { border-style: solid; border-color: rgba(143, 160, 255, .45); background: rgba(0, 34, 255, .14); }
.slot[data-z="bereit"] { border-color: var(--blau-hell); }
.slot[data-z="bereit"] small { color: #c9d1ff; }
.slot i { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--blau-hell); width: 100%; transform-origin: left; }
.slot-reihe--aus { display: none; }
[data-regel="wordpress"] .slot-reihe--prerender { opacity: .4; }

.pruefung { margin-top: 10px; padding: 9px 10px; border-radius: 6px; background: rgba(233, 235, 235, .06); font: 500 11.5px/1.45 'Geist Mono', monospace; color: var(--dunkel-text); min-height: 3.2em; }
.pruefung em { font-style: normal; color: var(--blau-hell); }
.pruefung strong { font-weight: 500; color: var(--nein); }
.pruefung .warn { color: var(--warn); }

.wechsel { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 14px; align-items: baseline; margin-top: 8px; }
.wechsel__zahl { font-size: clamp(2rem, 1.4rem + 1.6vw, 2.6rem); font-weight: 450; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.wechsel__text { font-size: .875rem; color: var(--dunkel-leise); }
.balken { display: grid; gap: 5px; margin-top: 12px; font: 500 10.5px/1.2 'Geist Mono', monospace; color: var(--dunkel-leise); }
.balken div { display: grid; grid-template-columns: 7.2em minmax(0, 1fr) 4.4em; gap: 8px; align-items: center; }
.balken s { text-decoration: none; height: 6px; border-radius: 3px; background: rgba(233, 235, 235, .22); display: block; }
.balken div[data-an] { color: var(--dunkel-text); }
.balken div[data-an] s { background: var(--blau-hell); }
.balken span:last-child { text-align: right; font-variant-numeric: tabular-nums; }

.statistik { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.statistik div { padding: 9px 10px; border-radius: 6px; background: rgba(233, 235, 235, .06); }
.statistik b { display: block; font-size: 1.5rem; font-weight: 450; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.statistik small { font-size: .78rem; color: var(--dunkel-leise); }
.statistik div:last-child b { color: var(--warn); }
.log { list-style: none; padding: 0; margin-top: 8px; display: grid; gap: 3px; font: 450 11px/1.4 'Geist Mono', monospace; color: var(--dunkel-leise); }
.log li:first-child { color: var(--dunkel-text); }

/* ---------- Code unter der Bühne ---------- */
.code-teil { padding: 8px var(--rand) 48px; display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) { .code-teil { grid-template-columns: minmax(0, 1.42fr) minmax(330px, 1fr); } }
.karte { background: var(--grund); border: 1px solid var(--linie); border-radius: 12px; padding: 18px; min-width: 0; }
.karte h2 { font-size: 1.125rem; font-weight: 560; letter-spacing: -0.01em; margin: 6px 0 10px; }
.karte pre { font: 450 12px/1.6 'Geist Mono', monospace; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--flaeche-hell); padding: 12px 14px; border-radius: 8px; }
.karte pre + h2 { margin-top: 18px; }
.karte ul { padding-left: 1.1em; display: grid; gap: 6px; font-size: .9375rem; }
.karte p:not(.label) { font-size: .9375rem; max-width: 62ch; }
.karte code { font-family: 'Geist Mono', monospace; font-size: .875em; }

/* Vorschau 960 × 720 */
.vorschau body { overflow: hidden; }
.vorschau .kopf p:not(.label), .vorschau .code-teil, .vorschau .m-block--log, .vorschau .wahl-info { display: none; }
.vorschau .kopf { padding-top: 14px; padding-bottom: 10px; }
.vorschau .buehne { height: calc(100svh - 92px); grid-template-columns: minmax(0, 1.3fr) minmax(330px, 1fr); }

:root.reduziert *, :root.reduziert *::after { transition-duration: .01ms !important; animation: none !important; }
:root.reduziert .zeiger { transition: none !important; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
::view-transition-old(seite), ::view-transition-new(seite) { animation-duration: 250ms; animation-timing-function: var(--kurve); }
@media (hover: none) { .liste a:hover .p-titel { font-style: normal; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], .wahl button { border: 1px solid ButtonText; }
  .wahl button[aria-pressed="true"] { outline: 2px solid Highlight; }
  .slot[data-z] { border-color: Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
  <div>
    <p class="label">Zugang &amp; Robustheit · Speculation Rules</p>
    <h1>Seitenwechsel ohne Wartezeit, <i>vorgeladen</i></h1>
    <p>Ruht der Zeiger 200 ms auf einem Link, lädt Chrome die Seite vor. Projekte und Kontakt werden ganz vorgerendert. Abmelden, Projektraum, Danke-Seite, PDF und Links mit Parametern bleiben ausgenommen. Auf dem Telefon zählt das Antippen und eine Pause nach dem Scrollen. Der Monitor zeigt, was die Regeln gerade tun (simuliert).</p>
  </div>
</header>

<main class="buehne" id="buehne" data-regel="empfehlung">
  <section class="browser" aria-label="Simulierte Website von Lenhard Saur Architekten">
    <div class="browser__leiste" aria-hidden="true">
      <span class="browser__punkte"><i></i><i></i><i></i></span>
      <span class="browser__url">lenhard-saur.example<b id="url-pfad">/</b></span>
    </div>
    <div class="ladebalken" id="ladebalken"></div>
    <div class="fenster" id="fenster">
      <header class="site-kopf">
        <a class="wortmarke" href="?seite=start" data-pfad="/">Lenhard Saur <span>Architekten</span></a>
        <nav class="site-nav" aria-label="Hauptnavigation der simulierten Seite">
          <ul>
            <li><a href="?seite=projekte" data-pfad="/projekte/">Projekte</a></li>
            <li><a href="?seite=buero" data-pfad="/buero/">Büro</a></li>
            <li><a href="?seite=kontakt" data-pfad="/kontakt/">Kontakt</a></li>
            <li><a href="?seite=projektraum" data-pfad="/projektraum/">Projektraum</a></li>
          </ul>
        </nav>
      </header>
      <div class="site">
        <div class="seite-inhalt" id="inhalt"></div>
      </div>
      <footer class="site-fuss">
        <span>Hafenstraße 21, 41460 Neuss</span>
        <a href="?seite=presse" data-pfad="/presse/pressemappe-2026.pdf">Pressemappe (PDF, 4 MB)</a>
        <a href="?seite=datenschutz" data-pfad="/datenschutz/" data-consent>Cookie-Einstellungen</a>
        <a href="?seite=impressum" data-pfad="/impressum/">Impressum</a>
      </footer>
      <svg class="zeiger" id="zeiger" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2v14l4-3.6 2.6 5.6 2.4-1.1-2.6-5.5H15Z" fill="#231f20" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/></svg>
    </div>
  </section>

  <aside class="monitor" aria-label="Vorlade-Monitor">
    <div class="m-block">
      <div class="m-kopf"><h2>Vorlade-Monitor</h2><span class="label">simuliert</span></div>
      <div class="wahl" role="group" aria-label="Regelsatz">
        <button type="button" data-v="empfehlung" aria-pressed="true">Empfehlung</button>
        <button type="button" data-v="wordpress" aria-pressed="false">WordPress-Standard</button>
      </div>
      <p class="wahl-info" id="wahl-info"></p>
      <div class="slots" aria-label="Laufende Vorlade-Vorgänge">
        <div class="slot-reihe slot-reihe--prerender"><span>prerender</span><div class="slot" id="s-prerender-0"></div><div class="slot" id="s-prerender-1"></div></div>
        <div class="slot-reihe"><span>prefetch</span><div class="slot" id="s-prefetch-0"></div><div class="slot" id="s-prefetch-1"></div></div>
      </div>
      <p class="pruefung" id="pruefung" aria-live="polite">Fahren Sie über einen Link der Seite oder tippen Sie ihn an.</p>
    </div>
    <div class="m-block">
      <div class="m-kopf"><h2>Letzter Seitenwechsel</h2><span class="label">Beispielwerte</span></div>
      <div class="wechsel"><span class="wechsel__zahl" id="wechsel-zahl">–</span><span class="wechsel__text" id="wechsel-text">noch kein Wechsel</span></div>
      <div class="balken" id="balken" aria-hidden="true">
        <div data-art="ohne"><span>ohne</span><s style="width:100%"></s><span>900 ms</span></div>
        <div data-art="prefetch"><span>prefetch</span><s style="width:13.3%"></s><span>120 ms</span></div>
        <div data-art="prerender"><span>prerender</span><s style="width:1.5%"></s><span>0 ms</span></div>
      </div>
    </div>
    <div class="m-block">
      <div class="m-kopf"><h2>Statistik</h2><span class="label">Seitenaufrufe</span></div>
      <div class="statistik">
        <div><b id="z-richtig">1</b><small>gezählt bei prerenderingchange</small></div>
        <div><b id="z-naiv">1</b><small>naiv gezählt, auch nie gesehene</small></div>
      </div>
    </div>
    <div class="m-block m-block--log">
      <div class="m-kopf"><h2>Protokoll</h2></div>
      <ol class="log" id="log" role="log" aria-label="Protokoll der Vorlade-Vorgänge"></ol>
    </div>
  </aside>
</main>

<section class="code-teil" aria-label="Code und Stand">
  <div class="karte">
    <p class="label">Im Kopf jeder Seite</p>
    <h2 id="code-titel">Speculation Rules: Empfehlung</h2>
    <pre id="code-regeln"></pre>
    <h2>Statistik erst bei Sichtbarkeit zählen</h2>
    <pre>function zaehleAufruf() { /* Statistik-Tool aufrufen */ }
if (document.prerendering) {
  document.addEventListener('prerenderingchange', zaehleAufruf, { once: true });
} else {
  zaehleAufruf();
}</pre>
    <h2>Übergang zwischen echten Seiten (CSS)</h2>
    <pre>@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}</pre>
  </div>
  <div class="karte">
    <p class="label">Stand 02.11.2026</p>
    <h2>Browser und Grenzen</h2>
    <ul>
      <li>Chrome und Edge: prerender seit 109, eagerness und Kopfzeile <code>Speculation-Rules</code> seit 121. Chrome 151 kann GET-Suchformulare vorrendern (<code>form_submission</code>).</li>
      <li>Firefox: keine Unterstützung in der stabilen Version. Safari: nur hinter einem Flag. Beide ignorieren den Block, die Seite lädt dort normal.</li>
      <li><code>moderate</code>: Desktop nach 200 ms Hover oder beim Drücken, mobil nach einer Viewport-Heuristik (500 ms nach dem Scrollen). Höchstens 2 prefetch und 2 prerender, das älteste fliegt raus.</li>
      <li>Kein Vorladen bei Datensparmodus, Energiesparmodus mit wenig Akku, wenig Speicher oder wenn „Seiten vorab laden“ aus ist.</li>
      <li>Prerender führt Skripte aus: Statistik, Autoplay und Chat-Widgets erst nach <code>prerenderingchange</code> starten. Prüfen in den DevTools unter Application → Speculative loads.</li>
      <li>Messen bleibt Pflicht: LCP unter 2,5 s, INP unter 200 ms, CLS unter 0,1.</li>
    </ul>
  </div>
</section>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const ohneHover = matchMedia('(hover: none)');

  /* ---------- Regelsätze: die Empfehlung kommt direkt aus dem Block im Kopf ---------- */
  const REGELN = {
    empfehlung: JSON.parse($('#regeln').textContent),
    wordpress: { prefetch: [{ source: 'document', where: { and: [
      { href_matches: '/*' },
      { not: { href_matches: ['/wp-*.php', '/wp-admin/*'] } },
      { not: { selector_matches: "a[rel~='nofollow']" } }
    ] }, eagerness: 'conservative' }] }
  };
  const INFO = {
    empfehlung: 'prefetch moderate für alle internen Links, prerender moderate für Projekte und Kontakt, eigene Ausschlüsse.',
    wordpress: 'Vereinfacht nach WordPress 6.8: nur prefetch, erst beim Drücken (conservative). Admin, Login und nofollow sind ausgenommen, eigene Pfade wie Abmelden nicht.'
  };
  const q = new URLSearchParams(location.search);
  let regel = REGELN[q.get('v')] ? q.get('v') : 'empfehlung';

  /* href_matches wie URL Pattern (nur Pfad; Suche und Fragment sind Platzhalter) */
  const muster = p => new RegExp('^' + p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
  function anker(a) { /* Link, wie er auf der echten Seite stünde */
    const v = document.createElement('a');
    v.setAttribute('href', a.dataset.pfad);
    for (const n of ['rel', 'class', 'data-consent']) if (a.hasAttribute(n)) v.setAttribute(n, a.getAttribute(n));
    return v;
  }
  const zeigeBed = b => b.href_matches ? 'href_matches ' + JSON.stringify(b.href_matches) : 'selector_matches ' + JSON.stringify(b.selector_matches);
  function passt(b, v, pfad) {
    if (b.and) { for (const x of b.and) { const r = passt(x, v, pfad); if (!r.ok) return r; } return { ok: true }; }
    if (b.not) { const r = passt(b.not, v, pfad); return r.ok ? { ok: false, grund: 'not ' + (r.treffer || zeigeBed(b.not)) } : { ok: true }; }
    if (b.href_matches) { const pf = pfad.split('?')[0]; const t = [].concat(b.href_matches).find(p => muster(p).test(pf)); return t ? { ok: true, treffer: 'href_matches "' + t + '"' } : { ok: false, grund: 'kein Treffer in href_matches' }; }
    if (b.selector_matches) { const t = [].concat(b.selector_matches).find(s => v.matches(s)); return t ? { ok: true, treffer: 'selector_matches "' + t + '"' } : { ok: false, grund: 'kein Treffer in selector_matches' }; }
    return { ok: false, grund: 'unbekannt' };
  }
  /* Welche Regel greift für diesen Link? prerender vor prefetch */
  function bewerte(a) {
    const v = anker(a), pfad = a.dataset.pfad, R = REGELN[regel];
    let grund = null;
    for (const art of ['prerender', 'prefetch']) for (const r of R[art] || []) {
      const e = passt(r.where, v, pfad);
      if (e.ok) return { art, eagerness: r.eagerness || 'conservative' };
      if (art === 'prefetch' || !grund) grund = e.grund;
    }
    return { art: null, grund };
  }

  /* ---------- Simulierte Vorgänge (Chrome: je 2 prefetch und 2 prerender, FIFO) ---------- */
  const DAUER = { prefetch: 300, prerender: 600 };     /* bis bereit, Beispielwerte */
  const WECHSEL = { ohne: 900, prefetch: 120 };       /* Seitenwechsel, Beispielwerte */
  let slots = { prefetch: [], prerender: [] };
  const zaehler = { richtig: 0, naiv: 0 };
  let seite = '/';

  function log(t) {
    const ol = $('#log'), li = document.createElement('li');
    li.textContent = t; ol.prepend(li);
    while (ol.children.length > 5) ol.lastChild.remove();
  }
  function zeichneSlots() {
    for (const art of ['prefetch', 'prerender']) for (let i = 0; i < 2; i++) {
      const el = $(`#s-${art}-${i}`), s = slots[art][i];
      if (!s) { el.removeAttribute('data-z'); el.innerHTML = '<small>frei</small>'; continue; }
      const bereit = performance.now() >= s.fertig;
      el.dataset.z = bereit ? 'bereit' : 'laeuft';
      el.innerHTML = `<b>${s.pfad}</b><small>${bereit ? (art === 'prerender' ? 'aufgebaut, sofort bereit' : 'HTML geladen') : 'lädt …'}</small>` + (bereit ? '' : '<i></i>');
      const bar = el.querySelector('i');
      if (bar) bar.animate([{ transform: 'scaleX(' + Math.max(0, 1 - (s.fertig - performance.now()) / DAUER[art]) + ')' }, { transform: 'scaleX(1)' }], { duration: reduziert() ? 1 : Math.max(1, s.fertig - performance.now()), fill: 'forwards' });
    }
  }
  function markiere() {
    document.querySelectorAll('#fenster a[data-pfad]').forEach(a => {
      if (a.dataset.zustand === 'wartet' || a.dataset.zustand === 'aus') return;
      const art = ['prerender', 'prefetch'].find(x => slots[x].some(s => s.pfad === a.dataset.pfad));
      if (art) a.dataset.zustand = art; else a.removeAttribute('data-zustand');
    });
  }
  function starte(art, pfad, ausloeser) {
    if (slots.prerender.some(s => s.pfad === pfad) || slots[art].some(s => s.pfad === pfad)) return;
    if (slots[art].length >= 2) { const alt = slots[art].shift(); log(`${art} ${alt.pfad} verworfen (FIFO, Grenze 2)`); }
    const s = { pfad, fertig: performance.now() + DAUER[art] };
    slots[art].push(s);
    if (art === 'prerender') { zaehler.naiv++; zeigeZaehler(); }
    log(`${art} ${pfad} · ${ausloeser}`);
    zeichneSlots(); markiere();
    setTimeout(() => { zeichneSlots(); }, DAUER[art] + 10);
  }
  function zeigeZaehler() { $('#z-richtig').textContent = zaehler.richtig; $('#z-naiv').textContent = zaehler.naiv; }

  /* ---------- Absicht erkennen ---------- */
  let wartet = null;
  function pruefText(a, e, art) {
    const p = $('#pruefung'), pfad = a.dataset.pfad;
    if (!e.art) {
      p.innerHTML = `<b>${pfad}</b> · <strong>nicht vorladen</strong><br>${e.grund}`;
      return;
    }
    const warn = regel === 'wordpress' && /abmelden|projektraum/.test(pfad) ? '<br><span class="warn">Achtung: GET mit Nebenwirkung wird vorgeladen.</span>' : '';
    p.innerHTML = `<b>${pfad}</b> · <em>${art || e.art}</em> · ${e.eagerness}${art ? '' : (e.eagerness === 'moderate' ? (ohneHover.matches ? ' · beim Antippen oder Scroll-Pause' : ' · nach 200 ms Hover') : ' · erst beim Drücken')}${warn}`;
  }
  function hoverStart(a) {
    clearTimeout(wartet && wartet.t);
    const e = bewerte(a);
    if (!e.art) { a.dataset.zustand = 'aus'; pruefText(a, e); return; }
    pruefText(a, e);
    if (e.eagerness !== 'moderate' || slots[e.art].some(s => s.pfad === a.dataset.pfad)) return;
    a.dataset.zustand = 'wartet';
    wartet = { a, t: setTimeout(() => { a.removeAttribute('data-zustand'); starte(e.art, a.dataset.pfad, 'Hover 200 ms'); }, 200) };
  }
  function hoverEnde(a) {
    if (wartet && wartet.a === a) { clearTimeout(wartet.t); wartet = null; }
    if (a.dataset.zustand === 'wartet' || a.dataset.zustand === 'aus') a.removeAttribute('data-zustand');
    markiere();
  }
  function druecken(a) {
    const e = bewerte(a);
    if (e.art) { clearTimeout(wartet && wartet.t); starte(e.art, a.dataset.pfad, e.eagerness === 'conservative' ? 'pointerdown (conservative)' : 'pointerdown'); }
  }

  /* ---------- Seitenwechsel ---------- */
  let laedt = null;
  function geheZu(pfad, a) {
    if (laedt) { clearTimeout(laedt); laedt = null; }
    const pr = slots.prerender.find(s => s.pfad === pfad), pf = slots.prefetch.find(s => s.pfad === pfad), jetzt = performance.now();
    let ms, art, text;
    if (pr) { ms = Math.max(0, Math.round(pr.fertig - jetzt)); art = 'prerender'; text = ms ? 'Vorrendern lief noch, Rest abgewartet' : 'vorgerendert, sofort angezeigt'; }
    else if (pf) { ms = Math.max(0, Math.round(pf.fertig - jetzt)) + WECHSEL.prefetch; art = 'prefetch'; text = 'HTML war da, nur noch Rendern'; }
    else { ms = WECHSEL.ohne; art = 'ohne'; text = a && bewerte(a).art === null ? 'ausgeschlossen, normal geladen' : 'nicht vorgeladen, normal geladen'; }
    const bal = $('#ladebalken');
    bal.getAnimations().forEach(x => x.cancel());
    if (ms > 0) {
      bal.classList.add('an');
      bal.animate([{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }], { duration: reduziert() ? 1 : ms, easing: 'linear', fill: 'forwards' });
    }
    laedt = setTimeout(() => {
      laedt = null;
      bal.classList.remove('an');
      const zeige = () => { zeichneSeite(pfad); };
      if (document.startViewTransition && !reduziert()) document.startViewTransition(zeige); else zeige();
      /* neue Seite: alte Spekulationen verfallen, Statistik zählt jetzt */
      slots = { prefetch: [], prerender: [] }; zeichneSlots(); markiere();
      $('#pruefung').textContent = 'Neue Seite ' + pfad + ': Regeln gelten neu, nichts vorgeladen.';
      zaehler.richtig++; if (art !== 'prerender') zaehler.naiv++; zeigeZaehler();
      $('#wechsel-zahl').textContent = ms + ' ms';
      $('#wechsel-text').textContent = text;
      document.querySelectorAll('#balken div').forEach(d => d.toggleAttribute('data-an', d.dataset.art === art));
      log(`Wechsel zu ${pfad} · ${ms} ms (${art === 'ohne' ? 'ohne Vorladen' : art})`);
      if (!html.classList.contains('vorfuehrung')) { const h = $('#inhalt h2'); if (h) h.focus({ preventScroll: true }); }
      $('#fenster').scrollTo({ top: 0 });
    }, ms);
  }

  /* ---------- Inhalte der simulierten Seite ---------- */
  const PROJEKTE = [
    { slug: 'speicher-hafenbecken', titel: 'Speicher am Hafenbecken I', ort: 'Neuss', jahr: 2025, typ: 'Umbau', bgf: '3.480 m²', lph: '1–8', text: 'Ein Getreidespeicher von 1911 wird zu Ateliers und Büros. Die Ziegelhülle bleibt, innen trägt ein neues Holztragwerk vier Geschosse. Die alten Ladeluken sind jetzt Fenster.', bild: 'speicher' },
    { slug: 'grundschule-reuschenberg', titel: 'Grundschule Reuschenberg', ort: 'Neuss', jahr: 2024, typ: 'Bildung', bgf: '1.920 m²', lph: '2–8', text: 'Erweiterung um sechs Lernräume und eine Mensa in Holzbauweise. Jeder Lernraum öffnet sich zu einer gedeckten Außenklasse.', bild: 'schule' },
    { slug: 'stadthaus-obertor', titel: 'Stadthaus am Obertor', ort: 'Neuss', jahr: 2023, typ: 'Wohnen', bgf: '1.140 m²', lph: '1–9', text: 'Neun Wohnungen auf einer Baulücke von elf Metern Breite. Die Loggien liegen zum Hof, die Straßenseite bleibt ruhig.', bild: 'stadthaus' },
    { slug: 'hofhaus-holzheim', titel: 'Hofhaus in Holzheim', ort: 'Neuss', jahr: 2022, typ: 'Wohnen', bgf: '310 m²', lph: '1–8', text: 'Ein Wohnhaus um einen offenen Hof, mit Ziegel aus der Region und einem flachen Dach, das zum Garten hin auskragt.', bild: 'hofhaus' },
    { slug: 'werkhalle-erftaue', titel: 'Werkhalle Erftaue', ort: 'Grevenbroich', jahr: 2021, typ: 'Gewerbe', bgf: '2.650 m²', lph: '2–5', text: 'Eine Halle für einen Metallbaubetrieb mit Sheddach. Das Nordlicht reicht für die Werkstatt, Kunstlicht brennt erst am Abend.', bild: 'halle' }
  ];
  const L = 'fill="none" stroke="#231f20" stroke-width="1.4" stroke-linejoin="round"';
  const BILD = {
    speicher: `<svg viewBox="0 0 400 240" aria-hidden="true"><path ${L} d="M120 220V70l80-46 80 46v150Z"/>${[0, 1, 2, 3].map(r => [0, 1, 2].map(c => `<rect ${L} x="${142 + c * 42}" y="${88 + r * 32}" width="18" height="20"/>`).join('')).join('')}<path ${L} d="M188 220v-34h24v34M40 220h320"/></svg>`,
    schule: `<svg viewBox="0 0 400 240" aria-hidden="true"><path ${L} d="M30 220V120h340v100M20 120h360"/>${[0, 1, 2, 3, 4, 5].map(i => `<rect ${L} x="${46 + i * 54}" y="138" width="34" height="62"/>`).join('')}${Array.from({ length: 34 }, (_, i) => `<path ${L} stroke-width=".7" d="M${34 + i * 10} 104v16"/>`).join('')}<path ${L} d="M10 220h380"/></svg>`,
    stadthaus: `<svg viewBox="0 0 400 240" aria-hidden="true"><path ${L} d="M150 220V30h100v190M110 220V96h40M250 220V80h44v140M80 220h260"/>${[0, 1, 2, 3, 4].map(r => `<rect ${L} x="166" y="${46 + r * 34}" width="28" height="22"/><rect ${L} x="206" y="${46 + r * 34}" width="28" height="22"/><path ${L} d="M160 ${72 + r * 34}h80"/>`).join('')}</svg>`,
    hofhaus: `<svg viewBox="0 0 400 240" aria-hidden="true"><path ${L} d="M40 150h300l30-8M60 150v70M330 150v70M30 220h350"/><rect ${L} x="86" y="168" width="70" height="52"/><rect ${L} x="180" y="168" width="34" height="34"/><rect ${L} x="236" y="168" width="70" height="52"/>${Array.from({ length: 13 }, (_, i) => `<path ${L} stroke-width=".7" d="M${62 + i * 21} 150v-6"/>`).join('')}</svg>`,
    halle: `<svg viewBox="0 0 400 240" aria-hidden="true"><path ${L} d="M30 220V120${[0, 1, 2, 3, 4].map(i => `l34-40v40l34 0`).join('').replace(/l34 0$/, '')}V220M20 220h360"/>${[0, 1, 2, 3, 4].map(i => `<path ${L} stroke-width=".7" d="M${64 + i * 68} 84v36"/>`).join('')}<rect ${L} x="70" y="160" width="60" height="60"/><rect ${L} x="250" y="150" width="90" height="70"/></svg>`
  };
  const lnk = (seiteId, pfad, inhalt, extra = '') => `<a href="?seite=${seiteId}" data-pfad="${pfad}" ${extra}>${inhalt}</a>`;
  const projektLink = p => `<li>${lnk('projekt', '/projekte/' + p.slug + '/', `<span class="p-titel">${p.titel}</span><span class="p-meta">${p.ort} • ${p.jahr}</span>`)}</li>`;
  const SEITEN = {
    '/': () => `<p class="label">Architekten in Neuss seit 2009</p><h2 tabindex="-1">Bauen im Bestand am <i>Niederrhein</i>.</h2>
      <p>Wir planen Umbauten, Schulen und Wohnhäuser zwischen Neuss, Düsseldorf und Grevenbroich. Was steht, nutzen wir weiter.</p>
      <ul class="liste">${PROJEKTE.slice(0, 3).map(projektLink).join('')}</ul>
      <p>${lnk('projekte', '/projekte/', 'Alle Projekte', 'class="weg"')}</p>`,
    '/projekte/': () => `<p class="label">Werkverzeichnis · ${PROJEKTE.length} Projekte</p><h2 tabindex="-1">Projekte</h2>
      <nav class="filter" aria-label="Projekte filtern">${lnk('projekte', '/projekte/', 'Alle', 'aria-current="page"')}${['Wohnen', 'Bildung', 'Umbau'].map(t => lnk('projekte', '/projekte/?typ=' + t.toLowerCase(), t)).join('')}</nav>
      <ul class="liste">${PROJEKTE.map(projektLink).join('')}</ul>`,
    '/buero/': () => `<p class="label">Büro</p><h2 tabindex="-1">Vierzehn Menschen, ein Raster.</h2>
      <div class="zweispaltig"><p>Anna Lenhard und Tobias Saur führen das Büro seit 2009. Wir arbeiten in allen Leistungsphasen und begleiten die Baustelle selbst.</p>
      <dl class="daten"><dt>Partner</dt><dd>Anna Lenhard, Tobias Saur</dd><dt>Team</dt><dd>14 Personen</dd><dt>Kammer</dt><dd>Architektenkammer Nordrhein-Westfalen</dd></dl></div>
      <p>${lnk('kontakt', '/kontakt/', 'Kontakt aufnehmen', 'class="weg"')}</p>`,
    '/kontakt/': () => `<p class="label">Kontakt</p><h2 tabindex="-1">Erzählen Sie uns von Ihrem <i>Haus</i>.</h2>
      <div class="zweispaltig"><dl class="daten"><dt>Adresse</dt><dd>Hafenstraße 21<br>41460 Neuss</dd><dt>Telefon</dt><dd><a href="tel:+492131664820">02131 66 48 20</a></dd><dt>E-Mail</dt><dd><a href="mailto:buero@lenhard-saur.example">buero@lenhard-saur.example</a></dd></dl>
      <form class="feld" id="anfrage"><label for="nachricht">Ihr Vorhaben in zwei Sätzen</label><textarea id="nachricht" name="nachricht">Wir möchten eine Scheune in Holzheim zu Wohnraum umbauen.</textarea><button class="knopf" type="submit">Anfrage senden</button></form></div>`,
    '/kontakt/danke/': () => `<p class="label">Formular-Ergebnis</p><h2 tabindex="-1">Danke, Ihre Anfrage ist da.</h2><p>Wir melden uns innerhalb von drei Werktagen. Diese Seite wird nie vorgeladen, sie entsteht erst durch das Absenden.</p><p>${lnk('start', '/', 'Zur Startseite', 'class="weg"')}</p>`,
    '/projektraum/': () => `<p class="label">Projektraum · angemeldet</p><h2 tabindex="-1">Guten Tag, Familie Nießen.</h2>
      <p>Hier liegen Pläne, Protokolle und Termine zu Ihrem Bauvorhaben. Der Bereich ist persönlich und wird deshalb nie vorgeladen.</p>
      <ul class="liste"><li>${lnk('projektraum', '/projektraum/plaene/', '<span class="p-titel">Werkpläne, Stand 28.10.2026</span><span class="p-meta">12 PDF</span>')}</li><li>${lnk('abmelden', '/projektraum/abmelden', '<span class="p-titel">Abmelden</span><span class="p-meta">beendet die Sitzung</span>')}</li></ul>`,
    '/projektraum/plaene/': () => `<p class="label">Projektraum</p><h2 tabindex="-1">Werkpläne</h2><p>Grundrisse, Schnitte und Ansichten im Maßstab 1:50, freigegeben am 28.10.2026.</p>`,
    '/projektraum/abmelden': () => `<p class="label">Projektraum</p><h2 tabindex="-1">Sie sind abgemeldet.</h2><p>Ein Vorladen dieser Adresse hätte Sie schon beim Darüberfahren abgemeldet. Deshalb steht sie in den Ausschlüssen.</p><p>${lnk('start', '/', 'Zur Startseite', 'class="weg"')}</p>`,
    '/presse/pressemappe-2026.pdf': () => `<p class="label">Download</p><h2 tabindex="-1">Pressemappe 2026</h2><p>PDF mit 4 MB. Große Dateien werden nicht vorgeladen, sie würden Datenvolumen kosten, auch wenn niemand klickt.</p>`,
    '/datenschutz/': () => `<p class="label">Datenschutz</p><h2 tabindex="-1">Cookie-Einstellungen</h2><p>Dieser Link öffnet die Einwilligung. Er trägt <code>data-consent</code> und wird nie vorgeladen, damit kein Dialog und kein Zähler im Hintergrund startet.</p>`,
    '/impressum/': () => `<p class="label">Impressum</p><h2 tabindex="-1">Impressum</h2><p>Lenhard Saur Architekten PartGmbB, Hafenstraße 21, 41460 Neuss. Kammer: Architektenkammer Nordrhein-Westfalen.</p>`
  };
  for (const p of PROJEKTE) SEITEN['/projekte/' + p.slug + '/'] = () => {
    const i = PROJEKTE.indexOf(p), n = PROJEKTE[(i + 1) % PROJEKTE.length];
    return `<p class="label">${p.typ} · ${p.ort} · ${p.jahr}</p><h2 tabindex="-1">${p.titel}</h2>
      <div class="bild">${BILD[p.bild]}</div>
      <div class="zweispaltig"><p>${p.text}</p><dl class="daten"><dt>Ort</dt><dd>${p.ort}</dd><dt>Fertig</dt><dd>${p.jahr}</dd><dt>BGF</dt><dd>${p.bgf}</dd><dt>Leistungsphasen</dt><dd>${p.lph}</dd></dl></div>
      <ul class="liste"><li>${lnk('projekt', '/projekte/' + n.slug + '/', `<span class="p-titel">Nächstes Projekt: ${n.titel}</span><span class="p-meta">${n.ort} • ${n.jahr}</span>`)}</li></ul>
      <p>${lnk('kontakt', '/kontakt/', 'Ähnliches Vorhaben? Kontakt', 'class="weg"')}</p>`;
  };
  SEITEN['/projekte/?typ=wohnen'] = SEITEN['/projekte/?typ=bildung'] = SEITEN['/projekte/?typ=umbau'] = SEITEN['/projekte/'];

  function zeichneSeite(pfad) {
    seite = pfad;
    $('#inhalt').innerHTML = (SEITEN[pfad] || SEITEN['/'])();
    $('#url-pfad').textContent = pfad;
    document.querySelectorAll('.site-nav a').forEach(a => {
      if (pfad.startsWith(a.dataset.pfad) && a.dataset.pfad !== '/') a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
    });
    const f = $('#anfrage');
    if (f) f.addEventListener('submit', e => { e.preventDefault(); log('Formular abgesendet → /kontakt/danke/'); geheZu('/kontakt/danke/'); });
  }

  /* ---------- Ereignisse ---------- */
  const fenster = $('#fenster');
  fenster.addEventListener('pointerover', e => { if (e.pointerType === 'touch') return; const a = e.target.closest('a[data-pfad]'); if (a && !a.contains(e.relatedTarget)) hoverStart(a); });
  fenster.addEventListener('pointerout', e => { const a = e.target.closest('a[data-pfad]'); if (a && !a.contains(e.relatedTarget)) hoverEnde(a); });
  fenster.addEventListener('pointerdown', e => { const a = e.target.closest('a[data-pfad]'); if (a) druecken(a); });
  fenster.addEventListener('click', e => {
    const a = e.target.closest('a[data-pfad]');
    if (!a) return;
    e.preventDefault();
    if (e.isTrusted) pause();
    geheZu(a.dataset.pfad, a);
  });
  /* Tastatur: Fokus löst bei moderate kein Vorladen aus, zeigt aber die Bewertung */
  fenster.addEventListener('focusin', e => { const a = e.target.closest('a[data-pfad]'); if (a) { const b = bewerte(a); pruefText(a, b); if (b.art) $('#pruefung').insertAdjacentHTML('beforeend', '<br>Tastaturfokus: kein Auslöser für moderate.'); } });
  /* Mobil: moderate nutzt eine Viewport-Heuristik, 500 ms nach dem Scrollen */
  let scrollRuhe = null;
  function nachScroll() {
    if (!ohneHover.matches) return;
    clearTimeout(scrollRuhe);
    scrollRuhe = setTimeout(() => {
      const fr = fenster.getBoundingClientRect(), oben = Math.max(0, fr.top), unten = Math.min(innerHeight, fr.bottom), mitte = (oben + unten) / 2;
      const kand = [...document.querySelectorAll('#inhalt a[data-pfad]')].map(a => ({ a, r: a.getBoundingClientRect() }))
        .filter(x => x.r.bottom > oben && x.r.top < unten && x.r.width * x.r.height > 600)
        .sort((x, y) => Math.abs(x.r.top - mitte) - Math.abs(y.r.top - mitte));
      for (const { a } of kand.slice(0, 2)) { const b = bewerte(a); if (b.art && b.eagerness === 'moderate') starte(b.art, a.dataset.pfad, 'Viewport-Heuristik (mobil)'); }
    }, 500);
  }
  addEventListener('scroll', nachScroll, { passive: true });
  fenster.addEventListener('scroll', nachScroll, { passive: true });

  function setzeRegel(v, nutzer) {
    regel = v;
    $('#buehne').dataset.regel = v;
    document.querySelectorAll('.wahl button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    $('#wahl-info').textContent = INFO[v];
    $('#code-titel').textContent = 'Speculation Rules: ' + (v === 'empfehlung' ? 'Empfehlung' : 'WordPress-Standard (vereinfacht)');
    $('#code-regeln').textContent = '<script type="speculationrules">\n' + (v === 'empfehlung' ? $('#regeln').textContent.trim() : JSON.stringify(REGELN.wordpress, null, 2)) + '\n</' + 'script>';
    slots = { prefetch: [], prerender: [] }; zeichneSlots(); markiere();
    if (nutzer) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(null, '', u); log('Regelsatz: ' + (v === 'empfehlung' ? 'Empfehlung' : 'WordPress-Standard')); }
  }
  document.querySelector('.wahl').addEventListener('click', e => { const b = e.target.closest('[data-v]'); if (b) { pause(); setzeRegel(b.dataset.v, true); } });

  /* Echte Statistik dieser Demo-Seite: zählt erst, wenn sie sichtbar ist */
  function zaehleAufruf() { zaehler.richtig++; zaehler.naiv++; zeigeZaehler(); log('Demo-Seite gezählt (document.prerendering war ' + (document.prerendering ? 'true' : 'false') + ')'); }
  zaehler.richtig = 0; zaehler.naiv = 0;
  if (document.prerendering) document.addEventListener('prerenderingchange', zaehleAufruf, { once: true }); else zaehleAufruf();

  /* ---------- Vorführung mit gedachtem Zeiger ---------- */
  const zeiger = $('#zeiger');
  let schleife = null, schritt = 0, aktuell = null;
  function zeigeAuf(sel) {
    const a = typeof sel === 'string' ? document.querySelector(sel) : sel;
    if (!a) return null;
    const f = fenster.getBoundingClientRect(), r = a.getBoundingClientRect();
    const top = r.top - f.top + fenster.scrollTop;
    if (top > fenster.scrollTop + fenster.clientHeight - 60 || top < fenster.scrollTop) fenster.scrollTo({ top: Math.max(0, top - fenster.clientHeight / 2), behavior: 'auto' });
    const r2 = a.getBoundingClientRect();
    zeiger.style.transform = `translate(${r2.left - f.left + Math.min(r2.width * .4, 60)}px, ${r2.top - f.top + fenster.scrollTop + r2.height * .55}px)`;
    if (aktuell && aktuell !== a) hoverEnde(aktuell);
    aktuell = a; hoverStart(a);
    return a;
  }
  function klicke() { if (!aktuell) return; zeiger.classList.add('drueckt'); druecken(aktuell); setTimeout(() => zeiger.classList.remove('drueckt'), 160); const a = aktuell; hoverEnde(a); aktuell = null; geheZu(a.dataset.pfad, a); }
  const nav = p => `.site-nav a[data-pfad="${p}"]`;
  const ABLAUF = [
    [900, () => { if (seite !== '/') { slots = { prefetch: [], prerender: [] }; zeichneSeite('/'); zeichneSlots(); } zeiger.style.transform = 'translate(40%, 60%)'; }],
    [1300, () => zeigeAuf(nav('/kontakt/'))],
    [1300, () => zeigeAuf(nav('/projekte/'))],
    [1100, klicke],
    [1300, () => zeigeAuf('#inhalt .liste li:nth-child(2) a')],
    [1100, klicke],
    [1500, () => zeigeAuf(nav('/projektraum/'))],
    [1400, () => zeigeAuf(nav('/kontakt/'))],
    [1300, klicke],
    [1700, () => zeigeAuf('.site-fuss a[data-consent]')],
    [1600, () => zeigeAuf('.wortmarke')],
    [1200, klicke]
  ];
  function lauf() { const [d, fn] = ABLAUF[schritt % ABLAUF.length]; fn(); schritt++; schleife = setTimeout(lauf, d); }

  function neustart() { pause(); slots = { prefetch: [], prerender: [] }; zeichneSeite('/'); zeichneSlots(); $('#pruefung').textContent = ohneHover.matches ? 'Tippen Sie einen Link an oder scrollen Sie und halten kurz an.' : 'Fahren Sie über einen Link der Seite.'; }
  function vorfuehren() { pause(); html.classList.add('vorfuehrung'); schritt = 0; lauf(); }
  function pause() { clearTimeout(schleife); schleife = null; html.classList.remove('vorfuehrung'); if (aktuell) { hoverEnde(aktuell); aktuell = null; } }
  function weiter() { if (!schleife) { html.classList.add('vorfuehrung'); lauf(); } }

  setzeRegel(regel, false);
  zeichneSeite('/');
  zeichneSlots();

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