Sprunglinks und Landmarken
Gestaltete Sprunglinks erscheinen erst beim ersten Tab und führen direkt zu Inhalt, Suche oder Spendenformular; darunter ist die Seite in benannte Landmarken gegliedert, die eine Karte sichtbar macht.
Seiten mit Suche und einem Hauptziel, das viele direkt ansteuern (Spenden bei Vereinen, Termin bei Praxen, Warenkorb im Shop).
Ganz oben im Dokument stehen ein bis drei Sprunglinks („Zum Inhalt“, „Zur Suche“, „Zum Spendenformular“). Sie sind unsichtbar, bis jemand mit der Tastatur hineintabbt, und erscheinen dann gestaltet in der Markensprache. Darunter ist die Seite in klare Landmarken gegliedert: header (banner), nav mit eigenem Namen, search, main, aside (complementary), ein benanntes Formular und footer (contentinfo). Screenreader springen damit direkt zu den Bereichen, Tastaturnutzer überspringen die Navigation.
Wer mit Tastatur oder Schalter navigiert, muss sonst bei jeder Seite durch Logo, Menü und Suche tabben. Ein Sprunglink spart das. Landmarken geben Screenreadern ein Inhaltsverzeichnis der Seite. Gestaltet statt versteckt zeigt der Sprunglink außerdem, dass die Marke an alle gedacht hat.
Wann einsetzen
Auf jeder Seite mit wiederholtem Kopf und Navigation. Das Muster gehört zur Basis jedes Rezepts. Weitere Sprungziele nur für Bereiche, die viele direkt suchen (Suche, Spenden, Termin, Warenkorb).
Wann nicht
Nicht mehr als drei Sprungziele, sonst wird die Leiste selbst zum Hindernis; meist reicht „Zum Inhalt“. Keine Landmarken für jede Kleinigkeit: zu viele region- oder nav-Bereiche entwerten die Liste. Sprunglinks nie mit display:none oder hidden verstecken, sonst sind sie nicht mehr erreichbar.
Varianten
- Sprungmenü mit bis zu drei Zielen als Leiste am oberen Rand
- Einzelner Sprunglink „Zum Inhalt“ als Pille links oben
- Sprunglink innerhalb eines Consent-Banners erst nach dem Banner (GOV.UK-Hinweis)
- Landmarken-Karte als Entwicklungs-Overlay, das Rolle und Namen jeder Landmarke einblendet
Umsetzung
Erstes Element in <body>, nicht in nav oder header: ein Block mit Liste <a href="#inhalt">Zum Inhalt</a>, <a href="#suchfeld">Zur Suche</a>, <a href="#spenden">Zum Spendenformular</a>. Versteckt per translate: 0 -120 %, sichtbar bei .sprung:focus-within (200 ms, cubic-bezier(0.645, 0.045, 0.355, 1)). Nie display:none, hidden oder visibility:hidden. Ziele: <main id="inhalt" tabindex="-1">, das Suchfeld selbst, <form id="spenden" tabindex="-1" aria-labelledby>. Ein kleines Script setzt nach dem Klick den Fokus aufs Ziel (wie GOV.UK seit v4). scroll-margin-top auf alle Ziele, damit ein fester Kopf sie nicht verdeckt; den Kopf nur fest machen, wenn genug Platz ist (min-height: 500px). Landmarken: <header>, <nav aria-label="Hauptnavigation">, <search>, <main>, <form> mit Namen, <aside> mit Namen auf oberster Ebene, <footer>, zweite nav mit eigenem Namen. Die Landmarken-Karte der Demo liest Rolle und Namen aus dem DOM und ist aria-hidden; im Kundenprojekt entfällt sie.
Robust umsetzen
Telefon. Touch-Nutzer sehen die Sprunglinks nicht, Tastatur- und Schalternutzer auf dem Telefon schon: dann stapeln sie sich in voller Breite. Fester Kopf nur bei genug Höhe.
Ohne Bewegung. Der Sprunglink erscheint ohne Gleiten sofort; Scrollen zum Ziel ohne smooth.
Barrierefrei. 2.4.1 Umgehen von Blöcken (A): Sprunglink zum Hauptinhalt als erstes fokussierbares Element. 1.3.1 Info und Beziehungen (A): Landmarken über native Elemente; jede nav mit eigenem Namen; aside auf oberster Ebene; form nur mit Namen eine Landmarke. 2.4.3 Fokusreihenfolge (A): Nach dem Sprung startet Tab im Ziel (tabindex="-1"). 2.4.7 Fokus sichtbar (AA): 3 px outline, auch im Kontrastmodus sichtbar, weil outline statt box-shadow. 2.4.11 Fokus nicht verdeckt (AA): scroll-margin-top unter dem festen Kopf, Kopf nur fest bei genug Höhe. 1.4.3 Kontrast (AA): Sprunglink weiß auf #002e6d, Spenden-Text #222520 auf #ffca0a. 1.4.11 Nicht-Text-Kontrast (AA): Fokusrahmen #002e6d auf Weiß und Weiß auf #002e6d. 2.5.8 Zielgröße (AA): Links mindestens 44 px hoch. 3.1.2 Sprache von Teilen (AA): Link „Serbsce“ mit lang="hsb". Die Landmarken-Karte ist eine reine Erklärhilfe und für Screenreader ausgeblendet.
Code
<!DOCTYPE html>
<!--
Sprunglinks und Landmarken
Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/sprunglinks-landmarken/
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>Sprunglinks und Landmarken · 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: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Archivo'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/archivo:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-stretch: 62% 125%; 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 {
/* Rezept Verein & Non-Profit: tiefer Markenton, helle Flächen derselben Farbe, Gelb nur für „Spenden“ */
--grund: #ffffff; --hell: #edf5fa; --hell-2: #dbebf5; --marke: #002e6d; --marke-2: #0a3f86;
--tinte: #222520; --leise: #4d524a; --grau: #dfdfd7; --linie: #c9d6df;
--signal: #ffca0a; --auf-signal: #222520;
--fokus: #002e6d; --fokus-dunkel: #ffffff;
--nacht-leise: #b8d8ea;
--kurve: cubic-bezier(0.645, 0.045, 0.355, 1); /* easeInOutCubic, Rezept verein */
--ui: 200ms;
--kopf-hoehe: 72px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Archivo', system-ui, sans-serif; font-size: 100%; -webkit-font-smoothing: antialiased; }
@media (prefers-reduced-motion: no-preference) { html:not(.reduziert) { scroll-behavior: smooth; } }
body { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 340px); grid-template-areas: "kopf kopf" "inhalt seite" "fuss fuss"; grid-template-rows: auto 1fr auto; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
button, input { font: inherit; color: inherit; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.display { font-family: 'Big Shoulders', 'Archivo', sans-serif; font-weight: 800; text-transform: uppercase; letter-spacing: .005em; line-height: .95; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
/* Sprungziele: fester Kopf darf sie nicht verdecken (WCAG 2.4.11) */
:target, [tabindex="-1"] { scroll-margin-top: calc(var(--kopf-hoehe) + 16px); }
/* ============ Sprunglinks ============ */
.sprung {
position: fixed; z-index: 50; inset: 0 0 auto 0;
display: flex; align-items: center; gap: 10px 22px; flex-wrap: wrap;
padding: 12px clamp(16px, 3vw, 40px);
background: var(--marke); color: #fff; border-bottom: 2px solid transparent;
translate: 0 -120%; transition: translate var(--ui) var(--kurve);
}
.sprung:focus-within, .sprung.sim-offen { translate: 0 0; }
.sprung-titel { color: var(--nacht-leise); }
.sprung ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.sprung a {
display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 16px 0 12px;
font-weight: 600; font-size: 16px; text-decoration: none; color: #fff; border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, .38);
transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve), border-color var(--ui) var(--kurve);
}
.sprung a .nr { font: 600 10.5px/1 'Geist Mono', monospace; letter-spacing: .06em; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(255, 255, 255, .14); }
.sprung a .pfeil { font-size: 15px; translate: 0 -1px; }
/* Sprunglinks erreicht man nur per Tastatur: :focus statt :focus-visible, damit auch Script-Fokus sichtbar ist */
.sprung a:focus, .sprung a.sim-fokus { outline: 3px solid var(--fokus-dunkel); outline-offset: 3px; background: #fff; color: var(--marke); border-color: #fff; }
.sprung a:focus .nr, .sprung a.sim-fokus .nr { background: var(--marke); color: #fff; }
.sprung-hilfe { margin-left: auto; color: var(--nacht-leise); font-size: 13.5px; }
.sprung-hilfe kbd { font: 600 11px/1 'Geist Mono', monospace; padding: 3px 6px; border: 1px solid rgba(255, 255, 255, .4); border-radius: 4px; color: #fff; }
/* Variante: einzelner Sprunglink als Pille */
[data-variante="einzeln"] .sprung { inset: 12px auto auto 12px; padding: 0; background: transparent; border: 0; translate: 0 calc(-100% - 24px); }
[data-variante="einzeln"] .sprung:focus-within, [data-variante="einzeln"] .sprung.sim-offen { translate: 0 0; }
[data-variante="einzeln"] .sprung-titel, [data-variante="einzeln"] .sprung-hilfe, [data-variante="einzeln"] .sprung li:not(:first-child) { display: none; }
[data-variante="einzeln"] .sprung a { background: var(--marke); border-color: var(--marke); min-height: 48px; padding: 0 20px 0 14px; box-shadow: 0 10px 30px rgba(0, 20, 50, .28); }
[data-variante="einzeln"] .sprung a:focus, [data-variante="einzeln"] .sprung a.sim-fokus { outline-color: var(--marke); background: #fff; color: var(--marke); border-color: var(--marke); }
/* ============ Kopf (banner) ============ */
.kopf { grid-area: kopf; z-index: 20; background: var(--grund); border-bottom: 1px solid var(--linie); }
.kopf-innen { display: flex; align-items: center; gap: 12px clamp(14px, 2vw, 28px); min-height: var(--kopf-hoehe); padding: 10px clamp(16px, 3vw, 40px); flex-wrap: wrap; }
.marke-link { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: var(--marke); margin-right: auto; }
.marke-link svg { width: 34px; height: 34px; flex: none; }
.marke-link b { font: 800 26px/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .01em; }
.marke-link small { display: block; font: 500 10px/1.2 'Geist Mono', monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--leise); margin-top: 3px; }
.hauptnav ul { list-style: none; display: flex; gap: 2px; flex-wrap: wrap; }
.hauptnav a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; font-weight: 600; font-size: 15px; text-decoration: none; border-radius: 6px; transition: background-color var(--ui) var(--kurve); }
.hauptnav a:hover { background: var(--hell); }
.hauptnav a[aria-current="page"] { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 6px; }
.suche form { display: flex; align-items: center; border: 1.5px solid var(--linie); border-radius: 999px; padding: 3px 3px 3px 14px; background: var(--grund); transition: border-color var(--ui) var(--kurve); }
.suche form:focus-within { border-color: var(--marke); }
.suche svg { width: 16px; height: 16px; flex: none; color: var(--leise); }
.suche input { border: 0; background: none; padding: 8px 8px; width: 16ch; min-width: 0; font-size: 15px; outline: none; }
.suche input::placeholder { color: #6a7068; }
.suche button { border: 0; background: var(--hell); color: var(--marke); font-weight: 600; font-size: 14px; border-radius: 999px; min-height: 36px; padding: 0 14px; cursor: pointer; }
.sprache { font-weight: 600; font-size: 14px; text-decoration: none; min-height: 44px; display: inline-flex; align-items: center; padding: 0 6px; }
.sprache:hover { text-decoration: underline; }
.spenden-knopf { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 20px; border-radius: 999px; background: var(--signal); color: var(--auf-signal); font-weight: 700; font-size: 15px; text-decoration: none; border: 2px solid transparent; transition: background-color var(--ui) var(--kurve); }
.spenden-knopf:hover { background: #ffd84a; }
/* Fester Kopf nur, wenn Platz ist: bei 200 % Zoom oder auf dem Telefon frisst er sonst den Bildschirm */
@media (min-width: 821px) and (min-height: 500px) { .kopf { position: sticky; top: 0; } }
/* Fokus für alles Übrige: sichtbar, mind. 3 : 1, als outline (bleibt im Kontrastmodus) */
:focus-visible { outline: 3px solid var(--fokus); outline-offset: 3px; border-radius: 4px; }
.suche input:focus-visible { outline: none; }
.suche form:has(input:focus-visible) { outline: 3px solid var(--fokus); outline-offset: 3px; }
/* Ziele mit tabindex="-1": kurz aufblitzen, dann ruhig */
@keyframes ziel { 0%, 55% { outline-color: var(--fokus); } 100% { outline-color: transparent; } }
:is(main, form)[tabindex="-1"]:focus { outline: 3px solid transparent; outline-offset: 6px; animation: ziel 1400ms var(--kurve) both; }
.ziel-sim { outline: 3px solid var(--fokus); outline-offset: 6px; animation: ziel 1400ms var(--kurve) both; }
.reduziert :is(main, form)[tabindex="-1"]:focus, .reduziert .ziel-sim { animation: none; outline-color: var(--fokus); }
/* ============ Inhalt (main) ============ */
.inhalt { grid-area: inhalt; display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .9fr); gap: 0; align-items: stretch; min-width: 0; }
.held { position: relative; isolation: isolate; overflow: hidden; background: var(--marke); color: #fff; padding: clamp(28px, 4vw, 56px) clamp(20px, 3.4vw, 48px) 0; display: flex; flex-direction: column; gap: 18px; min-height: 560px; }
.held .mono { color: var(--nacht-leise); }
.held h1 { font-size: clamp(2.75rem, 1.6rem + 4vw, 6rem); max-width: 11ch; text-wrap: balance; }
.held h1 em { font-style: normal; color: var(--hell-2); }
.held p.lead { font-size: 1.0625rem; line-height: 1.6; max-width: 46ch; color: #e6f0f7; text-wrap: pretty; }
.zahlen { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; border-top: 1px solid rgba(237, 245, 250, .22); margin-top: 6px; position: relative; z-index: 2; }
.zahlen li { padding: 14px 14px 0 0; }
.zahlen li + li { padding-left: 14px; border-left: 1px solid rgba(237, 245, 250, .22); }
.zahlen b { display: block; font: 800 clamp(2.25rem, 1.6rem + 2vw, 3.5rem)/1 'Big Shoulders', sans-serif; font-variant-numeric: tabular-nums; }
.zahlen span { display: block; font-size: 14px; line-height: 1.35; color: var(--nacht-leise); margin-top: 4px; }
.held .quelle { color: var(--nacht-leise); font-size: 12.5px; position: relative; z-index: 2; }
.stadt { margin-top: auto; margin-inline: calc(-1 * clamp(20px, 3.4vw, 48px)); position: relative; z-index: 1; }
.stadt svg { width: 100%; height: auto; }
.welle { animation: welle 9s var(--kurve) infinite alternate; }
.welle-2 { animation-duration: 12s; animation-direction: alternate-reverse; }
@keyframes welle { to { translate: -40px 0; } }
.reduziert .welle { animation: none; }
/* Spendenformular (form-Landmarke, eigener Name) */
.spende { background: var(--grund); padding: clamp(24px, 3vw, 40px) clamp(20px, 3vw, 40px); display: flex; flex-direction: column; gap: 18px; border-right: 1px solid var(--linie); }
.spende h2 { font: 800 clamp(1.75rem, 1.2rem + 1.4vw, 2.5rem)/1 'Big Shoulders', sans-serif; text-transform: uppercase; color: var(--marke); }
.spende .unter { font-size: 15px; line-height: 1.5; color: var(--leise); margin-top: -8px; }
.takt { border: 0; }
.takt legend, .betraege legend, .frei label { font: 600 13px/1.3 'Archivo', sans-serif; margin-bottom: 8px; display: block; }
.takt-wahl { position: relative; display: grid; grid-template-columns: 1fr 1fr; padding: 4px; background: var(--hell); border-radius: 999px; isolation: isolate; }
.takt-wahl::before { content: ''; position: absolute; z-index: -1; inset: 4px auto 4px 4px; width: calc(50% - 4px); border-radius: 999px; background: var(--marke); transition: translate var(--ui) var(--kurve); }
.takt-wahl:has(input[value="monatlich"]:checked)::before { translate: 100% 0; }
.takt-wahl label { position: relative; display: grid; place-items: center; min-height: 40px; font-weight: 600; font-size: 15px; border-radius: 999px; cursor: pointer; transition: color var(--ui) var(--kurve); }
.takt-wahl label:has(input:checked) { color: #fff; }
.takt-wahl input, .betraege input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.takt-wahl label:has(input:focus-visible), .betraege label:has(input:focus-visible) { outline: 3px solid var(--fokus); outline-offset: 2px; }
.betraege { border: 0; }
.betraege-liste { display: grid; gap: 8px; }
.betraege label { position: relative; display: grid; grid-template-columns: 4.2em 1fr; align-items: center; gap: 12px; padding: 10px 14px; border: 1.5px solid var(--linie); border-radius: 10px; cursor: pointer; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.betraege label b { font: 800 26px/1 'Big Shoulders', sans-serif; font-variant-numeric: tabular-nums; color: var(--marke); }
.betraege label span { font-size: 14px; line-height: 1.35; color: var(--leise); }
.betraege label:has(input:checked) { border-color: var(--marke); background: var(--hell); }
.betraege label:has(input:checked)::after { content: ''; position: absolute; right: 12px; top: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--marke); }
.frei input { width: 100%; font-size: 17px; padding: 11px 14px; border: 1.5px solid var(--linie); border-radius: 10px; background: var(--grund); }
.frei input:focus-visible { outline: 3px solid var(--fokus); outline-offset: 2px; border-color: var(--marke); }
.frei .fehler { color: #b3261e; font-size: 14px; margin-top: 6px; display: none; }
.frei.falsch input { border-color: #b3261e; }
.frei.falsch .fehler { display: block; }
.absenden { display: inline-flex; justify-content: center; align-items: center; gap: 10px; min-height: 52px; padding: 0 22px; border-radius: 999px; background: var(--signal); color: var(--auf-signal); border: 2px solid transparent; font-weight: 700; font-size: 17px; cursor: pointer; transition: background-color var(--ui) var(--kurve), scale var(--ui) var(--kurve); }
.absenden:hover { background: #ffd84a; }
.absenden:active { scale: .98; }
.status { font-size: 14px; line-height: 1.45; color: var(--leise); min-height: 1.45em; }
.vertrauen { display: flex; gap: 8px 14px; flex-wrap: wrap; font-size: 13px; color: var(--leise); border-top: 1px solid var(--linie); padding-top: 12px; }
.vertrauen span::before { content: '✓ '; color: var(--marke); font-weight: 700; }
/* ============ Seitenleiste (complementary) ============ */
.seite { grid-area: seite; background: var(--hell); padding: clamp(24px, 3vw, 36px) clamp(20px, 2.4vw, 32px); display: flex; flex-direction: column; gap: 20px; }
.seite h2 { font: 800 1.75rem/1 'Big Shoulders', sans-serif; text-transform: uppercase; color: var(--marke); }
.seite .ziel-zahl { font: 800 2.6rem/1 'Big Shoulders', sans-serif; color: var(--marke); }
.seite .ziel-zahl small { font: 500 15px/1 'Archivo', sans-serif; color: var(--leise); text-transform: none; }
.balken { height: 12px; border-radius: 999px; background: #fff; border: 1px solid var(--linie); overflow: hidden; margin-top: 10px; }
.balken i { display: block; height: 100%; width: 73.7%; background: var(--marke); border-radius: inherit; }
.seite p { font-size: 15px; line-height: 1.55; color: var(--leise); }
.verwendung { list-style: none; display: grid; gap: 8px; font-size: 14.5px; }
.verwendung li { display: grid; grid-template-columns: 3.2em 1fr; gap: 8px; align-items: baseline; padding-bottom: 8px; border-bottom: 1px solid var(--linie); }
.verwendung b { font: 800 22px/1 'Big Shoulders', sans-serif; color: var(--marke); font-variant-numeric: tabular-nums; }
.wege { list-style: none; display: grid; border-top: 1px solid var(--linie); }
.wege a { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid var(--linie); text-decoration: none; font-weight: 600; font-size: 15px; color: var(--marke); }
.wege a div span { font-weight: 400; font-size: 13.5px; color: var(--leise); display: block; }
.wege a:hover { text-decoration: underline; text-underline-offset: 4px; }
.person { display: flex; gap: 12px; align-items: center; margin-top: auto; }
.person svg { width: 48px; height: 48px; flex: none; }
.person p { font-size: 14px; line-height: 1.4; }
.person p b { display: block; color: var(--tinte); }
/* ============ Fuß (contentinfo) ============ */
.fuss { grid-area: fuss; background: #001d47; color: #e6f0f7; padding: 28px clamp(16px, 3vw, 40px) 32px; display: grid; grid-template-columns: 1.2fr 1fr auto; gap: 20px 40px; align-items: start; }
.fuss .display { font-size: 2rem; color: #fff; }
.fuss p { font-size: 14px; line-height: 1.55; color: var(--nacht-leise); }
.fuss nav ul { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 18px; }
.fuss nav a { font-size: 14.5px; min-height: 44px; display: inline-flex; align-items: center; text-decoration: underline; text-underline-offset: 4px; text-decoration-color: rgba(255, 255, 255, .35); }
.fuss :focus-visible { outline-color: var(--fokus-dunkel); }
/* ============ Landmarken-Karte (Erklärhilfe, aria-hidden) ============ */
.karte { position: absolute; inset: 0 auto auto 0; width: 100%; pointer-events: none; z-index: 40; opacity: 0; transition: opacity var(--ui) var(--kurve); }
.karte-an .karte { opacity: 1; }
.lm { position: absolute; border: 2px dashed var(--f); border-radius: 6px; background: color-mix(in srgb, var(--f) 6%, transparent); transition: border-width var(--ui) var(--kurve), background-color var(--ui) var(--kurve), opacity var(--ui) var(--kurve); }
.lm-etikett { position: absolute; left: 6px; top: 6px; display: inline-flex; align-items: center; gap: 7px; max-width: calc(100% - 12px); padding: 5px 9px 5px 7px; border-radius: 5px; background: #0b1424; color: #fff; font: 500 11px/1.15 'Geist Mono', monospace; letter-spacing: .04em; white-space: nowrap; box-shadow: 0 4px 14px rgba(0, 0, 0, .25); }
.lm-etikett i { width: 8px; height: 8px; border-radius: 2px; background: var(--f); flex: none; }
.lm-etikett s { text-decoration: none; color: #9fb4c8; overflow: hidden; text-overflow: ellipsis; }
.karte.rotor .lm { opacity: .35; }
.karte.rotor .lm.dran { opacity: 1; border-style: solid; border-width: 3px; background: color-mix(in srgb, var(--f) 12%, transparent); }
.karte.rotor .lm.dran .lm-etikett { background: var(--f); color: #0b1424; }
.karte.rotor .lm.dran .lm-etikett s { color: #0b1424; }
.karte.rotor .lm.dran .lm-etikett i { background: #0b1424; }
/* Tastenhinweis der Vorführung */
.taste { position: fixed; z-index: 60; right: 22px; top: 14px; display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px 8px 8px; border-radius: 10px; background: #0b1424; color: #fff; font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; opacity: 0; translate: 0 -6px; transition: opacity var(--ui) var(--kurve), translate var(--ui) var(--kurve); pointer-events: none; }
.taste kbd { font: 600 13px/1 'Geist Mono', monospace; letter-spacing: 0; text-transform: none; padding: 7px 10px; border-radius: 6px; background: #fff; color: #0b1424; box-shadow: inset 0 -2px 0 #b9c3cf; }
.taste.an { opacity: 1; translate: 0 0; }
.taste.druck kbd { box-shadow: inset 0 1px 0 #b9c3cf; translate: 0 1px; }
/* ============ Demo-Steuerung ============ */
.pult { position: fixed; z-index: 70; right: 16px; bottom: 16px; width: 300px; max-height: calc(100svh - 32px); overflow: auto; background: rgba(11, 20, 36, .94); color: #f3f2ee; border-radius: 14px; padding: 14px; display: grid; gap: 12px; box-shadow: 0 20px 50px rgba(0, 10, 30, .35); font-size: 13px; }
.pult .mono { color: rgba(243, 242, 238, .62); font-size: 10.5px; }
.pult-reihe { display: grid; gap: 6px; }
.segment { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; background: rgba(255, 255, 255, .07); padding: 3px; border-radius: 8px; }
.segment button, .pult-knopf { border: 0; background: none; color: inherit; font: 500 12px/1.2 'Archivo', sans-serif; padding: 8px 6px; border-radius: 6px; cursor: pointer; min-height: 34px; }
.segment button[aria-pressed="true"] { background: #f3f2ee; color: #0b1424; font-weight: 600; }
.pult-knopf { background: rgba(255, 255, 255, .1); text-align: left; padding: 9px 10px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.pult-knopf[aria-pressed="true"] { background: #f3f2ee; color: #0b1424; font-weight: 600; }
.pult-knopf .schalter { width: 28px; height: 16px; border-radius: 999px; background: rgba(255, 255, 255, .25); position: relative; flex: none; }
.pult-knopf .schalter::after { content: ''; position: absolute; left: 2px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: translate var(--ui) var(--kurve); }
.pult-knopf[aria-pressed="true"] .schalter { background: #0b1424; }
.pult-knopf[aria-pressed="true"] .schalter::after { translate: 12px 0; }
.pult :focus-visible { outline: 2px solid #8ec5ff; outline-offset: 2px; }
.rotor-liste { list-style: none; display: grid; gap: 2px; counter-reset: lm; }
.rotor-liste button { width: 100%; display: grid; grid-template-columns: 10px 1fr; gap: 8px; align-items: center; text-align: left; border: 0; background: none; color: inherit; padding: 5px 6px; border-radius: 5px; cursor: pointer; font: 500 11.5px/1.25 'Geist Mono', monospace; }
.rotor-liste button i { width: 8px; height: 8px; border-radius: 2px; background: var(--f); }
.rotor-liste button s { text-decoration: none; color: rgba(243, 242, 238, .6); }
.rotor-liste button:hover, .rotor-liste button.dran { background: rgba(255, 255, 255, .12); }
.werte { color: rgba(243, 242, 238, .62); font: 500 10.5px/1.5 'Geist Mono', monospace; letter-spacing: .04em; }
.werte b { color: #f3f2ee; font-weight: 500; }
/* ============ Kontrastmodus: Kanten und Fokus bleiben sichtbar ============ */
@media (forced-colors: active) {
.sprung { border-bottom-color: CanvasText; }
.sprung a:focus, .sprung a.sim-fokus { outline-color: Highlight; }
.lm { border-color: CanvasText; }
.takt-wahl::before { background: Highlight; }
.takt-wahl label:has(input:checked) { color: HighlightText; forced-color-adjust: none; }
.betraege label:has(input:checked) { border-color: Highlight; }
.suche button { border: 1px solid ButtonText; }
}
/* ============ Reduzierte Bewegung ============ */
.reduziert .sprung, .reduziert .takt-wahl::before, .reduziert .karte, .reduziert .taste { transition: none; }
/* ============ Touch ============ */
@media (hover: none) {
.hauptnav a:hover { background: none; }
.spenden-knopf:hover, .absenden:hover { background: var(--signal); }
.betraege label { padding: 12px 14px; }
}
/* ============ Schmal ============ */
@media (max-width: 1100px) {
.sprung-hilfe { display: none; }
body { grid-template-columns: minmax(0, 1fr); grid-template-areas: "kopf" "inhalt" "seite" "fuss"; }
.seite { display: grid; grid-template-columns: 1fr 1fr; align-items: start; }
.seite > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.inhalt { grid-template-columns: minmax(0, 1fr); }
.held { min-height: 0; }
.spende { border-right: 0; }
.seite { display: flex; }
.fuss { grid-template-columns: 1fr; }
.suche { order: 5; flex-basis: 100%; min-width: 0; }
.suche form { width: 100%; }
.suche input { flex: 1; width: auto; min-width: 0; }
.hauptnav { order: 4; flex-basis: 100%; }
.hauptnav ul { margin-inline: -12px; }
}
@media (max-width: 520px) {
.sprung { padding: 10px 14px; }
.sprung-titel, .sprung-hilfe { display: none; }
.sprung ul { display: grid; width: 100%; }
.sprung a { width: 100%; }
.marke-link b { font-size: 21px; }
.marke-link small { display: none; }
.marke-link svg { width: 30px; height: 30px; }
.spenden-knopf { padding: 0 16px; }
.kopf-innen { gap: 10px 8px; }
.zahlen { grid-template-columns: 1fr 1fr; }
.zahlen li:nth-child(3) { grid-column: 1 / -1; border-left: 0; padding-left: 0; border-top: 1px solid rgba(237, 245, 250, .22); padding-top: 12px; margin-top: 12px; }
.lm-etikett { font-size: 10px; }
.hauptnav a { padding: 0 10px; font-size: 14.5px; }
}
/* ============ Vorschau 960 × 720 ============ */
.vorschau body { overflow: hidden; grid-template-columns: minmax(0, 1fr) 270px; grid-template-areas: "kopf kopf" "inhalt seite" "fuss fuss"; }
.vorschau .held { min-height: 0; padding-top: 28px; gap: 12px; }
.vorschau .held h1 { font-size: 3.4rem; }
.vorschau .stadt svg { height: 120px; }
.vorschau .zahlen b { font-size: 2.4rem; }
.vorschau .seite > div:nth-child(3), .vorschau .takt legend { display: none; }
.vorschau .held p.lead, .vorschau .held .quelle, .vorschau .spende .unter, .vorschau .vertrauen, .vorschau .seite p, .vorschau .person, .vorschau .sprache { display: none; }
.vorschau .spende { padding: 22px; gap: 12px; }
.vorschau .betraege label { padding: 7px 12px; }
.vorschau .betraege label span { font-size: 12.5px; }
.vorschau .frei { display: none; }
.vorschau .seite { display: flex; padding: 22px 18px; gap: 14px; }
.vorschau .inhalt { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
.vorschau .suche input { width: 8ch; }
.vorschau .hauptnav a { padding: 0 9px; font-size: 14px; }
.vorschau .stadt { margin-top: 4px; }
</style>
</head>
<body data-variante="menue">
<!-- 1. Sprunglinks: erstes fokussierbares Element, nicht in nav oder header -->
<div class="sprung" data-sprung>
<span class="sprung-titel mono" aria-hidden="true">Direkt springen</span>
<ul>
<li><a href="#inhalt" data-ziel="inhalt"><span class="nr" aria-hidden="true">1</span>Zum Inhalt <span class="pfeil" aria-hidden="true">↓</span></a></li>
<li><a href="#suchfeld" data-ziel="suchfeld"><span class="nr" aria-hidden="true">2</span>Zur Suche <span class="pfeil" aria-hidden="true">↓</span></a></li>
<li><a href="#spenden" data-ziel="spenden"><span class="nr" aria-hidden="true">3</span>Zum Spendenformular <span class="pfeil" aria-hidden="true">↓</span></a></li>
</ul>
<p class="sprung-hilfe" aria-hidden="true"><kbd>Tab</kbd> weiter · <kbd>Enter</kbd> springen</p>
</div>
<header class="kopf" data-lm>
<div class="kopf-innen">
<a class="marke-link" href="#" aria-label="Spreewacht e. V., Startseite">
<svg viewBox="0 0 34 34" aria-hidden="true"><circle cx="17" cy="17" r="17" fill="#002e6d"/><path d="M6 19c3.5-3 7.5-3 11 0s7.5 3 11 0" fill="none" stroke="#edf5fa" stroke-width="2.4" stroke-linecap="round"/><path d="M6 24.5c3.5-3 7.5-3 11 0s7.5 3 11 0" fill="none" stroke="#7fb4dc" stroke-width="2.4" stroke-linecap="round"/><path d="M17 7.5v8.2M13.6 11.2 17 7.5l3.4 3.7" fill="none" stroke="#ffca0a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span><b>Spreewacht</b><small>e. V. · Bautzen</small></span>
</a>
<nav class="hauptnav" aria-label="Hauptnavigation" data-lm>
<ul>
<li><a href="#inhalt" aria-current="page">Projekte</a></li>
<li><a href="#seite">Mitmachen</a></li>
<li><a href="#seite">Transparenz</a></li>
<li><a href="#fuss">Über uns</a></li>
</ul>
</nav>
<search class="suche" aria-label="Website" data-lm>
<form action="#" data-suche>
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="5.2" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m11 11 3.6 3.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<label for="suchfeld" class="sr">Website durchsuchen</label>
<input id="suchfeld" type="search" name="q" placeholder="Einsätze, Berichte" autocomplete="off">
<button type="submit">Suchen</button>
</form>
</search>
<a class="sprache" href="#inhalt" lang="hsb" hreflang="hsb">Serbsce</a>
<a class="spenden-knopf" href="#spenden">Spenden</a>
</div>
</header>
<main class="inhalt" id="inhalt" tabindex="-1" data-lm>
<section class="held" aria-labelledby="mission">
<p class="mono">Gemeinnütziger Verein seit 2011</p>
<h1 id="mission" class="display">Saubere Spree, von der Quelle <em>bis Bautzen.</em></h1>
<p class="lead">Wir holen Müll aus Fluss und Ufern, pflanzen Weiden gegen Abbrüche und betreuen 42 Uferabschnitte mit Patinnen und Paten aus der Oberlausitz.</p>
<ul class="zahlen ziffern">
<li><b>11,4 t</b><span>Müll geborgen</span></li>
<li><b>386</b><span>Freiwillige im Einsatz</span></li>
<li><b>42</b><span>Uferpatenschaften</span></li>
</ul>
<p class="quelle">Stand: Jahresbericht 2025</p>
<figure class="stadt" aria-hidden="true">
<svg viewBox="0 0 800 240" preserveAspectRatio="xMidYMax slice">
<defs>
<linearGradient id="fluss" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0d4a94"/><stop offset="1" stop-color="#001d47"/></linearGradient>
</defs>
<!-- Silhouette: Wasserkunst, Dom, Reichenturm, Burg über der Spree (frei nachempfunden) -->
<g fill="#0b3a7a">
<path d="M0 150 L0 112 L40 108 L70 96 L110 100 L118 88 L150 92 L162 80 L210 84 L240 98 L300 94 L330 104 L380 100 L420 108 L470 102 L540 110 L600 96 L660 104 L720 98 L800 106 L800 150 Z"/>
</g>
<g fill="#124c95">
<!-- Alte Wasserkunst -->
<rect x="118" y="60" width="38" height="92"/><path d="M114 62 L137 22 L160 62 Z"/><rect x="133" y="10" width="2" height="14"/>
<rect x="127" y="78" width="6" height="11" fill="#0b3a7a"/><rect x="141" y="78" width="6" height="11" fill="#0b3a7a"/><rect x="127" y="104" width="6" height="11" fill="#0b3a7a"/><rect x="141" y="104" width="6" height="11" fill="#0b3a7a"/>
<!-- Kirche mit Turm -->
<path d="M168 150 L168 96 L214 74 L260 96 L260 150 Z"/><rect x="228" y="48" width="20" height="60"/><path d="M224 50 L238 18 L252 50 Z"/>
<!-- Dom mit hohem Turm -->
<rect x="330" y="86" width="96" height="64"/><path d="M326 88 L378 62 L430 88 Z"/><rect x="398" y="30" width="22" height="80"/><path d="M394 32 L409 0 L424 32 Z"/>
<!-- Reichenturm, leicht geneigt -->
<g transform="rotate(1.6 520 150)"><rect x="508" y="52" width="26" height="98"/><path d="M504 54 L521 30 L538 54 Z"/><rect x="514" y="70" width="6" height="9" fill="#0b3a7a"/></g>
<!-- Burg -->
<path d="M600 150 L600 98 L612 98 L612 90 L624 90 L624 98 L652 98 L652 90 L664 90 L664 98 L700 98 L700 150 Z"/><rect x="666" y="64" width="24" height="40"/><path d="M662 66 L678 44 L694 66 Z"/>
</g>
<!-- Brücke -->
<path d="M0 150 L800 150 L800 168 L0 168 Z" fill="#0b3a7a"/>
<g fill="#002e6d"><path d="M40 168 Q100 132 160 168 Z"/><path d="M200 168 Q260 132 320 168 Z"/><path d="M360 168 Q420 132 480 168 Z"/><path d="M520 168 Q580 132 640 168 Z"/><path d="M680 168 Q740 132 800 168 Z"/></g>
<rect x="0" y="168" width="800" height="72" fill="url(#fluss)"/>
<g fill="none" stroke="#7fb4dc" stroke-width="2" stroke-linecap="round" opacity=".55">
<path class="welle" d="M-40 186 q20 -6 40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0"/>
<path class="welle welle-2" d="M-20 206 q20 -6 40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0" opacity=".6"/>
</g>
<!-- Sammelboot -->
<g transform="translate(470 182)"><path d="M0 0 L64 0 L56 12 L8 12 Z" fill="#edf5fa"/><rect x="22" y="-14" width="18" height="14" fill="#edf5fa"/><rect x="26" y="-10" width="10" height="6" fill="#002e6d"/><path d="M48 -2 L48 -22 L60 -8" fill="none" stroke="#ffca0a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></g>
</svg>
</figure>
</section>
<form class="spende" id="spenden" tabindex="-1" aria-labelledby="spenden-titel" data-lm data-form novalidate>
<h2 id="spenden-titel">Jetzt spenden</h2>
<p class="unter">Jeder Betrag fließt in Einsätze an der Spree. Spendenquittung kommt automatisch.</p>
<fieldset class="takt">
<legend>Wie oft?</legend>
<div class="takt-wahl">
<label><input type="radio" name="takt" value="einmalig" checked>Einmalig</label>
<label><input type="radio" name="takt" value="monatlich">Monatlich</label>
</div>
</fieldset>
<fieldset class="betraege">
<legend>Betrag und Wirkung</legend>
<div class="betraege-liste">
<label><input type="radio" name="betrag" value="15"><b class="ziffern">15 €</b><span>Handschuhe und Greifzange für eine neue Freiwillige</span></label>
<label><input type="radio" name="betrag" value="30" checked><b class="ziffern">30 €</b><span>ein Sammelnetz für einen Uferabschnitt</span></label>
<label><input type="radio" name="betrag" value="60"><b class="ziffern">60 €</b><span>Abfuhr von 200 kg Müll vom Sammelplatz</span></label>
</div>
</fieldset>
<div class="frei" data-frei>
<label for="eigener">Eigener Betrag in Euro <span style="font-weight:400;color:var(--leise)">(optional, ab 5 €)</span></label>
<input id="eigener" name="eigener" inputmode="decimal" autocomplete="off" aria-describedby="eigener-fehler" placeholder="z. B. 45">
<p class="fehler" id="eigener-fehler">Bitte einen Betrag ab 5 € eintragen, nur Ziffern.</p>
</div>
<button class="absenden" type="submit" data-absenden>30 € spenden</button>
<p class="status" role="status" data-status></p>
<p class="vertrauen"><span>Gemeinnützig anerkannt</span><span>Sichere Zahlung</span><span>Jederzeit kündbar</span></p>
</form>
</main>
<aside class="seite" id="seite" aria-labelledby="ziel-titel" data-lm>
<div>
<h2 id="ziel-titel">Spendenziel 2026</h2>
<p class="ziel-zahl ziffern">18.420 € <small>von 25.000 €</small></p>
<div class="balken" aria-hidden="true"><i></i></div>
<p style="margin-top:10px">Für zwei neue Sammelboote. Noch 6.580 €.</p>
</div>
<div>
<p class="mono" style="color:var(--leise);margin-bottom:8px">Wohin das Geld geht</p>
<ul class="verwendung ziffern">
<li><b>82 %</b><span>Einsätze und Projekte</span></li>
<li><b>11 %</b><span>Material und Boote</span></li>
<li><b>7 %</b><span>Verwaltung</span></li>
</ul>
</div>
<div>
<p class="mono" style="color:var(--leise);margin-bottom:8px">Andere Wege zu helfen</p>
<ul class="wege">
<li><a href="#seite"><div>Uferpatenschaft übernehmen<span>Ein Abschnitt, viermal im Jahr</span></div><span aria-hidden="true">→</span></a></li>
<li><a href="#seite"><div>Beim Frühjahrsputz mitmachen<span class="ziffern">Samstag, 17. April 2027</span></div><span aria-hidden="true">→</span></a></li>
<li><a href="#seite"><div>Eigene Spendenaktion starten<span>Geburtstag, Lauf, Firma</span></div><span aria-hidden="true">→</span></a></li>
</ul>
</div>
<div class="person">
<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="24" fill="#dbebf5"/><circle cx="24" cy="19" r="8" fill="#0a3f86"/><path d="M9 41c3-8 9-11 15-11s12 3 15 11" fill="#0a3f86"/></svg>
<p><b>Lenka Rychter</b>Kassenwartin, beantwortet Fragen zur Spende</p>
</div>
</aside>
<footer class="fuss" id="fuss" data-lm>
<div>
<p class="display">Spreewacht e. V.</p>
<p>An der Spree · 02625 Bautzen · eingetragener, gemeinnütziger Verein</p>
</div>
<p>Spenden per Überweisung: Bankverbindung und Freistellungsbescheid unter Transparenz.</p>
<nav aria-label="Rechtliches" data-lm>
<ul>
<li><a href="#fuss">Impressum</a></li>
<li><a href="#fuss">Datenschutz</a></li>
<li><a href="#fuss">Satzung</a></li>
<li><a href="#fuss">Barrierefreiheit</a></li>
</ul>
</nav>
</footer>
<div class="karte" data-karte aria-hidden="true"></div>
<div class="taste" data-taste aria-hidden="true"><kbd>Tab</kbd><span>gedrückt</span></div>
<section class="pult" aria-label="Demo-Steuerung">
<div class="pult-reihe">
<p class="mono">Sprunglinks</p>
<div class="segment" role="group" aria-label="Variante">
<button type="button" data-v="menue" aria-pressed="true">Drei Ziele</button>
<button type="button" data-v="einzeln" aria-pressed="false">Nur „Zum Inhalt“</button>
</div>
<button type="button" class="pult-knopf" data-tab>Sprunglink zeigen (wie Tab) <span aria-hidden="true">⇥</span></button>
</div>
<div class="pult-reihe">
<button type="button" class="pult-knopf" aria-pressed="false" data-karte-knopf>Landmarken-Karte <span class="schalter" aria-hidden="true"></span></button>
<p class="mono" data-rotor-titel>Landmarken auf der Seite</p>
<ol class="rotor-liste" data-rotor></ol>
</div>
<p class="werte" data-werte></p>
</section>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const body = document.body;
const sprung = document.querySelector('[data-sprung]');
const links = [...sprung.querySelectorAll('a')];
const karte = document.querySelector('[data-karte]');
const karteKnopf = document.querySelector('[data-karte-knopf]');
const rotor = document.querySelector('[data-rotor]');
const taste = document.querySelector('[data-taste]');
const werte = document.querySelector('[data-werte]');
const schalter = [...document.querySelectorAll('[data-v]')];
const VARIANTEN = ['menue', 'einzeln'];
/* ---------- Landmarken aus dem DOM lesen (Rolle und Name wie im Accessibility-Tree) ---------- */
const FARBE = { banner: '#ff8a5b', navigation: '#5ec2ff', search: '#c79bff', main: '#3ddc97', form: '#ffd166', complementary: '#ff7eb6', contentinfo: '#ff8a5b', region: '#a0e7e5' };
function rolle(el) {
const t = el.tagName.toLowerCase();
const r = el.getAttribute('role');
if (r) return r;
const inBereich = el.parentElement && el.parentElement.closest('main, article, aside, nav, section');
if (t === 'header') return inBereich ? null : 'banner';
if (t === 'footer') return inBereich ? null : 'contentinfo';
if (t === 'nav') return 'navigation';
if (t === 'main') return 'main';
if (t === 'aside') return 'complementary';
if (t === 'search') return 'search';
if (t === 'form') return name(el) ? 'form' : null;
if (t === 'section') return name(el) ? 'region' : null;
return null;
}
function name(el) {
if (el.hasAttribute('aria-label')) return el.getAttribute('aria-label');
const ids = el.getAttribute('aria-labelledby');
if (ids) return ids.split(/\s+/).map(id => (document.getElementById(id) || {}).textContent || '').join(' ').trim();
return '';
}
const landmarken = [...document.querySelectorAll('[data-lm]')].map(el => ({ el, rolle: rolle(el), name: name(el) })).filter(l => l.rolle);
// Reihenfolge wie im Dokument (so listet sie auch ein Screenreader)
landmarken.sort((a, b) => a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
landmarken.forEach((l, i) => {
const box = document.createElement('div');
box.className = 'lm'; box.style.setProperty('--f', FARBE[l.rolle] || '#fff');
box.innerHTML = `<span class="lm-etikett"><i></i>${l.rolle}${l.name ? ` <s>· ${l.name}</s>` : ''}</span>`;
karte.appendChild(box); l.box = box;
const li = document.createElement('li');
li.innerHTML = `<button type="button" style="--f:${FARBE[l.rolle]}"><i aria-hidden="true"></i><span>${l.rolle}${l.name ? ` <s>· ${l.name}</s>` : ''}</span></button>`;
li.querySelector('button').addEventListener('click', () => { if (!body.classList.contains('karte-an')) karteSetzen(true); zeige(i, true); });
rotor.appendChild(li); l.eintrag = li.querySelector('button');
});
document.querySelector('[data-rotor-titel]').textContent = `Landmarken auf der Seite (${landmarken.length})`;
function lage() {
if (!body.classList.contains('karte-an')) return;
const tief = new Map();
landmarken.forEach(l => {
const r = l.el.getBoundingClientRect();
let d = 0; landmarken.forEach(m => { if (m !== l && m.el.contains(l.el)) d++; });
const inset = d * 5 + 3;
const fest = getComputedStyle(l.el).position === 'sticky' || getComputedStyle(l.el).position === 'fixed';
Object.assign(l.box.style, { left: (r.left + scrollX + inset) + 'px', top: (r.top + scrollY + inset) + 'px', width: Math.max(0, r.width - inset * 2) + 'px', height: Math.max(0, r.height - inset * 2) + 'px' });
tief.set(l, d);
});
karte.style.height = document.documentElement.scrollHeight + 'px';
}
function karteSetzen(an) {
body.classList.toggle('karte-an', an);
karteKnopf.setAttribute('aria-pressed', String(an));
if (an) lage(); else { karte.classList.remove('rotor'); landmarken.forEach(l => { l.box.classList.remove('dran'); l.eintrag.classList.remove('dran'); }); }
}
function zeige(i, scrollen) {
karte.classList.add('rotor');
landmarken.forEach((l, j) => { l.box.classList.toggle('dran', i === j); l.eintrag.classList.toggle('dran', i === j); });
if (scrollen && !html.classList.contains('vorschau')) {
const r = landmarken[i].el.getBoundingClientRect();
if (r.top < 80 || r.bottom > innerHeight) landmarken[i].el.scrollIntoView({ block: 'nearest', behavior: reduziert() ? 'auto' : 'smooth' });
}
}
karteKnopf.addEventListener('click', () => { stopp(); karteSetzen(!body.classList.contains('karte-an')); });
addEventListener('resize', lage); addEventListener('scroll', lage, { passive: true });
new ResizeObserver(lage).observe(body);
document.fonts && document.fonts.ready.then(lage);
/* ---------- Sprunglinks: nativer Anker, Fokus landet im Ziel ---------- */
// Kein Script nötig: href="#inhalt" + tabindex="-1" am Ziel. Das Script sorgt nur dafür,
// dass auch Browser, die den Fokus nicht verschieben, ihn verschieben (wie GOV.UK seit v4).
links.forEach(a => a.addEventListener('click', () => {
const ziel = document.getElementById(a.dataset.ziel);
if (ziel) requestAnimationFrame(() => ziel.focus({ preventScroll: false }));
}));
/* ---------- Spendenformular: echtes Verhalten ---------- */
const form = document.querySelector('[data-form]');
const absenden = document.querySelector('[data-absenden]');
const status = document.querySelector('[data-status]');
const frei = document.querySelector('[data-frei]');
const eigener = document.getElementById('eigener');
function betrag() {
const e = eigener.value.trim().replace(',', '.');
if (e) return /^\d+(\.\d{1,2})?$/.test(e) && +e >= 5 ? +e : null;
return +form.betrag.value;
}
function knopfText() {
const b = betrag(), m = form.takt.value === 'monatlich';
absenden.textContent = b ? `${b.toLocaleString('de-DE')} €${m ? ' monatlich' : ''} spenden` : 'Betrag prüfen';
}
form.addEventListener('input', e => {
if (e.target === eigener && eigener.value) form.querySelectorAll('[name="betrag"]').forEach(r => r.checked = false);
if (e.target.name === 'betrag') { eigener.value = ''; frei.classList.remove('falsch'); eigener.removeAttribute('aria-invalid'); }
knopfText();
});
eigener.addEventListener('blur', () => {
const falsch = eigener.value.trim() !== '' && betrag() === null;
frei.classList.toggle('falsch', falsch);
falsch ? eigener.setAttribute('aria-invalid', 'true') : eigener.removeAttribute('aria-invalid');
if (!eigener.value.trim() && !form.betrag.value) { form.querySelector('[value="30"]').checked = true; knopfText(); }
});
form.addEventListener('submit', e => {
e.preventDefault();
const b = betrag();
if (!b) { frei.classList.add('falsch'); eigener.setAttribute('aria-invalid', 'true'); status.textContent = 'Der eigene Betrag braucht noch eine Korrektur.'; eigener.focus(); return; }
status.textContent = `Danke! Auf der echten Seite geht es jetzt mit ${b.toLocaleString('de-DE')} € zum Zahlungsanbieter.`;
});
document.querySelector('[data-suche]').addEventListener('submit', e => { e.preventDefault(); });
/* ---------- Varianten ---------- */
function werteZeigen(v) {
werte.innerHTML = `Kurve <b>cubic-bezier(0.645, 0.045, 0.355, 1)</b> · <b>200 ms</b> · ${v === 'einzeln' ? 'Pille <b>translateY −100 % − 24 px → 0</b>' : 'Leiste <b>translateY −120 % → 0</b>'} · Ziel <b>scroll-margin-top 88 px</b> · Fokusrahmen <b>3 px</b>, Versatz 3 px`;
}
function variante(v, url = true) {
if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
body.dataset.variante = v;
schalter.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
werteZeigen(v);
if (url) {
const q = new URLSearchParams(location.search);
q.set('v', v);
history.replaceState(null, '', '?' + q.toString() + location.hash);
}
}
schalter.forEach(b => b.addEventListener('click', () => { stopp(); variante(b.dataset.v); }));
variante(new URLSearchParams(location.search).get('v') || VARIANTEN[0], false);
document.querySelector('[data-tab]').addEventListener('click', () => { stopp(); links[0].focus(); });
/* ---------- Vorführung: Tab, Tab, Tab, Enter, dann Landmarken der Reihe nach ---------- */
let zeit = [], laeuft = false;
const nach = (ms, f) => zeit.push(setTimeout(f, ms));
function tasteZeigen(t, druck) {
taste.querySelector('kbd').textContent = t;
taste.querySelector('span').textContent = t === 'Tab' ? 'nächstes Ziel' : t === 'Enter' ? 'springen' : 'Landmarke';
taste.classList.add('an'); taste.classList.toggle('druck', !!druck);
if (druck) nach(160, () => taste.classList.remove('druck'));
}
function simFokus(i) { links.forEach((a, j) => a.classList.toggle('sim-fokus', i === j)); sprung.classList.toggle('sim-offen', i >= 0); }
function aufraeumen() {
simFokus(-1); taste.classList.remove('an');
document.querySelectorAll('.ziel-sim').forEach(e => e.classList.remove('ziel-sim'));
karteSetzen(false);
}
function runde() {
aufraeumen();
const einzeln = body.dataset.variante === 'einzeln';
const ziele = einzeln ? 1 : 3;
nach(250, () => karteSetzen(true));
let t = 700;
for (let i = 0; i < ziele; i++) { nach(t, () => { tasteZeigen('Tab', true); simFokus(i); }); t += 1100; }
nach(t, () => tasteZeigen('Enter', true));
nach(t + 260, () => {
const zielEl = document.getElementById(einzeln ? 'inhalt' : 'spenden');
simFokus(-1);
zielEl.classList.remove('ziel-sim'); void zielEl.offsetWidth; zielEl.classList.add('ziel-sim');
});
t += 1900;
nach(t, () => taste.classList.remove('an'));
t += 300;
landmarken.forEach((l, i) => { nach(t, () => { tasteZeigen('D', true); zeige(i, false); }); t += 750; });
nach(t + 400, () => { karteSetzen(false); taste.classList.remove('an'); document.querySelectorAll('.ziel-sim').forEach(e => e.classList.remove('ziel-sim')); });
nach(t + 1600, runde);
}
function stopp() { zeit.forEach(clearTimeout); zeit = []; if (laeuft) { laeuft = false; html.classList.remove('vorfuehrung'); aufraeumen(); } }
function neustart() { stopp(); aufraeumen(); scrollTo(0, 0); }
function vorfuehren() { neustart(); html.classList.add('vorfuehrung'); laeuft = true; runde(); }
function pause() { zeit.forEach(clearTimeout); zeit = []; }
function weiter() { if (html.classList.contains('vorfuehrung')) { laeuft = true; runde(); } else vorfuehren(); }
})();
</script>
</body>
</html>