Mobile Bottom-Navigation und Bottom-Sheet-Menü
Die Leiste in der Daumenzone wächst selbst zum Sheet, per Wisch am Griff geht sie wieder zu.
Portfolio-, Hotel- und Gastro-Seiten mit 4 bis 7 Bereichen: Leiste in der Daumenzone wird zum Sheet; Unterumschalter Leiste, Tab-Pille, Drill-down.
Auf dem Telefon sitzt die Navigation unten in der Daumenzone. Entweder als schwebende Pille mit drei bis fünf Zielen oder als Leiste mit Marke und „Menü“-Knopf. Ein Tipp auf „Menü“ lässt genau diese Leiste nach oben zum Sheet wachsen. Die Fläche bleibt dieselbe, die Ecken bleiben rund, die Links stehen groß und linksbündig darin. Am Griff lässt sich das Sheet nach unten wegwischen. Beim Scrollen nach unten kann die Leiste schrumpfen.
Die Ziele liegen dort, wo der Daumen auf großen Telefonen ohnehin ist. Das Muster kennt jeder aus Apps. Weil die Leiste selbst zum Sheet wird, bleibt klar, woher das Menü kommt (Objektkonstanz).
Wann einsetzen
Bei überwiegend mobilen Besuchern und wenigen Zielen: Shops, Gastronomie, Events, Portfolios von Fotografen oder Studios.
Wann nicht
Wenn die Leiste wichtige Inhalte, Cookie-Banner oder Chat-Widgets unten verdeckt. Auf dem Desktop gehört die Navigation nach oben. Den Daumen-Knopf nie als Nach-oben-Knopf und nie zusätzlich zur vollen Leiste.
Varianten
- Leiste wächst zum Sheet (Expanding Bottom Navigation, Osmo-Ressource)
- Schwebende Pille mit Icons und Labels, „Mehr“ öffnet ein Sheet
- Sheet mit Drill-down für Unterebenen
- Mobiles Mega-Menü als Sheet
- Daumen-Knopf (FAB) öffnet ein Aktionsmenü mit Termin, Anrufen, WhatsApp und Anfahrt (Prinzip M3 Expressive FAB menu)
- Kontaktleiste für lokale Betriebe: Anrufen mit sichtbarer Nummer, WhatsApp oder Termin, Anfrage; weicht Consent-Banner, Tastatur und Footer
Umsetzung
Leiste mit position: fixed; bottom: calc(12px + env(safe-area-inset-bottom)) und viewport-fit=cover im Viewport-Meta. Das Sheet liegt in Zielgröße bereit, clip-path zeigt im Ruhezustand nur die Leiste: inset(calc(100% - 56px) 14px 0 14px round 28px) → inset(0 round 28px). Alternativ GSAP Flip. Wischen mit Pointer Events (pointerdown/move/up) und setPointerCapture, Schwelle 80 px oder 0,5 px/ms; touch-action: none nur auf dem Griff. Beim Loslassen Inline-Transition und -Transform im selben Schritt entfernen, dann läuft der Übergang vom aktuellen Wischweg aus. Hintergrund sperren (overflow: hidden am Root, inert auf dem Inhalt), im Sheet overscroll-behavior: contain. Höhen in svh/dvh, weil die iOS-Adressleiste die Viewporthöhe ändert.
Robust umsetzen
Telefon. Ist die Mobile-Lösung. Auf dem Desktop als obere Navigation ausgeben, etwa als Pill-Navigation oder in den Ecken.
Ohne Bewegung. Kein Morph: Die Leiste bleibt stehen, das Sheet blendet in 150 ms an seiner Zielposition ein. Keine Staffelung der Links.
Barrierefrei. Wischen ist nur eine Zusatzgeste, ein sichtbarer Schließen-Knopf ist Pflicht. Der Knopf trägt aria-expanded und aria-controls. Im offenen Sheet bleibt der Fokus gefangen, Esc schließt und gibt den Fokus an den Knopf zurück. Der Seiteninhalt ist solange inert. Links stehen in einem <nav> mit aria-label, Trefferflächen mindestens 44 bis 48 px. Daumen-Knopf: Menü-Semantik (aria-haspopup="menu", role="menu"/"menuitem"), Pfeiltasten, Esc gibt den Fokus zurück. Kontaktleiste: <nav aria-label="Kontakt">, Ziele mindestens 44 px, scroll-padding-bottom in Leistenhöhe, damit der Tastaturfokus nie unter der Leiste liegt (WCAG 2.4.11); ausgeblendet ist die Leiste inert.
Code
<!DOCTYPE html>
<!--
Mobile Bottom-Navigation und Bottom-Sheet-Menü
Fundus · Navigation · https://cecodesign.de/lab/fundus/navigation/mobile-bottom-navigation/
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, viewport-fit=cover">
<title>Mobile Bottom-Navigation und Bottom-Sheet-Menü · 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: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; 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; }
@font-face { font-family: 'Kalnia'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/kalnia:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 700; font-stretch: 100% 125%; font-display: swap; }
@font-face { font-family: 'Familjen Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/familjen-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
:root {
--grund: #f4f4f2; --text: #141414; --leise: #6e6e6e; --platz: #e6e6e6; --linie: rgba(20, 20, 20, .12);
--dunkel: #141414; --hell: #f2f2f2; --hell-leise: rgba(242, 242, 242, .56);
--kurve: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-Sheet-Kurve aus den Messwerten */
--blende: cubic-bezier(0.455, 0.03, 0.515, 0.955); /* Bildwechsel: easeInOutQuad wie bei Nadav Kander */
--auf: 450ms; --zu: 300ms; --stagger: 35ms; --ui: 150ms;
--s: 1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
.mono { font: 400 12px/1.45 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; color: var(--leise); }
.serif { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; line-height: 1.1; }
.label { font: 500 10.5px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
:focus-visible { outline: 2px solid #4b7bd8; outline-offset: 3px; }
/* ---------- Rahmen (Desktop): links Marke, Mitte Telefon, rechts Steuerung ---------- */
.rahmen { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: clamp(20px, 3vw, 56px); min-height: 100svh; padding: clamp(20px, 3vh, 32px) clamp(20px, 3vw, 44px); }
.spalte { display: flex; flex-direction: column; justify-content: space-between; gap: 24px; min-width: 0; }
.spalte.rechts { align-items: flex-end; text-align: right; }
.marke-gross { font-size: 26px; }
.spalte p + p { margin-top: 4px; }
.hinweis { max-width: 26ch; font-size: 14px; line-height: 1.5; color: #555; margin-top: 10px; }
.buehne { display: grid; place-items: center; }
.umschalter { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--linie); border-radius: 999px; }
.umschalter button { font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); white-space: nowrap; padding: 0 12px; height: 32px; border-radius: 999px; transition: background-color var(--ui) linear, color var(--ui) linear; }
.umschalter button[aria-pressed="true"] { background: var(--dunkel); color: var(--hell); }
.umschalter[hidden] { display: none !important; }
.werte { margin-top: 14px; display: grid; grid-template-columns: auto auto; gap: 3px 14px; justify-content: end; }
.werte dt { font: 500 10.5px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.werte div { display: contents; }
.werte dd { font: 400 11.5px/1.5 'Geist Mono', ui-monospace, monospace; color: var(--text); text-align: left; white-space: nowrap; }
/* ---------- Telefon ---------- */
.huelle { width: calc(389px * var(--s)); height: calc(794px * var(--s)); }
.telefon {
position: relative; width: 389px; height: 794px; transform: scale(var(--s)); transform-origin: 0 0;
border: 7px solid #141414; border-radius: 52px; overflow: hidden; background: var(--grund);
box-shadow: 0 40px 80px -30px rgba(20, 20, 20, .35), 0 0 0 1px rgba(20, 20, 20, .08);
isolation: isolate;
}
.status { position: absolute; z-index: 30; inset: 0 0 auto 0; height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 30px 0 34px; font-size: 14px; font-weight: 600; pointer-events: none; }
.status::after { content: ''; position: absolute; left: 50%; top: 11px; width: 108px; height: 30px; margin-left: -54px; border-radius: 20px; background: #141414; }
.status svg { width: 62px; height: 12px; }
.scroller { position: absolute; inset: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; padding-top: 48px; }
.scroller::-webkit-scrollbar { display: none; }
.offen .scroller { overflow: hidden; }
/* Seiteninhalt der Fotografin */
.kopf { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 18px 18px; }
.kopf .serif { font-size: 21px; }
.ansichten { position: relative; }
.ansicht { position: absolute; inset: 0 0 auto 0; padding: 0 18px 112px; opacity: 0; visibility: hidden; transition: opacity 600ms var(--blende), visibility 0s 600ms; }
.ansicht.an { position: relative; opacity: 1; visibility: visible; transition: opacity 600ms var(--blende); }
.titelzeile { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 14px; }
.titelzeile h1, .titelzeile h2 { font-size: 34px; }
.dia { position: relative; aspect-ratio: 4 / 5; background: var(--platz); overflow: hidden; cursor: pointer; }
.dia img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 600ms var(--blende); }
.dia img.an { opacity: 1; }
.unterzeile { display: grid; grid-template-columns: 4.6em 1fr; gap: 10px; margin-top: 10px; }
.unterzeile [data-bildtext] { transition: opacity 300ms var(--blende); }
.text { font-size: 15px; line-height: 1.55; color: #2a2a2a; margin-top: 22px; max-width: 34ch; }
.abschnitt { margin-top: 44px; padding-top: 14px; border-top: 1px solid var(--linie); }
.paar { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
.paar figure { margin: 0; }
.paar img { aspect-ratio: 4 / 5; object-fit: cover; width: 100%; background: var(--platz); }
.paar figcaption { margin-top: 8px; }
.paar .serif { font-size: 19px; display: block; }
.fuss { margin-top: 44px; display: flex; justify-content: space-between; }
.serienliste { list-style: none; border-top: 1px solid var(--linie); }
.serienliste li { display: grid; grid-template-columns: 64px 1fr auto; gap: 14px; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--linie); }
.serienliste img { width: 64px; aspect-ratio: 4 / 5; object-fit: cover; background: var(--platz); }
.serienliste .serif { font-size: 24px; }
.film { position: relative; aspect-ratio: 16 / 9; background: #1b2028; overflow: hidden; }
.film img { width: 100%; height: 100%; object-fit: cover; }
.film span { position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; margin: -28px 0 0 -28px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, .7); display: grid; place-items: center; }
.film span::before { content: ''; border-left: 13px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; margin-left: 4px; }
.kontakt { margin-top: 28px; display: grid; gap: 4px; font-size: 15px; }
/* ---------- Bottom-Sheet: eine Fläche in Zielgröße, clip-path zeigt im Ruhezustand nur die Leiste ---------- */
.schleier { position: absolute; inset: 0; z-index: 18; background: rgba(20, 20, 20, .34); opacity: 0; pointer-events: none; transition: opacity var(--zu) var(--kurve); }
.offen .schleier { opacity: 1; pointer-events: auto; transition-duration: var(--auf); }
.sheet {
position: absolute; z-index: 20; left: 10px; right: 10px; bottom: calc(12px + env(safe-area-inset-bottom));
height: min(420px, calc(100% - 96px)); color: var(--hell);
clip-path: inset(calc(100% - 56px) 14px 0 14px round 28px);
transition: clip-path var(--zu) var(--kurve), transform var(--zu) var(--kurve);
will-change: clip-path;
}
.klein:not(.offen) .sheet { clip-path: inset(calc(100% - 48px) 44px 0 44px round 24px); }
.offen .sheet { clip-path: inset(0 0 0 0 round 28px); transition-duration: var(--auf); }
.flaeche { position: absolute; inset: 0; background: var(--dunkel); }
.leiste {
position: absolute; left: 14px; right: 14px; bottom: 0; height: 56px; z-index: 2;
display: flex; align-items: center; justify-content: space-between; padding: 0 6px 0 22px;
background: var(--dunkel); border-radius: 28px;
transition: left var(--zu) var(--kurve), right var(--zu) var(--kurve), height var(--zu) var(--kurve), padding var(--zu) var(--kurve);
}
.offen .leiste { left: 0; right: 0; transition-duration: var(--auf); }
.klein:not(.offen) .leiste { left: 44px; right: 44px; height: 48px; padding-left: 18px; }
.leiste .serif { font-size: 19px; color: var(--hell); }
.knopf { position: relative; width: 108px; height: 44px; border-radius: 22px; background: rgba(242, 242, 242, .1); font-size: 14px; font-weight: 500; letter-spacing: .01em; display: flex; align-items: center; justify-content: center; gap: 8px; }
.klein:not(.offen) .knopf { height: 38px; }
.knopf .kreuz { position: relative; width: 11px; height: 11px; transition: rotate var(--zu) var(--kurve); }
.offen .knopf .kreuz { rotate: 45deg; transition-duration: var(--auf); }
.kreuz::before, .kreuz::after { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1.5px; margin-top: -.75px; background: currentColor; border-radius: 1px; }
.kreuz::after { rotate: 90deg; }
.knopf .w { display: grid; }
.knopf .w > span { grid-area: 1 / 1; transition: opacity var(--ui) linear; }
.knopf .w > span:last-child, .offen .knopf .w > span:first-child { opacity: 0; }
.offen .knopf .w > span:last-child { opacity: 1; }
.innen { position: absolute; inset: 0 0 56px 0; display: flex; flex-direction: column; }
/* zu: Menü nur per clip-path verdeckt; zusätzlich unsichtbar (nach dem Schließen), damit Fokus und Vorlesen es nicht finden */
.innen { visibility: hidden; transition: visibility 0s linear var(--zu); }
.offen .innen { visibility: visible; transition: none; }
.griff { flex: none; height: 40px; display: grid; place-items: center; touch-action: none; cursor: grab; }
.griff::before { content: ''; width: 38px; height: 4px; border-radius: 2px; background: rgba(242, 242, 242, .28); }
.ebenen { position: relative; flex: 1; overflow: hidden; }
.ebene { position: absolute; inset: 0; padding: 0 22px; transition: transform var(--auf) var(--kurve), opacity var(--auf) var(--kurve); }
.ebene ol { list-style: none; }
.e1 a { display: flex; align-items: baseline; gap: 14px; min-height: 60px; padding: 6px 0; font-size: 42px; font-weight: 450; letter-spacing: -.03em; line-height: 1.05; }
.e1 a .mono { color: var(--hell-leise); width: 1.8em; flex: none; }
.e1 a[aria-current="page"] { color: var(--hell); }
.e1 a:not([aria-current]) { color: rgba(242, 242, 242, .82); }
.e1 a .pfeil { display: none; margin-left: auto; font-size: 22px; color: var(--hell-leise); }
[data-variante="drill"] .e1 a[data-unter] .pfeil { display: inline; }
.e2 { transform: translateX(100%); }
.zurueck { display: flex; align-items: center; gap: 10px; height: 44px; font: 400 12px/1 'Fragment Mono', monospace; color: var(--hell-leise); letter-spacing: .02em; }
.e2 ol a { display: grid; grid-template-columns: 34px 1fr auto; gap: 14px; align-items: center; min-height: 56px; }
.e2 img { width: 34px; aspect-ratio: 4 / 5; object-fit: cover; }
.e2 .serif { font-size: 28px; color: var(--hell); }
.e2 .mono { color: var(--hell-leise); }
.ebene-2 .e1 { transform: translateX(-28%); opacity: 0; }
.ebene-2 .e2 { transform: none; }
.sheetfuss { flex: none; display: flex; justify-content: space-between; padding: 12px 22px 14px; border-top: 1px solid rgba(242, 242, 242, .1); margin: 0 22px; padding-left: 0; padding-right: 0; }
.sheetfuss .mono { color: var(--hell-leise); font-size: 11px; }
/* gestaffeltes Einblenden der Links */
.st { opacity: 0; transform: translateY(12px); transition: opacity var(--ui) linear, transform var(--ui) linear; }
.offen .st { opacity: 1; transform: none; transition: opacity var(--auf) var(--kurve) calc(var(--i) * var(--stagger) + 90ms), transform var(--auf) var(--kurve) calc(var(--i) * var(--stagger) + 90ms); }
/* ---------- Variante Tab-Pille: Icons + Labels, „Mehr“ öffnet klassisches Sheet ---------- */
.tabs { position: absolute; z-index: 16; left: 50%; bottom: calc(12px + env(safe-area-inset-bottom)); translate: -50% 0; display: none; padding: 5px; border-radius: 32px; background: var(--dunkel); color: var(--hell); transition: padding var(--zu) var(--kurve); }
[data-variante="tabs"] .tabs { display: flex; }
.tabs button { position: relative; z-index: 1; width: 74px; height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: rgba(242, 242, 242, .6); transition: color var(--ui) linear, height var(--zu) var(--kurve), width var(--zu) var(--kurve); }
.tabs button[aria-current="page"], .tabs button[aria-expanded="true"] { color: var(--hell); }
.tabs svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.tabs .t { font-size: 11px; font-weight: 500; letter-spacing: .02em; line-height: 1; transition: opacity var(--ui) linear; }
.tabs .ind { position: absolute; left: 5px; top: 5px; width: 74px; height: 56px; border-radius: 28px; background: rgba(242, 242, 242, .12); transition: transform 400ms var(--kurve), width var(--zu) var(--kurve), height var(--zu) var(--kurve); }
.klein .tabs button, .klein .tabs .ind { width: 58px; height: 44px; }
.klein .tabs .t { opacity: 0; position: absolute; }
[data-variante="tabs"] .sheet { clip-path: inset(0 0 0 0 round 28px); transform: translateY(calc(100% + 40px)); height: min(380px, calc(100% - 96px)); }
[data-variante="tabs"].offen .sheet { transform: none; }
[data-variante="tabs"] .leiste, [data-variante="tabs"].offen .leiste { left: 0; right: 0; height: 56px; }
[data-variante="tabs"] .e1 a { font-size: 34px; min-height: 54px; }
.e1 li[data-nur] { display: none; }
[data-variante="tabs"] .e1 li[data-nur] { display: block; }
[data-variante="tabs"] .e1 li[data-nicht] { display: none; }
/* ---------- Variante „fab“ (Rezept friseur-salon-handschrift): ein Daumen-Knopf öffnet ein Aktionsmenü ----------
Prinzip M3 Expressive „FAB menu“: der Knopf wird zum Schließen-Kreis, darüber erscheinen die Aktionen als Pillen.
Semantik: button mit aria-haspopup="menu" und aria-expanded, Liste role="menu", Einträge role="menuitem". */
html:not([data-v="fab"]) [data-welt="fab"], html[data-v="fab"] [data-welt="basis"] { display: none !important; }
html[data-v="fab"] .telefon { --puder: #f4e9e4; --espresso: #1e1412; --lack: #9b1d2e; --karte: #e9dbd4; --meta: #6e5a55; --s-linie: #d9c8c0; background: var(--puder); color: var(--espresso); }
.salon { padding: 0 18px 120px; font-family: 'Familjen Grotesk', system-ui, sans-serif; }
.salon-kopf { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 0 16px; border-bottom: 1px solid var(--s-linie); }
.salon-kopf b { font: 500 22px/1 'Kalnia', Georgia, serif; font-stretch: 112%; letter-spacing: -.01em; }
.salon-kopf span, .salon .meta { font-size: 13px; color: var(--meta); }
.salon-held { padding: 26px 0 8px; }
.salon-held h1 { font: 500 44px/.95 'Kalnia', Georgia, serif; font-stretch: 112%; letter-spacing: -.02em; }
.salon-held p { margin-top: 14px; font-size: 16px; line-height: 1.55; max-width: 30ch; }
.salon-offen { display: inline-flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 14px; }
.salon-offen::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #2f6b3f; }
.salon-bild { margin: 22px 0 0; aspect-ratio: 4 / 3; border-radius: 4px; overflow: hidden; background: var(--karte); }
.salon-bild svg { display: block; width: 100%; height: 100%; }
.salon h2 { margin-top: 34px; font: 500 28px/1 'Kalnia', Georgia, serif; letter-spacing: -.01em; }
.preise { margin-top: 12px; list-style: none; border-top: 1px solid var(--s-linie); }
.preise li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 11px 0; border-bottom: 1px solid var(--s-linie); font-size: 16px; }
.preise li span { grid-column: 1; font-size: 13px; color: var(--meta); }
.preise li b { grid-row: 1; grid-column: 2; font-weight: 500; font-variant-numeric: tabular-nums; }
.salon-adresse { margin-top: 30px; font-size: 15px; line-height: 1.6; }
.fab-schleier { position: absolute; inset: 0; z-index: 21; background: rgba(30, 20, 18, .38); opacity: 0; pointer-events: none; transition: opacity var(--zu) var(--kurve); }
.fab-offen .fab-schleier { opacity: 1; pointer-events: auto; transition-duration: var(--auf); }
.fab {
position: absolute; z-index: 23; right: 16px; bottom: calc(18px + env(safe-area-inset-bottom));
display: inline-flex; align-items: center; justify-content: center; gap: 10px; width: 136px; height: 56px; padding: 0; border-radius: 18px;
background: var(--lack); color: #fff; font: 600 16px/1 'Familjen Grotesk', sans-serif; letter-spacing: .01em; white-space: nowrap; overflow: hidden;
box-shadow: 0 10px 24px -8px rgba(30, 20, 18, .45);
transition: width var(--zu) var(--kurve), border-radius var(--zu) var(--kurve), background-color var(--ui) linear;
}
.fab:hover { background: #86182a; }
.fab:active { background: #741524; }
.fab:focus-visible { outline: 3px solid var(--espresso); outline-offset: 3px; }
.fab .fab-ico { position: relative; flex: none; width: 22px; height: 22px; }
.fab .fab-ico svg { position: absolute; inset: 0; width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; transition: opacity var(--ui) linear, rotate var(--zu) var(--kurve); }
.fab .zu-ico { opacity: 0; rotate: -90deg; }
.fab .fab-text { transition: opacity var(--ui) linear; }
.klein .fab, .fab-offen .fab { width: 56px; border-radius: 28px; }
.klein .fab .fab-text, .fab-offen .fab .fab-text { opacity: 0; position: absolute; }
.fab-offen .fab { transition-duration: var(--auf); }
.fab-offen .fab .auf-ico { opacity: 0; rotate: 90deg; }
.fab-offen .fab .zu-ico { opacity: 1; rotate: 0deg; }
.fab-menue { position: absolute; z-index: 23; right: 16px; bottom: calc(86px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; align-items: flex-end; gap: 8px; margin: 0; padding: 0; list-style: none; visibility: hidden; transition: visibility 0s linear var(--zu); }
.fab-offen .fab-menue { visibility: visible; transition: none; }
.fab-menue li { opacity: 0; transform: translateY(12px) scale(.92); transform-origin: right center; transition: opacity var(--ui) linear, transform var(--zu) var(--kurve); }
.fab-offen .fab-menue li { opacity: 1; transform: none; transition: opacity var(--auf) var(--kurve) calc(var(--i) * var(--stagger)), transform var(--auf) var(--kurve) calc(var(--i) * var(--stagger)); }
.fab-menue a { display: inline-flex; align-items: center; gap: 12px; height: 52px; padding: 0 20px 0 16px; border-radius: 26px; background: var(--puder); color: var(--espresso); font: 500 16px/1 'Familjen Grotesk', sans-serif; white-space: nowrap; box-shadow: 0 6px 18px -6px rgba(30, 20, 18, .4), inset 0 0 0 1px var(--s-linie); transition: background-color var(--ui) linear; }
.fab-menue a:hover, .fab-menue a:focus-visible { background: #fff; }
.fab-menue a:focus-visible { outline: 3px solid var(--puder); outline-offset: 2px; box-shadow: 0 0 0 5px var(--espresso); }
.fab-menue a.erst { background: var(--lack); color: #fff; box-shadow: 0 6px 18px -6px rgba(30, 20, 18, .5); }
.fab-menue a.erst:hover, .fab-menue a.erst:focus-visible { background: #86182a; }
.fab-menue svg { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.fab-menue small { font-size: 13px; color: var(--meta); font-variant-numeric: tabular-nums; }
.fab-menue a.erst small { color: rgba(255, 255, 255, .86); }
.reduziert .fab, .reduziert .fab *, .reduziert .fab-menue li { transition-duration: var(--ui) !important; transition-delay: 0s !important; transform: none !important; }
.reduziert .fab .fab-ico svg { rotate: 0deg !important; }
@media (forced-colors: active) { .fab, .fab-menue a { border: 1px solid ButtonText; } }
/* ---------- Variante „kontakt“ (Rezepte handwerk, kfz, pflege, friseur, praxis): untere Kontaktleiste für lokale Betriebe ----------
Anrufen (tel:-Link mit sichtbarer Nummer), WhatsApp (optional, ersetzbar durch „Termin“), Anfrage (springt zum Formular).
Die Leiste verdeckt nichts: scroll-padding-bottom hält fokussierte Elemente frei, sie weicht dem Consent-Banner,
der Bildschirmtastatur und dem Footer (dort stehen Telefon und Adresse ohnehin groß). Bewegung: token-tech-praezise. */
html:not([data-v="kontakt"]) [data-welt="kontakt"], html[data-v="kontakt"] [data-welt="basis"] { display: none !important; }
html[data-v="kontakt"] .telefon {
--k-grund: #efedea; --k-flaeche: #d3cdc2; --k-tinte: #1a1a1a; --k-leise: #5c5852; --k-signal: #f2b705; --k-salbei: #bdc1a9; --k-linie: rgba(26, 26, 26, .16);
--k-ein: cubic-bezier(0, 0, 0.38, 0.9); --k-aus: cubic-bezier(0.2, 0, 1, 0.9); --k-dauer-ein: 240ms; --k-dauer-aus: 150ms;
--k-hoehe: 68px; --k-safe: 22px; /* Attrappe: gedachte Home-Leiste; am echten Telefon env(safe-area-inset-bottom) */
background: var(--k-grund); color: var(--k-tinte);
}
html[data-v="kontakt"] .scroller { scroll-padding-bottom: calc(var(--k-hoehe) + var(--k-safe) + 16px); scroll-padding-top: 64px; }
html[data-v="kontakt"] .tastatur-auf .scroller { scroll-padding-bottom: 288px; }
.betrieb { font: 400 17px/1.55 'Atkinson Hyperlegible Next', system-ui, sans-serif; }
.betrieb :focus-visible { outline: 3px solid var(--k-tinte); outline-offset: 2px; }
.k-kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--k-linie); }
.k-marke, .k-gross, .betrieb h1, .betrieb h2, .k-preis, .k-fuss-tel { font-family: 'Special Gothic', 'Atkinson Hyperlegible Next', sans-serif; }
.k-marke { font-size: 17px; font-weight: 700; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; line-height: 1; }
.k-marke i { font-style: normal; display: inline-block; width: 10px; height: 14px; margin-right: 8px; background: var(--k-tinte); clip-path: polygon(60% 0, 0 58%, 42% 58%, 30% 100%, 100% 38%, 56% 38%); vertical-align: -1px; }
.k-kopf a[href^="tel"] { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; text-decoration: underline; text-underline-offset: .2em; min-height: 44px; display: inline-flex; align-items: center; }
.k-held { padding: 22px 18px 0; }
.k-label { font: 700 13px/1.2 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; color: var(--k-leise); }
.betrieb h1 { margin-top: 10px; font-size: 38px; font-weight: 700; line-height: 1.02; letter-spacing: -.01em; }
.k-held > p:not(.k-label):not(.k-status) { margin-top: 12px; max-width: 32ch; }
.k-status { display: inline-flex; align-items: center; gap: 8px; margin-top: 14px; font-size: 15px; }
.k-status::before { content: ''; width: 9px; height: 9px; border-radius: 50%; background: #2f6b3f; box-shadow: 0 0 0 3px rgba(47, 107, 63, .18); }
.k-bild { margin: 20px 0 0; aspect-ratio: 4 / 3; background: var(--k-flaeche); border-radius: 4px; overflow: hidden; }
.k-bild svg { display: block; width: 100%; height: 100%; }
.betrieb h2 { font-size: 26px; font-weight: 700; line-height: 1.05; }
.k-teil { padding: 34px 18px 0; }
.k-leistungen { list-style: none; margin-top: 12px; border-top: 1px solid var(--k-linie); }
.k-leistungen li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 12px 0; border-bottom: 1px solid var(--k-linie); }
.k-leistungen li span { grid-column: 1; font-size: 14px; color: var(--k-leise); line-height: 1.4; }
.k-leistungen .k-preis { grid-row: 1; grid-column: 2; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.k-klein { margin-top: 10px; font-size: 13px; color: var(--k-leise); }
.k-form { display: grid; gap: 14px; margin-top: 14px; }
.k-form label { display: grid; gap: 6px; font-size: 15px; font-weight: 700; }
.k-form label small { font-weight: 400; color: var(--k-leise); }
.k-form input, .k-form select, .k-form textarea { font: 400 17px/1.3 'Atkinson Hyperlegible Next', sans-serif; color: var(--k-tinte); background: #fbfaf8; border: 1.5px solid #8a857c; border-radius: 6px; min-height: 48px; padding: 10px 12px; width: 100%; }
.k-form textarea { min-height: 96px; resize: vertical; }
.k-form input:focus-visible, .k-form select:focus-visible, .k-form textarea:focus-visible { outline: 3px solid var(--k-tinte); outline-offset: 1px; border-color: var(--k-tinte); }
.k-senden { min-height: 52px; border-radius: 6px; background: var(--k-signal); color: var(--k-tinte); font: 700 17px/1 'Atkinson Hyperlegible Next', sans-serif; transition: background-color 150ms var(--k-ein); }
.k-senden:hover { background: #e0a800; }
.k-fuss { margin-top: 44px; padding: 28px 18px 40px; background: var(--k-tinte); color: #f1efea; }
.k-fuss .k-label { color: #c9c4ba; }
.k-fuss-tel { display: inline-flex; align-items: center; min-height: 48px; margin-top: 6px; font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: .01em; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .18em; }
.k-fuss p { margin-top: 12px; font-size: 15px; line-height: 1.55; }
.k-fuss :focus-visible { outline-color: #f1efea; }
.k-fuss-links { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 16px; padding: 0; list-style: none; font-size: 14px; }
.k-fuss-links a, .k-fuss-links button { display: inline-flex; align-items: center; min-height: 44px; text-decoration: underline; text-underline-offset: .2em; font: inherit; color: inherit; }
/* Die Leiste */
.kleiste {
position: absolute; z-index: 22; left: 0; right: 0; bottom: 0;
display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(64px, .7fr) minmax(0, 1fr); gap: 8px;
padding: 8px 10px calc(8px + max(env(safe-area-inset-bottom), var(--k-safe))); background: var(--k-tinte); color: #f1efea;
transform: none; transition: transform var(--k-dauer-ein) var(--k-ein), visibility 0s;
}
.k-aus .kleiste { transform: translateY(calc(100% + 2px)); visibility: hidden; transition: transform var(--k-dauer-aus) var(--k-aus), visibility 0s linear var(--k-dauer-aus); }
.kleiste a { display: flex; align-items: center; justify-content: center; gap: 9px; min-width: 0; min-height: 52px; padding: 0 10px; border-radius: 6px; border: 1.5px solid rgba(241, 239, 234, .34); font: 700 15px/1.1 'Atkinson Hyperlegible Next', sans-serif; transition: background-color 150ms var(--k-ein), border-color 150ms var(--k-ein); }
.kleiste svg { flex: none; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.kleiste .k-tel { justify-content: flex-start; }
.kleiste .k-tel span { display: grid; gap: 2px; min-width: 0; }
.kleiste .k-tel small { font-size: 13px; font-weight: 400; font-variant-numeric: tabular-nums; letter-spacing: .01em; white-space: nowrap; color: #e4e0d8; }
.kleiste .k-wa { flex-direction: column; gap: 3px; font-size: 12px; padding: 0 4px; }
.kleiste .k-anfrage { background: var(--k-signal); border-color: var(--k-signal); color: var(--k-tinte); }
.kleiste a:focus-visible { outline: 3px solid #f1efea; outline-offset: 2px; }
.kleiste a:active { scale: .97; }
@media (hover: hover) { .kleiste a:not(.k-anfrage):hover { background: rgba(241, 239, 234, .1); border-color: rgba(241, 239, 234, .6); } .kleiste .k-anfrage:hover { background: #e0a800; border-color: #e0a800; } }
@media (max-width: 360px) { .kleiste { grid-template-columns: minmax(0, 1.5fr) 56px minmax(0, 1fr); gap: 6px; padding-left: 6px; padding-right: 6px; } .kleiste a { padding: 0 6px; gap: 6px; font-size: 14px; } .kleiste .k-tel svg { display: none; } }
/* Consent-Banner: solange er offen ist, bleibt die Leiste weg */
.k-consent { position: absolute; z-index: 24; left: 10px; right: 10px; bottom: calc(10px + max(env(safe-area-inset-bottom), var(--k-safe))); padding: 16px; border-radius: 8px; background: #fbfaf8; color: var(--k-tinte); box-shadow: 0 14px 40px -12px rgba(26, 26, 26, .45), 0 0 0 1px var(--k-linie); font: 400 15px/1.5 'Atkinson Hyperlegible Next', sans-serif; transition: transform var(--k-dauer-ein) var(--k-ein), opacity var(--k-dauer-ein) var(--k-ein); }
.k-consent h2 { font: 700 18px/1.1 'Special Gothic', sans-serif; }
.k-consent p { margin-top: 6px; }
.k-consent div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; margin-top: 12px; }
.k-consent button { min-height: 48px; border-radius: 6px; border: 1.5px solid var(--k-tinte); font: 700 15px/1.1 'Atkinson Hyperlegible Next', sans-serif; padding: 0 8px; }
.k-consent button:focus-visible { outline: 3px solid var(--k-tinte); outline-offset: 2px; }
.k-consent[hidden] { display: none; }
/* Tastatur der Attrappe (am echten Telefon zeigt visualViewport die echte Tastatur an) */
.tastatur { position: absolute; z-index: 26; left: 0; right: 0; bottom: 0; height: 272px; padding: 8px 4px 30px; background: #d2d4da; display: grid; grid-template-rows: repeat(4, 1fr); gap: 10px; transform: translateY(100%); visibility: hidden; transition: transform var(--k-dauer-aus) var(--k-aus), visibility 0s linear var(--k-dauer-aus); pointer-events: none; }
.tastatur-auf .tastatur { transform: none; visibility: visible; transition: transform var(--k-dauer-ein) var(--k-ein), visibility 0s; }
.tastatur div { display: flex; justify-content: center; gap: 5px; }
.tastatur span { flex: 0 1 32px; display: grid; place-items: center; border-radius: 5px; background: #fff; box-shadow: 0 1px 0 #8b8f98; font: 400 18px/1 system-ui, sans-serif; color: #111; }
.tastatur span.w { flex: 0 1 44px; background: #adb1bb; font-size: 13px; }
.tastatur span.lang { flex: 1 1 auto; }
/* Rückmeldung in der Demo (tel: und WhatsApp öffnen hier nichts) */
.k-toast { position: absolute; z-index: 28; left: 50%; top: 58px; translate: -50% 0; max-width: calc(100% - 36px); padding: 10px 14px; border-radius: 8px; background: var(--k-tinte); color: #f1efea; font: 400 14px/1.35 'Atkinson Hyperlegible Next', sans-serif; text-align: center; opacity: 0; transition: opacity var(--k-dauer-aus) var(--k-aus); pointer-events: none; }
.k-toast.da { opacity: 1; transition: opacity var(--k-dauer-ein) var(--k-ein); }
.k-zustand { margin-top: 12px; }
.k-zustand b { font-weight: 500; color: var(--text); }
.vorschau .k-zustand { display: none; }
@media (max-width: 560px) { .k-zustand { display: none; } }
@media (max-width: 360px) { .umschalter button { padding: 0 3px; letter-spacing: .04em; font-size: 9.5px; } }
.reduziert .kleiste, .reduziert .k-aus .kleiste, .reduziert .tastatur, .reduziert .tastatur-auf .tastatur { transform: none; transition: opacity var(--ui) linear, visibility 0s linear var(--ui); }
.reduziert .kleiste { opacity: 1; } .reduziert .k-aus .kleiste { opacity: 0; }
.reduziert .tastatur { opacity: 0; } .reduziert .tastatur-auf .tastatur { opacity: 1; transition: opacity var(--ui) linear, visibility 0s; }
@media (forced-colors: active) { .kleiste a, .k-consent button, .k-senden { border: 1px solid ButtonText; } }
/* Vorführung: gedachter Finger */
.finger { position: absolute; z-index: 40; left: 0; top: 0; width: 46px; height: 46px; margin: -23px 0 0 -23px; pointer-events: none; opacity: 0; transition: opacity 200ms linear; }
.finger::before { content: ''; position: absolute; inset: 0; border-radius: 50%;
background: rgba(255, 255, 255, .32); border: 1.5px solid rgba(255, 255, 255, .95); box-shadow: 0 0 0 1px rgba(20, 20, 20, .28), 0 6px 18px rgba(20, 20, 20, .25);
transition: scale 140ms var(--kurve); }
.vorfuehrung .finger.da { opacity: 1; }
.finger.tipp::before { scale: .78; }
/* ---------- Telefonbreite: keine Attrappe, echte fixe Leiste ---------- */
@media (max-width: 560px) {
.rahmen { display: flex; flex-direction: column; padding: 0; gap: 0; }
.spalte.links { display: none; }
.spalte.rechts { order: -1; align-items: stretch; text-align: left; padding: 10px 18px; border-bottom: 1px solid var(--linie); gap: 0; }
.spalte.rechts .oben { display: none; }
.umschalter { display: flex; }
.umschalter button { flex: 1; padding: 0 6px; height: 34px; }
.werte { display: none; } /* am Telefon zuerst die Seite; die Werte stehen auf der Detailseite */
.werte div { display: flex; gap: 6px; align-items: baseline; }
.werte dd { font-size: 11px; }
.buehne { display: block; }
.huelle { width: auto; height: auto; }
.telefon { width: auto; height: auto; transform: none; border: 0; border-radius: 0; overflow: visible; box-shadow: none; isolation: auto; }
.status { display: none; }
.scroller { position: static; overflow: visible; padding-top: 0; }
.schleier, .sheet, .tabs, .finger, .fab, .fab-menue, .fab-schleier, .kleiste, .k-consent, .k-toast { position: fixed; }
html[data-v="kontakt"] { scroll-padding-bottom: calc(var(--k-hoehe, 68px) + env(safe-area-inset-bottom) + 16px); scroll-padding-top: 16px; }
html[data-v="kontakt"] .telefon { --k-safe: 0px; }
.tastatur { display: none; }
html:has(.telefon.offen) { overflow: hidden; }
}
@media (max-width: 860px) and (min-width: 561px) {
.hinweis, .werte { display: none; }
.marke-gross { font-size: 21px; }
}
/* Touch: größere Griffzone, kein Greif-Cursor */
@media (hover: none) {
.griff { height: 44px; cursor: auto; }
.dia { cursor: auto; }
}
@media (hover: hover) {
.e1 a:hover, .e2 ol a:hover .serif { color: var(--hell); }
.knopf:hover { background: rgba(242, 242, 242, .16); }
}
/* Galerie-Vorschau: nur Bühne */
.vorschau body { overflow: hidden; }
.vorschau .hinweis, .vorschau .werte, .vorschau .umschalter { display: none; }
/* ---------- Reduzierte Bewegung: Leiste bleibt stehen, Sheet blendet an Zielposition ein ---------- */
.reduziert .sheet, .reduziert .offen .sheet { transition: clip-path 0s var(--ui), transform 0s var(--ui); }
.reduziert .offen .sheet { transition-delay: 0s; }
.reduziert .flaeche, .reduziert .innen { opacity: 0; transition: opacity var(--ui) linear; }
.reduziert .offen .flaeche, .reduziert .offen .innen { opacity: 1; }
.reduziert .offen .leiste { left: 14px; right: 14px; }
.reduziert .leiste, .reduziert .knopf .kreuz, .reduziert .tabs, .reduziert .tabs button, .reduziert .tabs .ind, .reduziert .ebene, .reduziert .st { transition: none !important; }
.reduziert .st { opacity: 1; transform: none; }
.reduziert [data-variante="tabs"] .leiste { left: 0; right: 0; }
.reduziert .ansicht, .reduziert .ansicht.an, .reduziert .dia img { transition-duration: var(--ui); }
.reduziert .ebene-2 .e1 { opacity: 0; transform: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="rahmen">
<div class="spalte links">
<div data-welt="basis">
<p class="serif marke-gross">Theres Ammann</p>
<p class="mono">Fotografie und Film, Konstanz</p>
</div>
<div data-welt="fab">
<p class="marke-gross" style="font: 500 28px/1 'Kalnia', Georgia, serif; font-stretch: 112%">Salon Mira</p>
<p class="mono">Friseur, Bad Kissingen</p>
</div>
<div data-welt="kontakt">
<p class="marke-gross" style="font: 700 28px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .03em; text-transform: uppercase">Elektro Kaltenecker</p>
<p class="mono">Elektromeister, Freising</p>
</div>
<div>
<p class="label">Mobile Ansicht · <span data-variantenname>Leiste wächst</span></p>
<p class="hinweis" data-welt="basis">Die Navigation sitzt in der Daumenzone. Tippe auf „Menü“ und ziehe das Sheet am Griff nach unten.</p>
<p class="hinweis" data-welt="fab">Ein Knopf in der Daumenzone öffnet die Wege zum Termin. Pfeiltasten wählen, Esc schließt.</p>
<p class="hinweis" data-welt="kontakt">Anrufen und Anfrage bleiben in Daumennähe. Die Leiste weicht dem Consent-Banner, der Tastatur und dem Footer.</p>
</div>
</div>
<div class="buehne">
<div class="huelle">
<div class="telefon" data-telefon data-variante="morph">
<div class="status" aria-hidden="true"><span>9:41</span><svg viewBox="0 0 62 12"><g fill="#141414"><rect x="0" y="8" width="3" height="4" rx="1"/><rect x="5" y="5.5" width="3" height="6.5" rx="1"/><rect x="10" y="3" width="3" height="9" rx="1"/><rect x="15" y=".5" width="3" height="11.5" rx="1"/><rect x="36" y=".5" width="22" height="11" rx="3.2" fill="none" stroke="#141414" stroke-opacity=".4"/><rect x="38" y="2.5" width="16" height="7" rx="1.6"/><rect x="59.5" y="4" width="1.6" height="4" rx=".8" fill-opacity=".4"/></g><path d="M26.5 3.2a8 8 0 0 1 5.6 2.3l-1 1a6.6 6.6 0 0 0-9.2 0l-1-1a8 8 0 0 1 5.6-2.3Zm0 3a5 5 0 0 1 3.5 1.4l-1 1a3.6 3.6 0 0 0-5 0l-1-1a5 5 0 0 1 3.5-1.4Zm0 3 1.3 1.3-1.3 1.3-1.3-1.3Z" fill="#141414"/></svg></div>
<div class="scroller" data-scroller>
<header class="kopf" data-welt="basis">
<a href="#bilder" class="serif" data-ziel="bilder">Theres Ammann</a>
<span class="mono">Konstanz</span>
</header>
<main class="ansichten" data-welt="basis">
<section class="ansicht an" data-ansicht="bilder" aria-label="Bilder">
<div class="titelzeile"><h1 class="serif" data-serientitel>Seestücke</h1><span class="mono" data-serienjahr>2019–2026</span></div>
<div class="dia" data-dia role="button" tabindex="0" aria-label="Nächstes Bild der Bildfolge" aria-describedby="dia-text">
<img alt="" data-bild="0"><img alt="" data-bild="1"><img alt="" data-bild="2"><img alt="" data-bild="3"><img alt="" data-bild="4"><img alt="" data-bild="5">
</div>
<div class="unterzeile"><span class="mono" data-zaehler>01 / 06</span><span class="mono" data-bildtext id="dia-text" aria-live="polite">Seerhein, Nebel, 06:40</span></div>
<p class="text">Seit 2019 fotografiere ich den Bodensee an zwölf festen Stellen, immer zur gleichen Stunde. Was sich ändert, ist nur das Licht.</p>
<div class="abschnitt">
<p class="mono">Weitere Serien</p>
<div class="paar">
<figure><img alt="Uferzonen: Schilf im Eis vor der Mainau" data-bild="4"><figcaption><span class="serif">Uferzonen</span><span class="mono">18 Bilder</span></figcaption></figure>
<figure><img alt="Konstanzer Nächte: Fährhafen am Abend" data-bild="3"><figcaption><span class="serif">Konstanzer Nächte</span><span class="mono">16 Bilder</span></figcaption></figure>
</div>
</div>
<div class="abschnitt">
<p class="mono">Aufträge</p>
<p class="text" style="margin-top:10px">Für Hotels, Verlage und Architekturbüros rund um den See. Reportage, Architektur, ruhige Porträts.</p>
</div>
<div class="fuss"><span class="mono">© 2026 Theres Ammann</span><span class="mono">Impressum</span></div>
</section>
<section class="ansicht" data-ansicht="serien" aria-label="Serien">
<div class="titelzeile"><h2 class="serif">Serien</h2><span class="mono">04</span></div>
<ol class="serienliste">
<li><img alt="" data-bild="0"><span class="serif">Seestücke</span><span class="mono">24</span></li>
<li><img alt="" data-bild="4"><span class="serif">Uferzonen</span><span class="mono">18</span></li>
<li><img alt="" data-bild="1"><span class="serif">Wintergäste</span><span class="mono">12</span></li>
<li><img alt="" data-bild="3"><span class="serif">Konstanzer Nächte</span><span class="mono">16</span></li>
</ol>
<p class="text">Jede Serie hat eine eigene Seite mit allen Bildern, Ort und Uhrzeit.</p>
</section>
<section class="ansicht" data-ansicht="film" aria-label="Film">
<div class="titelzeile"><h2 class="serif">Film</h2><span class="mono">2025</span></div>
<div class="film"><img alt="Standbild: Untersee in der Dämmerung, Schilf im Vordergrund" data-bild="6"><span aria-hidden="true"></span></div>
<div class="unterzeile"><span class="mono">14:20</span><span class="mono">Der lange Winter am Untersee, Kurzfilm</span></div>
<p class="text">Drei Monate, ein Standort, eine Kamera. Ein stiller Film über Eis, Nebel und die Vögel, die bleiben.</p>
</section>
<section class="ansicht" data-ansicht="info" aria-label="Info">
<div class="titelzeile"><h2 class="serif">Theres Ammann</h2></div>
<p class="text" style="margin-top:0">Fotografin und Filmemacherin, geboren 1988 in Radolfzell. Studio am Seerhein in Konstanz. Arbeiten für Verlage, Hotels und Architekturbüros in Deutschland und der Schweiz.</p>
<div class="kontakt">
<p class="mono">Kontakt</p>
<p>post@theresammann.de</p>
<p>Atelier am Seerhein, Konstanz</p>
</div>
</section>
</main>
<div class="salon" data-welt="fab">
<header class="salon-kopf"><a href="#salon" aria-label="Salon Mira, Startseite"><b>Salon Mira</b></a><span>Bad Kissingen</span></header>
<section class="salon-held" aria-labelledby="salon-titel">
<h1 id="salon-titel">Gutes Haar braucht Zeit.</h1>
<p>Wir schneiden, färben und beraten in Ruhe. Drei Stühle, kein Fließband, Termine auch samstags.</p>
<p class="salon-offen">Heute geöffnet bis 19 Uhr</p>
<figure class="salon-bild" aria-hidden="true">
<svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice"><rect width="400" height="300" fill="#e9dbd4"/><rect y="226" width="400" height="74" fill="#d9c8c0"/><path d="M118 226V96a62 62 0 0 1 124 0v130Z" fill="#f4e9e4" stroke="#1e1412" stroke-width="3"/><path d="M132 226V100a48 48 0 0 1 96 0v126Z" fill="#cdb9ae"/><path d="M150 120c18-26 52-26 66 8" stroke="#f4e9e4" stroke-width="5" fill="none" opacity=".7"/><rect x="152" y="176" width="64" height="12" rx="6" fill="#1e1412"/><path d="M160 188v38M208 188v38" stroke="#1e1412" stroke-width="4"/><path d="M146 150h76v26h-76z" fill="#9b1d2e"/><circle cx="318" cy="208" r="20" fill="#1e1412"/><path d="M318 188c-4-30 6-60 26-74M318 188c-10-24-30-36-46-36M318 188c8-20 26-30 44-24" stroke="#5d6b4a" stroke-width="5" stroke-linecap="round" fill="none"/><path d="M40 40h40v120H40z" fill="#f4e9e4" stroke="#d9c8c0" stroke-width="2"/><path d="M50 60h20M50 76h20M50 92h14" stroke="#6e5a55" stroke-width="3"/></svg>
</figure>
</section>
<h2>Preise</h2>
<ul class="preise">
<li>Damenschnitt mit Föhnen<span>60 Minuten</span><b>58 €</b></li>
<li>Herrenschnitt<span>30 Minuten</span><b>32 €</b></li>
<li>Farbe Ansatz<span>75 Minuten, ohne Schnitt</span><b>ab 64 €</b></li>
<li>Strähnen in Folie<span>120 Minuten</span><b>ab 118 €</b></li>
<li>Kinder bis 12<span>20 Minuten</span><b>18 €</b></li>
</ul>
<p class="salon-adresse"><span class="meta">Adresse</span><br>Untere Marktstraße 9, 97688 Bad Kissingen<br>Di bis Fr 9 bis 19 Uhr, Sa 8 bis 14 Uhr<br><a href="#preisliste" style="text-decoration: underline; text-underline-offset: .2em">Ganze Preisliste</a></p>
</div>
<div class="betrieb" data-welt="kontakt">
<header class="k-kopf">
<a href="#k-start" class="k-marke" aria-label="Elektro Kaltenecker, Startseite"><i aria-hidden="true"></i>Kaltenecker</a>
<a href="tel:+4981619482730" data-k-tel>08161 948 27 30</a>
</header>
<main>
<section class="k-held" id="k-start" aria-labelledby="k-titel">
<p class="k-label">Elektromeister · Freising und 25 km drumherum</p>
<h1 id="k-titel">Strom, Licht, Wallbox. Sauber verlegt.</h1>
<p>Elektroinstallation im Alt- und Neubau, Zählerschränke nach aktueller Norm, Wallboxen mit Anmeldung beim Netzbetreiber.</p>
<p class="k-status">Heute bis 17 Uhr erreichbar</p>
<figure class="k-bild">
<svg viewBox="0 0 400 300" role="img" aria-label="Gezeichneter, geöffneter Zählerschrank mit Stromzähler und drei Reihen Sicherungen"><rect width="400" height="300" fill="#d3cdc2"/><path d="M0 92h400M0 196h400" stroke="#c6bfb2" stroke-width="2"/><path d="M150 276v24M168 276v24M186 276v24M204 276v24M222 276v24" stroke-width="7" stroke-linecap="round"/><path d="M150 276v24" stroke="#7a5a3a" stroke-width="7"/><path d="M168 276v24" stroke="#1a1a1a" stroke-width="7"/><path d="M186 276v24" stroke="#8a8a86" stroke-width="7"/><path d="M204 276v24" stroke="#46638c" stroke-width="7"/><path d="M222 276v24" stroke="#7d8a3c" stroke-width="7"/><rect x="66" y="20" width="268" height="258" rx="6" fill="#f4f3ef" stroke="#1a1a1a" stroke-width="3"/><rect x="80" y="34" width="240" height="230" rx="3" fill="none" stroke="#cfcac0" stroke-width="2"/><rect x="96" y="46" width="98" height="88" rx="4" fill="#e6e3dc" stroke="#1a1a1a" stroke-width="2"/><rect x="108" y="58" width="74" height="24" rx="2" fill="#1a1a1a"/><text x="145" y="75.5" text-anchor="middle" font-family="ui-monospace, monospace" font-size="14" fill="#e9e6df" letter-spacing="1">004213</text><path d="M108 94h46M108 104h30" stroke="#8a857c" stroke-width="3" stroke-linecap="round"/><circle cx="172" cy="114" r="6" fill="none" stroke="#1a1a1a" stroke-width="2"/><rect x="206" y="46" width="98" height="88" rx="4" fill="none" stroke="#8a857c" stroke-width="2" stroke-dasharray="6 5"/><path d="M232 90h46M255 67v46" stroke="#8a857c" stroke-width="2"/><g fill="#fbfaf8" stroke="#1a1a1a" stroke-width="1.6"><path d="M92 154h216M92 200h216M92 246h216" stroke="#8a857c" stroke-width="3"/><rect x="98" y="140" width="34" height="30" rx="2"/><rect x="136" y="140" width="15" height="30" rx="2"/><rect x="154" y="140" width="15" height="30" rx="2"/><rect x="172" y="140" width="15" height="30" rx="2"/><rect x="190" y="140" width="15" height="30" rx="2"/><rect x="208" y="140" width="15" height="30" rx="2"/><rect x="226" y="140" width="15" height="30" rx="2"/><rect x="98" y="186" width="34" height="30" rx="2"/><rect x="136" y="186" width="15" height="30" rx="2"/><rect x="154" y="186" width="15" height="30" rx="2"/><rect x="172" y="186" width="15" height="30" rx="2"/><rect x="190" y="186" width="15" height="30" rx="2"/><rect x="250" y="186" width="50" height="30" rx="2"/><rect x="98" y="232" width="15" height="30" rx="2"/><rect x="116" y="232" width="15" height="30" rx="2"/><rect x="134" y="232" width="15" height="30" rx="2"/><rect x="152" y="232" width="15" height="30" rx="2"/></g><g fill="#1a1a1a"><rect x="110" y="147" width="10" height="7"/><rect x="140" y="147" width="7" height="7"/><rect x="158" y="147" width="7" height="7"/><rect x="176" y="147" width="7" height="7"/><rect x="194" y="147" width="7" height="7"/><rect x="212" y="147" width="7" height="7"/><rect x="230" y="147" width="7" height="7"/><rect x="110" y="193" width="10" height="7"/><rect x="140" y="193" width="7" height="7"/><rect x="158" y="193" width="7" height="7"/><rect x="176" y="193" width="7" height="7"/><rect x="194" y="203" width="7" height="7"/><rect x="102" y="239" width="7" height="7"/><rect x="120" y="239" width="7" height="7"/><rect x="138" y="239" width="7" height="7"/><rect x="156" y="239" width="7" height="7"/></g><path d="M258 196h34M258 204h22" stroke="#8a857c" stroke-width="2.5" stroke-linecap="round"/><path d="M98 132h208" stroke="#1a1a1a" stroke-width="1" opacity=".2"/></svg>
</figure>
</section>
<section class="k-teil" aria-labelledby="k-leistungen">
<h2 id="k-leistungen">Was wir machen</h2>
<ul class="k-leistungen">
<li>Wallbox 11 kW <span>mit Anmeldung beim Netzbetreiber</span><b class="k-preis">ab 1.290 €</b></li>
<li>Zählerschrank erneuern <span>nach VDE-AR-N 4100, mit Zählerplatz für PV</span><b class="k-preis">ab 2.400 €</b></li>
<li>E-Check <span>Prüfprotokoll für Vermieter und Versicherung</span><b class="k-preis">ab 149 €</b></li>
<li>Licht und Steckdosen <span>Altbau, Umbau, Küche</span><b class="k-preis">nach Aufwand</b></li>
</ul>
<p class="k-klein">Preise inkl. 19 % MwSt., Stand November 2026. Vor Ort sehen wir uns alles an, dann gibt es ein festes Angebot.</p>
</section>
<section class="k-teil" id="anfrage" aria-labelledby="anfrage-titel">
<h2 id="anfrage-titel" tabindex="-1">Anfrage</h2>
<p class="k-klein">Antwort in der Regel am nächsten Werktag.</p>
<form class="k-form" data-k-form novalidate>
<label>Was steht an?
<select name="leistung"><option>Wallbox</option><option>Zählerschrank</option><option>E-Check</option><option>Licht und Steckdosen</option><option>Etwas anderes</option></select>
</label>
<label>Postleitzahl <input name="plz" inputmode="numeric" autocomplete="postal-code" maxlength="5"></label>
<label>Name <input name="name" autocomplete="name"></label>
<label>Telefon für den Rückruf <input name="telefon" type="tel" autocomplete="tel"></label>
<label>Worum geht es? <small>optional</small> <textarea name="nachricht" rows="3"></textarea></label>
<button type="submit" class="k-senden">Anfrage senden</button>
</form>
</section>
</main>
<footer class="k-fuss" data-k-fuss>
<p class="k-label">Elektro Kaltenecker GmbH</p>
<a class="k-fuss-tel" href="tel:+4981619482730" data-k-tel>08161 948 27 30</a>
<p>Am Lohmühlbach 6, 85354 Freising<br>Mo bis Do 7 bis 17 Uhr, Fr 7 bis 13 Uhr<br>Notdienst für Wartungskunden rund um die Uhr</p>
<p>Meisterbetrieb, eingetragen in die Handwerksrolle der Handwerkskammer für München und Oberbayern.</p>
<ul class="k-fuss-links">
<li><a href="#impressum">Impressum</a></li>
<li><a href="#datenschutz">Datenschutz</a></li>
<li><button type="button" data-consent-auf>Cookie-Einstellungen</button></li>
</ul>
</footer>
</div>
</div>
<div class="schleier" data-schleier></div>
<div class="sheet" data-sheet data-welt="basis">
<div class="flaeche"></div>
<div class="innen" id="sheet-innen">
<div class="griff" data-griff aria-hidden="true"></div>
<div class="ebenen" data-ebenen inert>
<nav class="ebene e1" aria-label="Hauptmenü">
<ol>
<li data-nicht="tabs"><a href="#bilder" class="st" data-ziel="bilder" aria-current="page"><span class="mono">01</span>Bilder</a></li>
<li data-nicht="tabs"><a href="#serien" class="st" data-ziel="serien" data-unter><span class="mono">02</span>Serien<span class="pfeil" aria-hidden="true">→</span></a></li>
<li data-nicht="tabs"><a href="#film" class="st" data-ziel="film"><span class="mono">03</span>Film</a></li>
<li><a href="#info" class="st" data-ziel="info"><span class="mono">04</span>Info</a></li>
<li data-nur="tabs"><a href="#auftraege" class="st" data-ziel="info"><span class="mono">05</span>Aufträge</a></li>
<li data-nur="tabs"><a href="#kontakt" class="st" data-ziel="info"><span class="mono">06</span>Kontakt</a></li>
<li data-nur="tabs"><a href="#newsletter" class="st" data-ziel="info"><span class="mono">07</span>Newsletter</a></li>
</ol>
</nav>
<div class="ebene e2" data-e2 inert>
<button class="zurueck" data-zurueck type="button"><span aria-hidden="true">←</span> Zurück zum Menü</button>
<ol aria-label="Serien">
<li><a href="#seestuecke" data-serie="0"><img alt="" data-bild="0"><span class="serif">Seestücke</span><span class="mono">24</span></a></li>
<li><a href="#uferzonen" data-serie="1"><img alt="" data-bild="4"><span class="serif">Uferzonen</span><span class="mono">18</span></a></li>
<li><a href="#wintergaeste" data-serie="2"><img alt="" data-bild="1"><span class="serif">Wintergäste</span><span class="mono">12</span></a></li>
<li><a href="#naechte" data-serie="3"><img alt="" data-bild="3"><span class="serif">Konstanzer Nächte</span><span class="mono">16</span></a></li>
</ol>
</div>
</div>
<div class="sheetfuss st"><span class="mono">post@theresammann.de</span><span class="mono">47.66° N</span></div>
</div>
<div class="leiste">
<span class="serif">Theres Ammann</span>
<button class="knopf" type="button" data-knopf aria-expanded="false" aria-controls="sheet-innen"><span class="kreuz" aria-hidden="true"></span><span class="w"><span>Menü</span><span>Schließen</span></span></button>
</div>
</div>
<nav class="tabs" data-tabs aria-label="Bereiche" data-welt="basis">
<span class="ind" data-ind aria-hidden="true"></span>
<button type="button" data-tab="bilder" aria-current="page"><svg viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="m3.5 16 5-4.5 4 3.5 3-2.5 5 4"/></svg><span class="t">Bilder</span></button>
<button type="button" data-tab="serien"><svg viewBox="0 0 24 24"><rect x="6.5" y="7.5" width="13" height="12" rx="1.5"/><path d="M4.5 16.5v-11a1 1 0 0 1 1-1h11"/></svg><span class="t">Serien</span></button>
<button type="button" data-tab="film"><svg viewBox="0 0 24 24"><rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="m10.5 9.5 4 2.5-4 2.5Z"/></svg><span class="t">Film</span></button>
<button type="button" data-tab="mehr" aria-expanded="false" aria-controls="sheet-innen"><svg viewBox="0 0 24 24"><path d="M5 9h14M5 15h14"/></svg><span class="t">Mehr</span></button>
</nav>
<div class="fab-schleier" data-fab-schleier data-welt="fab"></div>
<ul class="fab-menue" id="fab-menue" role="menu" aria-label="Termin und Kontakt" data-fab-menue data-welt="fab" inert>
<li role="none" style="--i:3"><a role="menuitem" tabindex="-1" href="#anfahrt"><svg viewBox="0 0 20 20"><path d="M10 18s6-5.2 6-10a6 6 0 0 0-12 0c0 4.8 6 10 6 10Z"/><circle cx="10" cy="8" r="2.2"/></svg>Anfahrt <small>4 Min. vom Bahnhof</small></a></li>
<li role="none" style="--i:2"><a role="menuitem" tabindex="-1" href="#whatsapp"><svg viewBox="0 0 20 20"><path d="M3.5 16.5 4.6 13A7 7 0 1 1 7 15.4Z"/><path d="M7.6 7.4c.3 2 2.6 4.4 5 5"/></svg>WhatsApp schreiben</a></li>
<li role="none" style="--i:1"><a role="menuitem" tabindex="-1" href="tel:+499714512230"><svg viewBox="0 0 20 20"><path d="M4 3.5h3l1.3 3.6-1.8 1.2a8.5 8.5 0 0 0 5.2 5.2l1.2-1.8 3.6 1.3v3A1.5 1.5 0 0 1 15 17.5 12.5 12.5 0 0 1 2.5 5 1.5 1.5 0 0 1 4 3.5Z"/></svg>Anrufen <small>0971 451 22 30</small></a></li>
<li role="none" style="--i:0"><a role="menuitem" tabindex="-1" href="#termin" class="erst"><svg viewBox="0 0 20 20"><rect x="3" y="4.5" width="14" height="12.5" rx="2"/><path d="M3 8.5h14M7 2.5v4M13 2.5v4"/></svg>Termin online buchen</a></li>
</ul>
<button type="button" class="fab" data-fab data-welt="fab" aria-haspopup="menu" aria-expanded="false" aria-controls="fab-menue" aria-label="Termin und Kontakt">
<span class="fab-ico" aria-hidden="true"><svg class="auf-ico" viewBox="0 0 22 22"><rect x="3" y="5" width="16" height="14" rx="2.2"/><path d="M3 9.5h16M7.5 2.8v4M14.5 2.8v4"/></svg><svg class="zu-ico" viewBox="0 0 22 22"><path d="M5.5 5.5l11 11M16.5 5.5l-11 11"/></svg></span>
<span class="fab-text" aria-hidden="true">Termin</span>
</button>
<section class="k-consent" data-consent data-welt="kontakt" aria-labelledby="consent-titel">
<h2 id="consent-titel">Cookies und Dienste</h2>
<p>Wir setzen nur technisch nötige Cookies. WhatsApp und die Karte laden erst, wenn Sie zustimmen.</p>
<div><button type="button" data-consent-wahl="noetig">Nur notwendige</button><button type="button" data-consent-wahl="alle">Alle erlauben</button></div>
</section>
<nav class="kleiste" data-kleiste data-welt="kontakt" aria-label="Kontakt">
<a class="k-tel" href="tel:+4981619482730" data-k-tel><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 3.5h3l1.3 3.6-1.8 1.2a8.5 8.5 0 0 0 5.2 5.2l1.2-1.8 3.6 1.3v3A1.5 1.5 0 0 1 15 17.5 12.5 12.5 0 0 1 2.5 5 1.5 1.5 0 0 1 4 3.5Z"/></svg><span>Anrufen <small>08161 948 27 30</small></span></a>
<a class="k-wa" href="#whatsapp" data-k-wa><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 16.5 4.6 13A7 7 0 1 1 7 15.4Z"/><path d="M7.6 7.4c.3 2 2.6 4.4 5 5"/></svg>WhatsApp</a>
<a class="k-anfrage" href="#anfrage" data-k-anfrage><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 5.5h14v10H3z"/><path d="m3 6 7 5.5L17 6"/></svg>Anfrage</a>
</nav>
<div class="tastatur" data-tastatur data-welt="kontakt" aria-hidden="true"></div>
<p class="k-toast" data-k-toast data-welt="kontakt" role="status"></p>
<div class="finger" data-finger aria-hidden="true"></div>
</div>
</div>
</div>
<div class="spalte rechts">
<p class="mono oben">47.66° N, 9.18° O</p>
<div>
<div class="umschalter" role="group" aria-label="Variante" style="margin-bottom:8px">
<button type="button" data-v-wahl="basis" aria-pressed="true">Menü-Leiste</button>
<button type="button" data-v-wahl="fab" aria-pressed="false">Daumen-Knopf</button>
<button type="button" data-v-wahl="kontakt" aria-pressed="false">Kontaktleiste</button>
</div>
<div class="umschalter" role="group" aria-label="Form" data-form-gruppe>
<button type="button" data-variante-wahl="morph" aria-pressed="true">Leiste</button>
<button type="button" data-variante-wahl="tabs" aria-pressed="false">Tab-Pille</button>
<button type="button" data-variante-wahl="drill" aria-pressed="false">Drill-down</button>
</div>
<dl class="werte" data-werte></dl>
<p class="mono k-zustand" data-k-zustand data-welt="kontakt" aria-live="polite"></p>
</div>
</div>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const mobil = () => matchMedia('(max-width: 560px)').matches;
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const tel = $('[data-telefon]'), scroller = $('[data-scroller]'), sheet = $('[data-sheet]'), griff = $('[data-griff]');
const knopf = $('[data-knopf]'), schleier = $('[data-schleier]'), ebenen = $('[data-ebenen]'), e2 = $('[data-e2]');
const tabs = $('[data-tabs]'), ind = $('[data-ind]'), finger = $('[data-finger]');
const mehr = $('[data-tab="mehr"]');
/* ---------- Bildwelt: Seestücke vom Bodensee als SVG ---------- */
const zufall = s => () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const verlauf = (id, st) => `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">${st.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join('')}</linearGradient>`;
const kamm = (r, y0, amp, n, w, rauh = 0) => { // Hügel- oder Bergkante als Pfad
const a = [r() * 6, r() * 6, r() * 6];
let d = `M0 ${y0}`;
for (let i = 0; i <= n; i++) {
const x = w * i / n, u = i / n;
let h = Math.sin(u * 3.1 + a[0]) * .5 + Math.sin(u * 7.3 + a[1]) * .3 + Math.sin(u * 17 + a[2]) * .2;
if (rauh) { // Grat-Rauschen: spitze Gipfel, weiche Täler
h = 0;
for (let o = 0, f = 4, g = .62; o < 4; o++, f *= 2.1, g *= .45) h += Math.pow(1 - Math.abs(Math.sin(u * f + a[o % 3] * (o + 1))), 2) * g;
h = h * (.7 + .3 * Math.sin(u * 2.2 + a[0])) + (r() - .5) * rauh;
} else h = (h + 1) / 2;
d += ` L${x.toFixed(1)} ${(y0 - h * amp).toFixed(1)}`;
}
return d + ` L${w} ${y0} Z`;
};
const wellen = (r, y0, y1, w, farbe, dichte, max) => {
let s = '';
for (let i = 0; i < dichte; i++) {
const t = Math.pow(r(), 1.6), y = y0 + (y1 - y0) * t, l = 8 + t * max * r();
s += `<rect x="${(r() * w).toFixed(1)}" y="${y.toFixed(1)}" width="${l.toFixed(1)}" height="${(.6 + t * 1.4).toFixed(2)}" fill="${farbe}"/>`;
}
return s;
};
const svg = (w, h, inhalt) => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice"><defs><filter id="w2"><feGaussianBlur stdDeviation="1.4"/></filter></defs>${inhalt}</svg>`);
const BILDER = [
{ text: 'Seerhein, Nebel, 06:40', alt: 'Dalben im Nebel auf dem Seerhein', mal() {
const r = zufall(3), H = 268; let p = '';
for (let k = 0; k < 11; k++) {
const t = k / 10, x = 52 + 196 * Math.pow(t, .62), hoe = 150 * Math.pow(1 - t, 1.5) + 5, br = 10 * (1 - t) + 1.6;
const f = Math.round(58 + 128 * Math.pow(t, .7)), farbe = `rgb(${f},${f + 5},${f + 6})`, fuss = H + 210 * Math.pow(1 - t, 2.1) + 2;
p += `<rect x="${x}" y="${fuss - hoe}" width="${br}" height="${hoe}" fill="${farbe}"/><rect x="${x}" y="${fuss}" width="${br}" height="${hoe * .55}" fill="${farbe}" opacity=".22" filter="url(#w2)"/>`;
}
return svg(400, 500, verlauf('a', [[0, '#c9cfcf'], [.75, '#dcdfdd'], [1, '#e3e4e1']]) + verlauf('b', [[0, '#d6d9d7'], [1, '#a2abac']]) + verlauf('n', [[0, 'rgba(230,231,228,0)'], [.5, 'rgba(230,231,228,.92)'], [1, 'rgba(230,231,228,0)']]) +
`<rect width="400" height="${H}" fill="url(#a)"/><rect y="${H}" width="400" height="232" fill="url(#b)"/>` + wellen(r, H + 4, 500, 400, 'rgba(255,255,255,.18)', 70, 60) +
`<rect y="${H - 46}" width="400" height="86" fill="url(#n)"/>` + p);
} },
{ text: 'Hörnle, Februar, 08:15', alt: 'Flacher See vor dem Schweizer Ufer im Februar', mal() {
const r = zufall(11), H = 300;
return svg(400, 500, verlauf('a', [[0, '#bfcbd3'], [1, '#e8ecec']]) + verlauf('b', [[0, '#b9c5cb'], [1, '#7e8f9a']]) +
`<rect width="400" height="${H}" fill="url(#a)"/><path d="${kamm(r, H, 16, 40, 400)}" fill="#b2bcc3"/><path d="${kamm(r, H, 7, 60, 400)}" fill="#8a97a0"/>` +
`<rect y="${H}" width="400" height="200" fill="url(#b)"/>` + wellen(r, H + 3, 500, 400, 'rgba(232,238,240,.22)', 80, 70) +
`<rect x="250" y="${H - 7}" width="16" height="3" fill="#6f7c86"/><rect x="255" y="${H - 11}" width="5" height="4" fill="#6f7c86"/>`);
} },
{ text: 'Föhn über dem Obersee, 17:30', alt: 'Alpenkette bei Föhn über dem Obersee', mal() {
const r = zufall(7), H = 312;
return svg(400, 500, verlauf('a', [[0, '#8ea5bb'], [.55, '#c6c5c6'], [1, '#ebc8ad']]) + verlauf('b', [[0, '#6c7e93'], [1, '#334357']]) +
`<radialGradient id="g" cx=".5" cy="0" r=".5"><stop offset="0" stop-color="#ecc6a8" stop-opacity=".42"/><stop offset="1" stop-color="#ecc6a8" stop-opacity="0"/></radialGradient>` +
`<rect width="400" height="${H}" fill="url(#a)"/><path d="${kamm(r, H - 4, 120, 160, 400, .02)}" fill="#a9b4c4"/><path d="${kamm(r, H, 12, 40, 400)}" fill="#7a899c"/>` +
`<rect y="${H}" width="400" height="188" fill="url(#b)"/><rect x="40" y="${H}" width="320" height="150" fill="url(#g)"/>` + wellen(r, H + 3, 500, 400, 'rgba(236,200,173,.2)', 90, 50));
} },
{ text: 'Fährhafen Staad, 21:10', alt: 'Lichter am Ufer und eine Fähre in der Dämmerung', mal() {
const r = zufall(5), H = 286; let l = '';
for (let i = 0; i < 18; i++) {
const x = 20 + r() * 360, y = H - 3 - r() * 4, hh = 30 + r() * 70;
l += `<rect x="${x - .8}" y="${H + 2}" width="1.6" height="${hh}" fill="#f2c88f" opacity=".32" filter="url(#w2)"/><circle cx="${x}" cy="${y}" r="${(1 + r() * .6).toFixed(2)}" fill="#f6d39e"/>`;
}
let f = '<rect x="236" y="279" width="64" height="7" fill="#0f1620"/><rect x="250" y="274" width="30" height="6" fill="#0f1620"/>';
for (let i = 0; i < 8; i++) f += `<rect x="${240 + i * 7}" y="281" width="3" height="1.6" fill="#f6d39e"/>`;
return svg(400, 500, verlauf('a', [[0, '#121d2f'], [.6, '#32425d'], [.9, '#776c7c'], [1, '#a9877a']]) + verlauf('b', [[0, '#1e293a'], [1, '#0b1119']]) +
`<rect width="400" height="${H}" fill="url(#a)"/><path d="${kamm(r, H, 9, 50, 400)}" fill="#141c28"/><rect y="${H}" width="400" height="214" fill="url(#b)"/>` +
wellen(r, H + 3, 500, 400, 'rgba(120,140,170,.14)', 70, 50) + l + f + `<rect x="246" y="${H + 2}" width="40" height="40" fill="#f6d39e" opacity=".12" filter="url(#w2)"/>`);
} },
{ text: 'Eiswinter vor der Mainau, 10:05', alt: 'Schilf im Eis, heller Wintermorgen', mal() {
const r = zufall(19), H = 228; let s = '';
for (let i = 0; i < 150; i++) {
const x = r() * 420 - 10, y0 = 500, hoe = 50 + r() * 95, b = (r() - .5) * 26, g = Math.round(110 + r() * 40);
s += `<path d="M${x.toFixed(1)} ${y0} q${(b * .3).toFixed(1)} ${(-hoe * .6).toFixed(1)} ${b.toFixed(1)} ${(-hoe).toFixed(1)}" stroke="rgb(${g},${g - 4},${g - 26})" stroke-width="${(.8 + r() * 1.1).toFixed(2)}" fill="none" opacity="${(.55 + r() * .45).toFixed(2)}"/>`;
}
let e = '';
for (let i = 0; i < 14; i++) { const y = H + 20 + Math.pow(r(), 1.3) * 210; e += `<path d="M${(r() * 400).toFixed(0)} ${y.toFixed(0)} l${(40 + r() * 120).toFixed(0)} ${(r() * 6 - 3).toFixed(1)}" stroke="#ffffff" stroke-width="1" opacity=".55"/>`; }
return svg(400, 500, verlauf('a', [[0, '#dfe4e4'], [1, '#f0f1ef']]) + verlauf('b', [[0, '#e5e8e7'], [1, '#cfd6d5']]) +
`<rect width="400" height="${H}" fill="url(#a)"/><path d="${kamm(r, H, 8, 70, 400)}" fill="#b9c0bf"/><rect y="${H}" width="400" height="272" fill="url(#b)"/>` + e + s);
} },
{ text: 'Sturmwarnung, Seestraße, 15:50', alt: 'Graue Wellen, eine orange Sturmwarnleuchte am Ufer', mal() {
const r = zufall(23), H = 262;
return svg(400, 500, verlauf('a', [[0, '#5b6469'], [.7, '#8b9395'], [1, '#bcc0be']]) + verlauf('b', [[0, '#57636a'], [1, '#2c353a']]) +
verlauf('n', [[0, 'rgba(205,208,205,0)'], [.6, 'rgba(205,208,205,.7)'], [1, 'rgba(205,208,205,0)']]) + `<rect width="400" height="${H}" fill="url(#a)"/><rect y="${H - 40}" width="400" height="50" fill="url(#n)"/><path d="${kamm(r, H, 5, 40, 400)}" fill="#6d777b"/>` +
`<rect y="${H}" width="400" height="238" fill="url(#b)"/>` + wellen(r, H + 3, 500, 400, 'rgba(225,230,230,.3)', 160, 40) +
`<rect x="66" y="196" width="3" height="150" fill="#22282b"/><rect x="62" y="190" width="11" height="8" rx="1.5" fill="#22282b"/><circle cx="67.5" cy="186" r="13" fill="#f29a3a" opacity=".35" filter="url(#w2)"/><circle cx="67.5" cy="186" r="3.6" fill="#f6a24a"/>`);
} },
{ text: 'Untersee, Januar', alt: '', mal() {
const r = zufall(31), H = 206; let s = '';
for (let i = 0; i < 110; i++) { const x = Math.pow(r(), 1.25) * 330 - 20, hoe = (140 + r() * 140) * (1 - x / 520), b = (r() - .3) * 34; s += `<path d="M${x.toFixed(1)} 362 q${(b * .3).toFixed(1)} ${(-hoe * .6).toFixed(1)} ${b.toFixed(1)} ${(-hoe).toFixed(1)}" stroke="#090c10" stroke-width="${(1 + r() * 1.6).toFixed(2)}" fill="none"/>`; }
return svg(640, 360, verlauf('a', [[0, '#28313f'], [.7, '#696c78'], [1, '#a8988f']]) + verlauf('b', [[0, '#525a68'], [1, '#2a303a']]) +
`<rect width="640" height="${H}" fill="url(#a)"/><path d="${kamm(r, H, 22, 60, 640)}" fill="#252c36"/><rect y="${H}" width="640" height="154" fill="url(#b)"/>` +
wellen(r, H + 3, 360, 640, 'rgba(168,152,143,.18)', 80, 60) + s);
} },
];
const quellen = BILDER.map(b => b.mal());
$$('img[data-bild]').forEach(img => { img.src = quellen[+img.dataset.bild]; if (!img.alt && img.closest('[data-dia]')) img.alt = BILDER[+img.dataset.bild].alt; });
/* ---------- Bildfolge (Crossfade, nur Opazität) ---------- */
const diaBilder = $$('[data-dia] img'), zaehler = $('[data-zaehler]'), bildtext = $('[data-bildtext]');
const SERIEN = [
{ titel: 'Seestücke', jahr: '2019–2026', start: 0 }, { titel: 'Uferzonen', jahr: '2021–2025', start: 4 },
{ titel: 'Wintergäste', jahr: '2023–2026', start: 1 }, { titel: 'Konstanzer Nächte', jahr: '2020–2024', start: 3 },
];
let bild = 0, diaUhr = null;
function zeigeBild(i) {
bild = (i + diaBilder.length) % diaBilder.length;
diaBilder.forEach((im, j) => { im.classList.toggle('an', j === bild); im.alt = j === bild ? BILDER[j].alt : ''; });
zaehler.textContent = String(bild + 1).padStart(2, '0') + ' / ' + String(diaBilder.length).padStart(2, '0');
bildtext.textContent = BILDER[bild].text;
}
function diaTakt() { clearInterval(diaUhr); if (!reduziert()) diaUhr = setInterval(() => zeigeBild(bild + 1), 4000); }
$('[data-dia]').addEventListener('click', () => { zeigeBild(bild + 1); diaTakt(); });
$('[data-dia]').addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zeigeBild(bild + 1); diaTakt(); } });
addEventListener('keydown', e => {
if (zustand.offen) return;
if (e.key === 'ArrowRight') { zeigeBild(bild + 1); diaTakt(); }
if (e.key === 'ArrowLeft') { zeigeBild(bild - 1); diaTakt(); }
});
/* ---------- Zustand ---------- */
let welt = 'basis';
const zustand = { variante: 'morph', offen: false, ebene: 1, ansicht: 'bilder', klein: false };
const NAMEN = { morph: 'Leiste wächst', tabs: 'Tab-Pille', drill: 'Drill-down' };
const WERTE = {
gemeinsam: [['Kurve', 'cubic-bezier(0.32, 0.72, 0, 1)'], ['Öffnen', '450 ms'], ['Schließen', '300 ms'], ['Stagger', '35 ms · 12 px'], ['Wischen', '80 px oder 0,5 px/ms']],
morph: [['Leiste', '56 px · Radius 28 px'], ['Klein', '48 px ab 40 px Scroll']],
tabs: [['Pille', '64 px · Ziel 74 × 56 px'], ['Indikator', '400 ms']],
drill: [['Ebene', 'translateX 100 % · 450 ms'], ['Leiste', '56 px · Radius 28 px']],
kontakt: [['Ein', '240 ms · cubic-bezier(0, 0, 0.38, 0.9)'], ['Aus', '150 ms · cubic-bezier(0.2, 0, 1, 0.9)'], ['Leiste', '68 px + Safe Area · 3 Ziele'], ['Ziele', 'min. 52 px hoch · Abstand 8 px'], ['scroll-padding', 'bottom 84 px'], ['Weicht', 'Consent · Tastatur · Footer'], ['Tastatur', 'visualViewport −150 px'], ['Signal', '#f2b705 nur für Anfrage']],
fab: [['Kurve', 'cubic-bezier(0.32, 0.72, 0, 1)'], ['Öffnen', '450 ms'], ['Schließen', '300 ms'], ['Stagger', '35 ms von unten · 12 px'], ['Knopf', '136 × 56 px · Radius 18 px'], ['Gescrollt', '56 × 56 px rund'], ['Menü', '4 × 52 px · Abstand 8 px'], ['Rollen', 'menu · menuitem']],
};
function zeigeWerte() {
const zeilen = welt === 'basis' ? [...WERTE.gemeinsam, ...WERTE[zustand.variante]] : WERTE[welt];
$('[data-werte]').innerHTML = zeilen.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('');
$('[data-variantenname]').textContent = welt === 'fab' ? 'Daumen-Knopf' : welt === 'kontakt' ? 'Kontaktleiste' : NAMEN[zustand.variante];
}
function setzeVariante(v) {
if (zustand.offen) schliessen(true);
const teile = [sheet, $('.leiste')];
teile.forEach(t => { t.style.transition = 'none'; }); // Variantenwechsel ohne Zwischenbewegung
zustand.variante = v; tel.dataset.variante = v;
sheet.offsetWidth; teile.forEach(t => { t.style.transition = ''; });
$$('[data-variante-wahl]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.varianteWahl === v)));
zuEbene(1, true); zeigeWerte(); setzeIndikator();
staffeln();
}
function staffeln() { // Staffelung nur über sichtbare Links
$$('.e1 .st, .sheetfuss', sheet).filter(el => el.offsetParent !== null || el.classList.contains('sheetfuss')).forEach((el, i) => el.style.setProperty('--i', i));
}
$$('[data-variante-wahl]').forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.varianteWahl); }));
/* Ansichten wechseln (Crossfade) */
function zeigeAnsicht(name) {
zustand.ansicht = name;
$$('[data-ansicht]').forEach(a => a.classList.toggle('an', a.dataset.ansicht === name));
$$('.e1 a').forEach(a => a.dataset.ziel === name ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current'));
$$('[data-tab]').forEach(t => { if (t.dataset.tab !== 'mehr') t.dataset.tab === name ? t.setAttribute('aria-current', 'page') : t.removeAttribute('aria-current'); });
setzeIndikator();
scrollNach(0);
}
function setzeIndikator() {
const t = zustand.offen && zustand.variante === 'tabs' ? 'mehr' : zustand.ansicht;
const knoepfe = $$('[data-tab]'), i = Math.max(0, knoepfe.findIndex(k => k.dataset.tab === t));
ind.style.transform = `translateX(${i * (zustand.klein ? 58 : 74)}px)`;
}
/* ---------- Öffnen / Schließen ---------- */
const fokusierbar = () => $$('a, button', sheet).filter(el => !el.closest('[inert]') && el.offsetParent !== null);
let zuletzt = null;
function oeffnen() {
if (zustand.offen) return;
zuletzt = document.activeElement;
zustand.offen = true; tel.classList.add('offen');
knopf.setAttribute('aria-expanded', 'true'); mehr.setAttribute('aria-expanded', 'true');
scroller.inert = true; tabs.inert = zustand.variante !== 'tabs' ? false : true; ebenen.inert = false;
setzeIndikator();
setTimeout(() => { const z = $('.e1 a', sheet); if (zustand.offen && !html.classList.contains('vorfuehrung')) z.focus({ preventScroll: true }); }, 60);
}
function schliessen(sofort) {
if (!zustand.offen) return;
zustand.offen = false; tel.classList.remove('offen');
knopf.setAttribute('aria-expanded', 'false'); mehr.setAttribute('aria-expanded', 'false');
scroller.inert = false; tabs.inert = false; ebenen.inert = true; // zu: Menü ist nur per clip-path versteckt, darf keinen Fokus bekommen
setzeIndikator();
setTimeout(() => { if (!zustand.offen) zuEbene(1, true); }, sofort ? 0 : 320);
if (zuletzt && sheet.contains(document.activeElement)) (zustand.variante === 'tabs' ? mehr : knopf).focus({ preventScroll: true });
}
function zuEbene(n, still) {
zustand.ebene = n;
if (still) ebenen.querySelectorAll('.ebene').forEach(e => { e.style.transition = 'none'; });
ebenen.classList.toggle('ebene-2', n === 2);
e2.inert = n !== 2; $('.e1').inert = n === 2;
if (still) { ebenen.offsetWidth; ebenen.querySelectorAll('.ebene').forEach(e => { e.style.transition = ''; }); }
else if (zustand.offen && !html.classList.contains('vorfuehrung')) setTimeout(() => (n === 2 ? $('[data-zurueck]') : $('.e1 a[data-unter]')).focus({ preventScroll: true }), 80);
}
knopf.addEventListener('click', () => zustand.offen ? schliessen() : oeffnen());
mehr.addEventListener('click', () => zustand.offen ? schliessen() : oeffnen());
schleier.addEventListener('click', () => schliessen());
$$('[data-tab]').forEach(t => { if (t !== mehr) t.addEventListener('click', () => zeigeAnsicht(t.dataset.tab)); });
$$('[data-ziel]').forEach(a => a.addEventListener('click', e => {
e.preventDefault();
if (a.hasAttribute('data-unter') && zustand.variante === 'drill') { zuEbene(2); return; }
if (a.dataset.ziel === 'bilder') setzeSerie(0);
zeigeAnsicht(a.dataset.ziel); schliessen();
}));
$('[data-zurueck]').addEventListener('click', () => zuEbene(1));
function setzeSerie(i) {
const s = SERIEN[i];
$('[data-serientitel]').textContent = s.titel; $('[data-serienjahr]').textContent = s.jahr;
zeigeBild(s.start); diaTakt();
}
$$('[data-serie]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); setzeSerie(+a.dataset.serie); zeigeAnsicht('bilder'); schliessen(); }));
// Tastatur: Esc schließt, Tab bleibt im offenen Sheet
addEventListener('keydown', e => {
if (!zustand.offen) return;
if (e.key === 'Escape') { e.preventDefault(); schliessen(); return; }
if (e.key !== 'Tab') return;
const f = fokusierbar(); if (!f.length) return;
const i = f.indexOf(document.activeElement);
if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
});
/* ---------- Variante fab: Daumen-Knopf mit Aktionsmenü (Menü-Semantik nach APG „Menu Button“) ---------- */
const fab = $('[data-fab]'), fabMenue = $('[data-fab-menue]'), fabSchleier = $('[data-fab-schleier]');
const fabEintraege = () => $$('[role="menuitem"]', fabMenue);
let fabOffen = false;
function fabAuf(fokus) {
if (fabOffen) return;
fabOffen = true; tel.classList.add('fab-offen');
fab.setAttribute('aria-expanded', 'true'); fabMenue.inert = false; scroller.inert = true;
if (fokus && !html.classList.contains('vorfuehrung')) { const e = fabEintraege(); setTimeout(() => (fokus === 'letzt' ? e[e.length - 1] : e[0]).focus({ preventScroll: true }), 30); }
}
function fabZu(zurueck) {
if (!fabOffen) return;
fabOffen = false; tel.classList.remove('fab-offen');
fab.setAttribute('aria-expanded', 'false'); fabMenue.inert = true; scroller.inert = false;
if (zurueck) fab.focus({ preventScroll: true });
}
fab.addEventListener('click', () => fabOffen ? fabZu(true) : fabAuf('erst'));
fab.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); fabAuf(e.key === 'ArrowUp' ? 'letzt' : 'erst'); }
});
fabMenue.addEventListener('keydown', e => {
const liste = fabEintraege(), i = liste.indexOf(document.activeElement);
const geh = j => { e.preventDefault(); liste[(j + liste.length) % liste.length].focus({ preventScroll: true }); };
if (e.key === 'ArrowDown') geh(i + 1);
else if (e.key === 'ArrowUp') geh(i - 1);
else if (e.key === 'Home') geh(0);
else if (e.key === 'End') geh(liste.length - 1);
else if (e.key === 'Escape') { e.preventDefault(); fabZu(true); }
else if (e.key === 'Tab') fabZu(false);
else if (e.key.length === 1 && /\S/.test(e.key)) { // Erstbuchstabe springt
const k = e.key.toLowerCase(), t = liste.findIndex((a, j) => j > i && a.textContent.trim().toLowerCase().startsWith(k));
const u = t >= 0 ? t : liste.findIndex(a => a.textContent.trim().toLowerCase().startsWith(k));
if (u >= 0) geh(u);
}
});
fabEintraege().forEach(a => a.addEventListener('click', e => { e.preventDefault(); fabZu(true); }));
fabSchleier.addEventListener('click', () => fabZu(false));
$$('.salon a').forEach(a => a.addEventListener('click', e => e.preventDefault()));
/* ---------- Variante kontakt: untere Kontaktleiste (Anrufen, WhatsApp, Anfrage), weicht Consent, Tastatur und Footer ---------- */
const kl = $('[data-kleiste]'), consent = $('[data-consent]'), tastatur = $('[data-tastatur]'), kFuss = $('[data-k-fuss]'), toast = $('[data-k-toast]'), kZustand = $('[data-k-zustand]');
const betrieb = $('.betrieb'), grob = matchMedia('(pointer: coarse)');
const kz = { consent: true, fuss: false, tastatur: false };
tastatur.innerHTML = ['qwertzuiopü', 'asdfghjklöä', '⇧yxcvbnm⌫', ['123', ',', 'Leerzeichen', '.', '↵']].map(r =>
`<div>${[...(Array.isArray(r) ? r : r)].map(k => `<span class="${k.length > 1 ? 'w' : ''}${k === 'Leerzeichen' ? ' lang' : ''}">${k === 'Leerzeichen' ? '' : k}</span>`).join('')}</div>`).join('');
function kontaktZeigen() {
const grund = kz.consent ? 'Consent-Banner offen' : kz.tastatur ? 'Tastatur offen' : kz.fuss ? 'Footer erreicht' : '';
tel.classList.toggle('k-aus', !!grund);
kl.inert = !!grund; // unsichtbare Leiste bekommt keinen Fokus
kZustand.innerHTML = grund ? `Leiste <b>aus</b> · ${grund}` : 'Leiste <b>sichtbar</b>';
}
const istFeld = el => !!el && el.matches && el.matches('input:not([type=checkbox]):not([type=radio]):not([type=submit]), textarea');
const sk = () => mobil() ? 1 : +getComputedStyle(html).getPropertyValue('--s') || 1;
// Footer: sobald er die Leiste erreichen würde, geht sie weg (dort stehen Telefon und Adresse groß)
function fussPruefen() {
if (welt !== 'kontakt') return;
const unten = mobil() ? innerHeight : scroller.getBoundingClientRect().bottom;
const f = kFuss.getBoundingClientRect().top < unten - kl.getBoundingClientRect().height;
if (f !== kz.fuss) { kz.fuss = f; kontaktZeigen(); }
}
// Tastatur: am echten Telefon über visualViewport (Höhe schrumpft um mehr als 150 px) oder Fokus in einem Feld bei grobem Zeiger;
// in der Attrappe am Desktop erscheint eine gezeichnete Tastatur
const vvOffen = () => !!window.visualViewport && innerHeight - visualViewport.height > 150;
function tastaturSetzen(auf, feld) {
if (welt !== 'kontakt') return;
if (!mobil()) {
tel.classList.toggle('tastatur-auf', auf);
if (auf && feld) setTimeout(() => feld.scrollIntoView({ block: 'nearest' }), 20);
}
kz.tastatur = mobil() ? (auf && grob.matches) || vvOffen() : auf;
kontaktZeigen();
}
if (window.visualViewport) visualViewport.addEventListener('resize', () => { if (welt === 'kontakt' && mobil()) { kz.tastatur = vvOffen() || (grob.matches && istFeld(document.activeElement)); kontaktZeigen(); } });
betrieb.addEventListener('focusin', e => { if (istFeld(e.target)) tastaturSetzen(true, e.target); });
betrieb.addEventListener('focusout', () => setTimeout(() => { if (!istFeld(document.activeElement)) tastaturSetzen(false); }, 60));
// Fokus nie unter der Leiste (WCAG 2.4.11): scroll-padding-bottom im CSS, hier die Rückfallebene
document.addEventListener('focusin', e => {
if (welt !== 'kontakt' || springt || tel.classList.contains('k-aus') || kl.contains(e.target) || consent.contains(e.target) || !betrieb.contains(e.target)) return;
const r = e.target.getBoundingClientRect(), oben = kl.getBoundingClientRect().top - 16;
if (r.bottom > oben) { const d = (r.bottom - oben) / sk(); if (mobil()) scrollBy(0, d); else scroller.scrollTop += d; }
});
// Consent: Leiste erst nach der Wahl; „Cookie-Einstellungen“ im Footer öffnet den Banner wieder
function consentSetzen(offen, fokus) {
consent.hidden = !offen; kz.consent = offen; kontaktZeigen();
if (offen && fokus) consent.querySelector('button').focus({ preventScroll: true });
}
$$('[data-consent-wahl]').forEach(b => b.addEventListener('click', () => {
const vonFuss = consent.dataset.vonFuss; consentSetzen(false); delete consent.dataset.vonFuss;
if (vonFuss) $('[data-consent-auf]').focus({ preventScroll: true });
}));
$('[data-consent-auf]').addEventListener('click', () => { consent.dataset.vonFuss = '1'; consentSetzen(true, true); });
// Demo-Rückmeldung statt Wählen
let toastUhr = 0;
function melde(text) { toast.textContent = text; toast.classList.add('da'); clearTimeout(toastUhr); toastUhr = setTimeout(() => toast.classList.remove('da'), 2200); }
$$('[data-k-tel]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); melde('Wählt 08161 948 27 30 (in der Demo ohne Anruf)'); }));
$('[data-k-wa]').addEventListener('click', e => { e.preventDefault(); melde('Öffnet den Chat in WhatsApp (in der Demo nicht)'); });
$$('.betrieb a[href^="#"]:not([data-k-anfrage])').forEach(a => a.addEventListener('click', e => { if (a.hash !== '#anfrage') e.preventDefault(); }));
// Anfrage: zum Formular springen, Fokus auf die Überschrift (nicht ins Feld, sonst öffnet sich sofort die Tastatur)
let springt = false; // während des Sprungs keine Rückfall-Korrektur, sie würde das weiche Scrollen abbrechen
function zuAnfrage() {
const ziel = $('#anfrage-titel'), weich = reduziert() ? 'auto' : 'smooth';
springt = true; setTimeout(() => { springt = false; }, 50);
if (mobil()) scrollTo({ top: ziel.getBoundingClientRect().top + scrollY - 16, behavior: weich });
else scroller.scrollTo({ top: scroller.scrollTop + (ziel.getBoundingClientRect().top - scroller.getBoundingClientRect().top) / sk() - 64, behavior: weich });
ziel.focus({ preventScroll: true });
}
$('[data-k-anfrage]').addEventListener('click', e => { e.preventDefault(); zuAnfrage(); });
$('[data-k-form]').addEventListener('submit', e => { e.preventDefault(); document.activeElement.blur(); melde('Danke. Wir melden uns am nächsten Werktag.'); });
function kontaktGrund() { tastaturSetzen(false); kz.fuss = false; consentSetzen(true); toast.classList.remove('da'); }
/* Varianten nach Regel 13: ?v=basis (Menü-Leiste, Tab-Pille, Drill-down) | fab (Daumen-Knopf) | kontakt (Kontaktleiste) */
const WELTEN = ['basis', 'fab', 'kontakt'];
const vKnoepfe = $$('[data-v-wahl]'), formGruppe = $('[data-form-gruppe]');
function setzeWelt(v, url = true) {
if (zustand.offen) schliessen(true);
fabZu(false); tastaturSetzen(false);
welt = v; html.dataset.v = v;
vKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.vWahl === v)));
formGruppe.hidden = v !== 'basis';
if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
scrollNach(0); zeigeWerte();
if (v === 'kontakt') { kz.fuss = false; kontaktZeigen(); fussPruefen(); }
}
vKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeWelt(b.dataset.vWahl); }));
/* ---------- Wischen zum Schließen (nur am Griff, Schwelle 80 px oder 0,5 px/ms) ---------- */
const SCHWELLE = 80, TEMPO = 0.5;
let zug = null;
function zugStart(y) { if (!zustand.offen) return false; zug = { y0: y, dy: 0, t: performance.now(), v: 0, ly: y }; sheet.style.transition = 'none'; return true; }
function zugBewegen(y) {
if (!zug) return;
const s = mobil() ? 1 : +getComputedStyle(html).getPropertyValue('--s') || 1;
const now = performance.now(), roh = (y - zug.y0) / s;
zug.v = ((y - zug.ly) / s) / Math.max(1, now - zug.t); zug.t = now; zug.ly = y;
zug.dy = roh < 0 ? roh * 0.2 : roh; // nach oben nur gedämpft
sheet.style.transform = `translateY(${zug.dy}px)`;
schleier.style.opacity = String(Math.max(0, 1 - zug.dy / 320));
}
function zugEnde() {
if (!zug) return;
const zu = zug.dy > SCHWELLE || zug.v > TEMPO;
zug = null;
sheet.style.transition = ''; sheet.style.transform = ''; schleier.style.opacity = '';
if (zu) schliessen();
}
griff.addEventListener('pointerdown', e => { if (zugStart(e.clientY)) { griff.setPointerCapture(e.pointerId); if (e.isTrusted) stopVorfuehrung(); } });
griff.addEventListener('pointermove', e => zugBewegen(e.clientY));
griff.addEventListener('pointerup', zugEnde);
griff.addEventListener('pointercancel', zugEnde);
/* ---------- Leiste schrumpft beim Runterscrollen ---------- */
let letzteY = 0;
const scrollY = () => mobil() ? window.scrollY : scroller.scrollTop;
function beimScrollen() {
const y = scrollY(), d = y - letzteY; letzteY = y;
let k = zustand.klein;
if (y < 40) k = false; else if (d > 2) k = true; else if (d < -4) k = false;
if (k !== zustand.klein) { zustand.klein = k; tel.classList.toggle('klein', k); setzeIndikator(); }
fussPruefen();
}
scroller.addEventListener('scroll', beimScrollen, { passive: true });
addEventListener('scroll', beimScrollen, { passive: true });
function scrollNach(y) { if (mobil()) window.scrollTo(0, y); else scroller.scrollTop = y; letzteY = y; zustand.klein = false; tel.classList.remove('klein'); if (typeof fussPruefen === 'function') fussPruefen(); }
/* ---------- Telefon auf die Fensterhöhe skalieren ---------- */
function skalieren() {
const s = mobil() ? 1 : Math.min(1, (innerHeight - 40) / 794, (innerWidth * .52) / 389);
html.style.setProperty('--s', s.toFixed(4));
}
addEventListener('resize', skalieren); skalieren();
/* ---------- Vorführung: gedachter Finger ---------- */
let lauf = 0;
const halt = () => { const g = lauf; return ms => new Promise((ok, nein) => setTimeout(() => g === lauf ? ok() : nein('stop'), ms)); };
function lokal(el, fx = .5, fy = .5) {
const t = tel.getBoundingClientRect(), r = el.getBoundingClientRect(), s = mobil() ? 1 : +getComputedStyle(html).getPropertyValue('--s') || 1;
const bx = mobil() ? 0 : 7;
return { x: (r.left - t.left) / s - bx + r.width / s * fx, y: (r.top - t.top) / s - bx + r.height / s * fy };
}
function fingerAuf(p, ms) {
finger.style.transition = ms ? `transform ${ms}ms var(--kurve), opacity 200ms linear` : 'opacity 200ms linear';
finger.style.transform = `translate(${p.x}px, ${p.y}px)`;
}
async function tippe(w, el, fx, fy) {
finger.classList.add('da');
fingerAuf(lokal(el, fx, fy), 560); await w(600);
finger.classList.add('tipp'); await w(150);
el.click(); finger.classList.remove('tipp'); await w(80);
}
async function wische(w) {
finger.classList.add('da');
const p = lokal(griff); fingerAuf(p, 520); await w(600);
finger.classList.add('tipp'); await w(120);
zugStart(0);
const dauer = 520, t0 = performance.now(), weg = 150;
await new Promise(ok => {
const s = mobil() ? 1 : +getComputedStyle(html).getPropertyValue('--s') || 1;
const schritt = t => {
const q = Math.min(1, (t - t0) / dauer), e = q * q * (3 - 2 * q), dy = e * weg;
zugBewegen(dy * s); fingerAuf({ x: p.x, y: p.y + dy }, 0);
q < 1 ? requestAnimationFrame(schritt) : ok();
};
requestAnimationFrame(schritt);
});
zugEnde(); finger.classList.remove('tipp');
fingerAuf({ x: p.x, y: p.y + 150 }, 0); await w(140);
finger.classList.remove('da');
}
async function scrolle(w, ziel, dauer) {
const von = scrollY(), t0 = performance.now();
await new Promise(ok => {
const schritt = t => {
const q = Math.min(1, (t - t0) / dauer), e = q < .5 ? 2 * q * q : 1 - Math.pow(-2 * q + 2, 2) / 2, y = von + (ziel - von) * e;
if (mobil()) window.scrollTo(0, y); else scroller.scrollTop = y;
q < 1 ? requestAnimationFrame(schritt) : ok();
};
requestAnimationFrame(schritt);
});
await w(50);
}
const SZENEN = {
async kontakt(w) {
await w(700);
await tippe(w, $('[data-consent-wahl="noetig"]'), .5, .5); finger.classList.remove('da');
await w(1300); // Leiste fährt nach der Wahl herein
await tippe(w, $('.kleiste .k-tel'), .4, .5); await w(1700);
await tippe(w, $('[data-k-anfrage]'), .5, .5); finger.classList.remove('da');
await w(1300); // zum Formular gesprungen
const plz = $('.k-form input[name="plz"]');
await tippe(w, plz, .3, .5); plz.focus({ preventScroll: true }); finger.classList.remove('da');
await w(1800); // Tastatur offen, Leiste weg
plz.blur(); await w(1100);
await scrolle(w, mobil() ? document.documentElement.scrollHeight - innerHeight : scroller.scrollHeight - scroller.clientHeight, 1400); await w(1500); // Footer: Leiste weg
await scrolle(w, 0, 900); await w(700);
},
async fab(w) {
await w(400);
await tippe(w, fab, .5, .5); await w(1700); // Menü öffnet sich über dem Daumen
await tippe(w, fabSchleier, .35, .35); finger.classList.remove('da');
await w(700);
await scrolle(w, 420, 1400); await w(900); // Knopf wird beim Scrollen rund
await tippe(w, fab, .5, .5); await w(1500);
await tippe(w, fabEintraege()[3], .45, .5); finger.classList.remove('da');
await w(800);
await scrolle(w, 0, 800); await w(600);
},
async morph(w) {
await w(350);
await tippe(w, knopf); // öffnen
await w(1500);
await tippe(w, $('.e1 a[data-ziel="film"]')); // Ziel wählen, Sheet schließt
finger.classList.remove('da'); await w(1300);
await tippe(w, knopf); await w(1000);
await wische(w); await w(700); // per Wisch schließen
await tippe(w, $('.kopf a')); finger.classList.remove('da');
await w(700);
await scrolle(w, 420, 1400); await w(900); // Leiste schrumpft
await scrolle(w, 300, 600); await w(900); // wächst beim Hochscrollen
await scrolle(w, 0, 700); await w(500);
},
async tabs(w) {
await w(350);
await tippe(w, $('[data-tab="serien"]')); await w(1100);
await tippe(w, $('[data-tab="film"]')); await w(900);
await tippe(w, mehr); await w(1500);
await wische(w); await w(700);
await tippe(w, $('[data-tab="bilder"]')); finger.classList.remove('da');
await w(600);
await scrolle(w, 420, 1300); await w(1000);
await scrolle(w, 0, 800); await w(600);
},
async drill(w) {
await w(350);
await tippe(w, knopf); await w(900);
await tippe(w, $('.e1 a[data-unter]'), .3); await w(1500); // Unterebene
await tippe(w, $('[data-serie="2"]'), .5); finger.classList.remove('da');
await w(1700);
await tippe(w, knopf); await w(800);
await tippe(w, $('.e1 a[data-unter]'), .3); await w(900);
await tippe(w, $('[data-zurueck]'), .3); await w(900);
await tippe(w, knopf); finger.classList.remove('da');
await w(700);
setzeSerie(0);
},
};
const FOLGE = ['morph', 'tabs', 'drill'];
async function vorfuehren() {
lauf++;
const w = halt();
html.classList.add('vorfuehrung');
grundzustand(false);
try {
for (;;) {
await SZENEN[welt === 'basis' ? zustand.variante : welt](w);
grundzustand(true);
if (welt === 'basis') setzeVariante(FOLGE[(FOLGE.indexOf(zustand.variante) + 1) % FOLGE.length]);
await w(400);
}
} catch (e) { if (e !== 'stop') throw e; }
}
function grundzustand(variante) {
if (zug) zugEnde();
schliessen(true); zuEbene(1, true); fabZu(false);
if (welt === 'kontakt') kontaktGrund();
finger.classList.remove('da', 'tipp');
if (zustand.ansicht !== 'bilder') zeigeAnsicht('bilder'); else scrollNach(0);
if (!variante) fingerAuf({ x: 300, y: 700 }, 0);
}
function stopVorfuehrung() { if (html.classList.contains('vorfuehrung')) { lauf++; html.classList.remove('vorfuehrung'); finger.classList.remove('da', 'tipp'); } }
tel.addEventListener('pointerdown', e => { if (e.isTrusted) stopVorfuehrung(); }, true);
function pause() { lauf++; }
function weiter() { if (html.classList.contains('vorfuehrung')) vorfuehren(); }
function neustart() { lauf++; html.classList.remove('vorfuehrung'); grundzustand(false); setzeSerie(0); zeigeBild(0); diaTakt(); }
zeigeBild(0); diaTakt(); zeigeWerte(); setzeIndikator(); staffeln();
const ausUrl = new URLSearchParams(location.search).get('v');
setzeWelt(WELTEN.includes(ausUrl) ? ausUrl : WELTEN[0], false);
})();
</script>
</body>
</html>