Zustandsautomat mit Datenbindung (Rive-Prinzip)
Ein Rechner-Hero, dessen Illustration ein Zustandsautomat steuert: Zahl, Schalter und Auslöser treiben Stapel, Prüfstation, Freigabe-Tor und Ergebnis live, wie bei Rive mit View Model und State Machine.
Eine Illustration, die nicht abgespielt, sondern gesteuert wird. Ein Zustandsautomat kennt wenige Zustände (Ruhe, Einlesen, Prüfen, Freigabe, Gebucht) und wechselt sie nach Bedingungen. Eingänge wie bei Rive treiben ihn: eine Zahl, ein Wahrheitswert und ein Auslöser. Über Datenbindung hängen Zahlen und Texte der Illustration direkt an einem Datenmodell (View Model). Typischer Einsatz: der Hero eines Rechners. Der Schieberegler ändert die Menge, die Grafik zeigt sie, das Ergebnis rechnet live mit.
Die Grafik erklärt das Produkt, während man es ausprobiert. Jede Eingabe hat eine sichtbare Folge. Das wirkt wie ein Stück der echten App und nicht wie ein Werbefilm.
Wann einsetzen
Rechner und Konfiguratoren im Hero (Ersparnis, Preis, Raten), Onboarding-Zustände, Status-Illustrationen in Produkten, Maskottchen mit Zuständen. Passt zu SaaS, Handwerk mit Kostenrechner und Kursen.
Wann nicht
Wenn die Illustration nur Deko ist und kein Wert sie steuert. Wenn niemand die Grafik pflegen kann (eine .riv-Datei braucht den Rive-Editor). Nicht als einzige Stelle für das Ergebnis: Zahlen gehören zusätzlich als Text ins HTML.
Varianten
- Rechner-Hero: Zahl und Schalter steuern Illustration und Ergebnis
- Status-Illustration in der App (Leer, Lädt, Fertig, Fehler)
- Figur oder Maskottchen mit Zuständen (siehe maskottchen-zustaende)
- Echte Rive-Datei mit State Machine und View Model über die Web-Runtime
- Nachbau ohne Rive: SVG plus kleiner Zustandsautomat in JS
Umsetzung
Mit Rive: Artboard mit Illustration und State Machine „Fluss“ (Ruhe → Einlesen → Prüfen → Freigabe → Gebucht), dazu ein Layer für den Stapel als 1D-Blend über die Belegzahl. View Model „Rechner“ mit belege (Number), vierAugen (Boolean), durchlauf (Trigger) und ersparnis (String über Formel- und Text-Converter). Übergänge hängen an Bedingungen auf diesen Eigenschaften. Im Web: const r = new rive.Rive({ src: 'rechner.riv', canvas, stateMachine: 'Fluss', autoplay: true, autoBind: true, onLoad() { r.resizeDrawingSurfaceToCanvas(); const vm = r.viewModelInstance; regler.oninput = () => vm.number('belege').value = +regler.value; schalter.onclick = () => vm.boolean('vierAugen').value = an; knopf.onclick = () => vm.trigger('durchlauf').trigger(); vm.string('ersparnis').on(e => ausgabe.textContent = e.data); } }). Paket @rive-app/canvas (MIT), lädt zur Laufzeit eine WASM-Datei. Ohne Rive wie in der Demo: SVG-Illustration, Eigenschaften mit .value/.trigger()/.on(), eine advance(dt)-Schleife mit requestAnimationFrame (1. Änderungen an Bindungen melden, 2. Bedingungen prüfen und Zustand wechseln, 3. Zeitleiste des Zustands zeichnen). Schleife schläft im Ruhezustand. Auslöser während eines Durchlaufs verfallen. Ergebnis immer auch als HTML-Text mit aria-live ausgeben.
Robust umsetzen
Telefon. Gleiches Verhalten, Eingaben unter der Illustration. Schieberegler mit großem Griff, Zahl zusätzlich als Eingabefeld. Bei Rive: isTouchScrollEnabled setzen, damit die Seite über dem Canvas scrollt.
Ohne Bewegung. Zustände wechseln sofort, nichts wandert. Zahlen springen auf den Endwert. Die Illustration zeigt immer den Endzustand.
Barrierefrei. Die Illustration ist Beiwerk: aria-hidden oder role=img mit kurzer Beschreibung. Eingaben sind echte Formularelemente (input type=range plus Zahlfeld, button role=switch mit aria-checked, normaler button für den Auslöser), mit Tastatur bedienbar. Ergebnis als Text in einer Region mit aria-live=polite, beim Schieben entprellt. Ein Rive-Canvas allein ist für Screenreader leer.
Code
<!DOCTYPE html>
<!--
Zustandsautomat mit Datenbindung (Rive-Prinzip)
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/rive-datenbindung/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zustandsautomat mit Datenbindung (Rive-Prinzip) · 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: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept saas-produkt-praezise: Graustufen tragen alles, ein Markenton für Aktion, Fokus und aktive Zustände */
--papier: #fafafa; --karte: #ffffff; --tinte: #161616; --leise: #62666d; --zart: #a1a1aa;
--linie: #e4e4e7; --flaeche: #f1f1f3; --marke: #0d7a5f; --marke-hell: #e3f1ec; --fehler: #b42318;
/* easeOutQuad wie Linear; Dauern aus dem Codrops-Rechner (Größe 150 ms, Tabs 100 ms) */
--kurve: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ui: 150ms; --tab: 100ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Geist', system-ui, sans-serif; letter-spacing: -.006em; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button, input { font: inherit; color: inherit; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.ziffern { font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.seite { max-width: 1200px; margin: 0 auto; padding: 0 clamp(16px, 3.4vw, 40px) 72px; }
/* ---------- Kopf ---------- */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 64px; border-bottom: 1px solid var(--linie); }
.marke { display: inline-flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 600; letter-spacing: -.03em; color: inherit; text-decoration: none; }
.marke svg { width: 22px; height: 22px; flex: none; }
.kopf nav { display: flex; gap: 26px; font-size: 14px; font-weight: 500; color: var(--leise); }
.kopf nav a, .kopf__rechts a { color: inherit; text-decoration: none; transition: color var(--ui) var(--kurve); }
.kopf__rechts .knopf--primaer { color: #fff; } /* Zugang: Weiß wie im Hero (vorher erbte der Kopf-Knopf die Tinte, 3,4:1) */
.kopf nav a:hover { color: var(--tinte); }
.kopf__rechts { display: flex; align-items: center; gap: 16px; font-size: 14px; font-weight: 500; }
.kopf__rechts .leise { color: var(--leise); }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; padding: 0 15px; border-radius: 8px; border: 1px solid transparent; font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; white-space: nowrap; transition: background-color var(--ui) var(--kurve), border-color var(--ui) var(--kurve), color var(--ui) var(--kurve), scale var(--tab) var(--kurve); }
.knopf--primaer { background: var(--marke); color: #fff; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .16), 0 1px 2px rgba(22, 22, 22, .18); }
.knopf--primaer:hover { background: #0a6650; }
.knopf--zweit { background: var(--karte); border-color: var(--linie); color: var(--tinte); }
.knopf--zweit:hover { border-color: #c9c9cf; }
.knopf:active, .knopf.gedrueckt { scale: .97; }
:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
/* ---------- Hero ---------- */
.hero { display: grid; grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr); gap: clamp(28px, 4.4vw, 64px); align-items: center; padding-top: clamp(36px, 7vh, 84px); }
.hero__text { display: grid; gap: 22px; align-content: center; container-type: inline-size; min-width: 0; }
.hero h1 { font-size: min(clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem), 16.5cqi); font-weight: 580; letter-spacing: -.022em; line-height: 1; text-wrap: balance; }
.hero h1 em { font-style: normal; color: var(--leise); }
.unterzeile { font-size: 1.0625rem; line-height: 1.6; color: var(--leise); max-width: 44ch; text-wrap: pretty; }
.ctas { display: flex; flex-wrap: wrap; gap: 10px; }
.ctas .knopf { height: 42px; padding: 0 18px; font-size: 15px; }
.vertrauen { display: flex; flex-wrap: wrap; gap: 6px 18px; padding-top: 8px; border-top: 1px solid var(--linie); }
.vertrauen span::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--marke); margin-right: 8px; vertical-align: 1px; }
/* ---------- Rechner-Karte (das Muster) ---------- */
.rechner { background: var(--karte); border: 1px solid var(--linie); border-radius: 14px; box-shadow: 0 1px 2px rgba(22, 22, 22, .04), 0 18px 40px -18px rgba(22, 22, 22, .16); overflow: hidden; }
.rechner__kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--linie); }
.rechner__kopf .mono { font-size: 10.5px; }
.status { display: inline-flex; align-items: center; gap: 8px; height: 24px; padding: 0 10px 0 9px; border-radius: 999px; background: var(--flaeche); font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.status i { width: 6px; height: 6px; border-radius: 50%; background: var(--zart); transition: background-color var(--ui) var(--kurve); }
.status[data-z]:not([data-z="ruhe"]) { background: var(--marke-hell); color: var(--marke); }
.status[data-z]:not([data-z="ruhe"]) i { background: var(--marke); }
.bild { position: relative; background:
radial-gradient(circle at 1px 1px, rgba(22, 22, 22, .07) 1px, transparent 1.4px) 0 0 / 16px 16px, var(--karte); }
.bild svg { display: block; width: 100%; height: auto; overflow: visible; }
/* SVG-Grundstil: feine Linien wie im Produkt */
.str { fill: none; stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; }
.blatt-f { fill: #fff; stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; }
.zeile { fill: #d9d9de; }
.kopfbalken { fill: var(--tinte); }
.sv-mono { font: 500 8.5px 'Geist Mono', ui-monospace, monospace; letter-spacing: .12em; fill: var(--leise); text-transform: uppercase; }
.sv-zahl { font: 560 22px 'Geist', system-ui, sans-serif; letter-spacing: -.02em; fill: var(--tinte); font-variant-numeric: tabular-nums; }
.sv-klein { font: 500 7px 'Geist Mono', ui-monospace, monospace; fill: var(--tinte); font-variant-numeric: tabular-nums; }
/* Stapel: 1D-Blend über die Zahl „belege“ (jedes Blatt 150 ms) */
.stapel-blatt { transition: opacity var(--ui) var(--kurve), transform var(--ui) var(--kurve); }
.stapel-blatt.weg { opacity: 0; transform: translateY(-8px); }
.leer-hinweis { opacity: 0; transition: opacity var(--ui) var(--kurve); }
.ist-leer .leer-hinweis { opacity: 1; }
/* Stationen */
.station-punkt { fill: #fff; stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; transition: fill var(--ui) var(--kurve), stroke var(--ui) var(--kurve); }
.station-punkt.an { fill: var(--marke); stroke: var(--marke); }
.station-text { transition: fill var(--ui) var(--kurve); }
.station-text.an { fill: var(--marke); }
.fortschritt { stroke: var(--marke); stroke-width: 2; vector-effect: non-scaling-stroke; }
/* Prüfstation: Linse */
.linse-glas { fill: rgba(13, 122, 95, .05); stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; }
.linse-ring { fill: none; stroke: var(--tinte); stroke-width: 1; stroke-dasharray: 2 5; vector-effect: non-scaling-stroke; }
.scan { stroke: var(--marke); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.fund { opacity: 0; transform: translateY(4px); transition: opacity var(--ui) var(--kurve), transform var(--ui) var(--kurve); }
.fund.an { opacity: 1; transform: none; }
.fund rect { fill: #fff; stroke: var(--linie); stroke-width: 1; vector-effect: non-scaling-stroke; }
.fund .ok { fill: var(--marke); }
/* Freigabe-Tor: Boolean „vierAugen“ */
.tor { transition: opacity var(--ui) var(--kurve); }
.tor.aus { opacity: .22; }
.tor .pfosten { transition: stroke-dasharray var(--ui) var(--kurve); }
.tor.aus .pfosten { stroke-dasharray: 3 4; }
.tor-aus-text { opacity: 0; transition: opacity var(--ui) var(--kurve); }
.tor.aus + .tor-aus-text { opacity: 1; }
.kopf-kreis { fill: #fff; stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; transition: fill var(--ui) var(--kurve), stroke var(--ui) var(--kurve); }
.kopf-kreis.ok { fill: var(--marke-hell); stroke: var(--marke); }
.haken { fill: none; stroke: var(--marke); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; stroke-dasharray: 14; stroke-dashoffset: 14; transition: stroke-dashoffset var(--ui) var(--kurve); }
.haken.an { stroke-dashoffset: 0; }
/* Ordner */
.stempel { opacity: 0; transform-box: fill-box; transform-origin: center; transform: scale(.8); transition: opacity var(--ui) var(--kurve), transform var(--ui) var(--kurve); }
.stempel.an { opacity: 1; transform: none; }
/* ---------- Eingänge (Data Binding) ---------- */
.eingaenge { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 12px; align-items: center; padding: 16px; border-top: 1px solid var(--linie); }
.eingaenge .feld { grid-column: 1 / -1; }
.eingaenge .schalter { justify-self: start; }
.feld { display: grid; gap: 8px; min-width: 0; }
.feld__kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.feld__kopf label { font-size: 13px; font-weight: 500; color: var(--tinte); }
.regler-zeile { display: grid; grid-template-columns: minmax(0, 1fr) 84px; gap: 12px; align-items: center; }
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 24px; background: transparent; cursor: pointer; --anteil: 21%; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 4px; background: linear-gradient(to right, var(--marke) var(--anteil), var(--linie) var(--anteil)); }
input[type="range"]::-moz-range-track { height: 4px; border-radius: 4px; background: var(--linie); }
input[type="range"]::-moz-range-progress { height: 4px; border-radius: 4px; background: var(--marke); }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: #fff; border: 1px solid #c9c9cf; box-shadow: 0 1px 3px rgba(22, 22, 22, .2); transition: scale var(--tab) var(--kurve); }
input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1px solid #c9c9cf; box-shadow: 0 1px 3px rgba(22, 22, 22, .2); }
input[type="range"]:active::-webkit-slider-thumb, input[type="range"].zieht::-webkit-slider-thumb { scale: 1.12; }
input[type="range"]:focus-visible { outline: none; }
input[type="range"]:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--marke); outline-offset: 2px; }
input[type="range"]:focus-visible::-moz-range-thumb { outline: 2px solid var(--marke); outline-offset: 2px; }
.zahlfeld { width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--linie); border-radius: 8px; background: var(--karte); font: 500 14px/1 'Geist Mono', ui-monospace, monospace; text-align: right; font-variant-numeric: tabular-nums; transition: border-color var(--ui) var(--kurve), box-shadow var(--ui) var(--kurve); }
.zahlfeld:focus-visible { outline: none; border-color: var(--marke); box-shadow: 0 0 0 3px rgba(13, 122, 95, .16); }
.zahlfeld[aria-invalid="true"] { border-color: var(--fehler); box-shadow: 0 0 0 3px rgba(180, 35, 24, .12); }
.fehlertext { font-size: 12.5px; line-height: 1.35; color: var(--fehler); }
.fehlertext::before { content: '!'; display: inline-grid; place-items: center; width: 14px; height: 14px; margin-right: 6px; border-radius: 50%; background: var(--fehler); color: #fff; font: 600 10px/1 'Geist', sans-serif; vertical-align: 1px; }
.schalter { display: inline-flex; align-items: center; gap: 10px; height: 36px; padding: 0 12px 0 6px; border: 1px solid var(--linie); border-radius: 8px; background: var(--karte); cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; transition: border-color var(--ui) var(--kurve); }
.schalter:hover { border-color: #c9c9cf; }
.schalter__spur { position: relative; width: 30px; height: 18px; border-radius: 999px; background: #d4d4d8; transition: background-color var(--ui) var(--kurve); }
.schalter__spur::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(22, 22, 22, .25); transition: translate var(--ui) var(--kurve); }
.schalter[aria-checked="true"] .schalter__spur { background: var(--marke); }
.schalter[aria-checked="true"] .schalter__spur::after { translate: 12px 0; }
.ausloeser { height: 36px; }
.ausloeser[aria-disabled="true"] { background: #d4d4d8; color: #52525b; box-shadow: none; cursor: not-allowed; }
.ausloeser svg { width: 12px; height: 12px; }
.vorgefuehrt { outline: 2px solid var(--marke); outline-offset: 2px; }
/* ---------- Ergebnis (Converter: Zahl → Text) ---------- */
.ergebnis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--linie); }
.ergebnis > div { padding: 14px 16px 15px; display: grid; gap: 6px; align-content: start; }
.ergebnis > div + div { border-left: 1px solid var(--linie); }
.ergebnis .mono { font-size: 10px; }
.ergebnis b { font-size: clamp(1.375rem, 1rem + 1.1vw, 1.75rem); font-weight: 560; letter-spacing: -.02em; line-height: 1; font-variant-numeric: tabular-nums; }
.ergebnis b small { font-size: .55em; font-weight: 500; color: var(--leise); letter-spacing: 0; margin-left: 3px; }
.ergebnis .hervor b { color: var(--marke); }
.leerhinweis { grid-column: 1 / -1; padding: 0 16px 14px; font-size: 13px; color: var(--leise); }
.annahme { padding: 10px 16px 12px; border-top: 1px solid var(--linie); font-size: 12px; line-height: 1.45; color: var(--leise); background: #fcfcfc; }
/* ---------- Pult: View Model und State Machine (nur Fundus) ---------- */
.inspektor { position: fixed; left: 16px; bottom: 16px; z-index: 20; width: 248px; padding: 12px 14px 10px; border-radius: 10px; background: rgba(22, 22, 22, .92); color: #f4f4f5; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: 0 12px 32px rgba(0, 0, 0, .2); font: 500 11px/1.5 'Geist Mono', ui-monospace, monospace; }
.inspektor h2 { font: inherit; font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; color: #a1a1aa; margin-bottom: 6px; display: flex; justify-content: space-between; }
.inspektor dl { display: grid; grid-template-columns: auto auto 1fr; gap: 1px 10px; margin-bottom: 8px; }
.inspektor dt { color: #f4f4f5; }
.inspektor dd { color: #71717a; }
.inspektor dd:last-of-type, .inspektor dd.w { color: #6ee7c4; text-align: right; font-variant-numeric: tabular-nums; }
.inspektor .sm { display: flex; flex-wrap: wrap; gap: 3px; }
.inspektor .sm span { padding: 1px 5px; border-radius: 4px; color: #71717a; border: 1px solid #3f3f46; font-size: 10px; transition: color var(--tab) var(--kurve), border-color var(--tab) var(--kurve), background-color var(--tab) var(--kurve); }
.inspektor .sm span.an { color: #161616; background: #6ee7c4; border-color: #6ee7c4; }
.inspektor p { margin-top: 8px; color: #71717a; font-size: 10px; letter-spacing: .02em; }
/* Reduzierte Bewegung: Zustände springen, nichts wandert */
.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
/* Touch: größere Ziele, Werte direkt */
@media (hover: none) {
.schalter, .ausloeser, .zahlfeld { height: 44px; }
input[type="range"] { height: 36px; }
input[type="range"]::-webkit-slider-thumb { width: 26px; height: 26px; margin-top: -11px; }
input[type="range"]::-moz-range-thumb { width: 26px; height: 26px; }
.knopf--primaer:hover { background: var(--marke); }
}
@media (max-width: 1020px) {
.kopf nav { display: none; }
}
@media (max-width: 900px) {
.hero { grid-template-columns: minmax(0, 1fr); padding-top: 32px; }
.hero h1 { max-width: 14ch; }
}
@media (max-width: 620px) {
.kopf__rechts .leise { display: none; }
.eingaenge { grid-template-columns: 1fr; }
.schalter, .ausloeser { width: 100%; justify-content: center; }
.schalter { justify-content: flex-start; }
.ergebnis { grid-template-columns: 1fr 1fr; }
.ergebnis > div:nth-child(3) { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--linie); }
.vertrauen { font-size: 10px; }
.sv-mono { font-size: 13px; }
.sv-zahl { font-size: 30px; }
.ocr-text { display: none; }
.sv-kopf { transform: translateY(-8px); }
}
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .seite { padding-bottom: 0; }
.vorschau .hero { padding-top: 34px; grid-template-columns: minmax(0, .78fr) minmax(0, 1.22fr); }
.vorschau .vertrauen, .vorschau .annahme { display: none; }
.vorschau .kopf nav { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="seite">
<header class="kopf">
<a class="marke" href="#" aria-label="Belegfluss, Startseite">
<svg viewBox="0 0 22 22" aria-hidden="true"><rect x="2.5" y="5.5" width="11" height="14" rx="1.5" fill="none" stroke="#161616"/><rect x="7.5" y="2.5" width="11" height="14" rx="1.5" fill="#0d7a5f"/><path d="M10.5 8.5h5M10.5 11.5h3" stroke="#fff" stroke-linecap="round"/></svg>
Belegfluss
</a>
<nav aria-label="Hauptnavigation"><a href="#">Produkt</a><a href="#">Preise</a><a href="#">Sicherheit & DSGVO</a><a href="#">Changelog</a></nav>
<div class="kopf__rechts"><a class="leise" href="#">Anmelden</a><a class="knopf knopf--primaer" href="#">Kostenlos testen</a></div>
</header>
<main class="hero">
<div class="hero__text">
<p class="mono">Rechnungseingang · Wetzlar</p>
<h1>Rechnungen in Minuten. <em>Nicht in Stunden.</em></h1>
<p class="unterzeile">Belegfluss liest Rechnungen aus Postfach und Scanner, prüft Betrag, Steuer und Bestellung und legt sie gebucht in Ihre Buchhaltung. Rechnen Sie nach, was das für Ihr Team bedeutet.</p>
<div class="ctas"><a class="knopf knopf--primaer" href="#">Kostenlos testen</a><a class="knopf knopf--zweit" href="#">Demo vereinbaren</a></div>
<p class="vertrauen mono"><span>Server in Frankfurt</span><span>GoBD-konform archiviert</span><span>14 Tage frei</span></p>
</div>
<section class="rechner" aria-labelledby="rechner-titel">
<div class="rechner__kopf">
<h2 id="rechner-titel" class="mono">Ersparnis-Rechner</h2>
<span class="status" data-status data-z="ruhe" aria-hidden="true"><i></i><span data-status-text>Bereit</span></span>
</div>
<figure class="bild" aria-hidden="true">
<svg viewBox="0 0 640 262" data-bild>
<defs>
<clipPath id="linse-clip"><circle cx="248" cy="136" r="50"/></clipPath>
<clipPath id="schiene-clip"><rect x="0" y="0" width="640" height="196.5"/></clipPath>
<g id="blatt-inhalt">
<rect class="kopfbalken" x="8" y="9" width="20" height="4" rx="1"/>
<rect class="zeile" x="40" y="9" width="16" height="2"/>
<rect class="zeile" x="8" y="22" width="44" height="2"/>
<rect class="zeile" x="8" y="28" width="34" height="2"/>
<rect class="zeile" x="8" y="34" width="40" height="2"/>
<path d="M8 52H56M8 60H56" stroke="#e4e4e7" stroke-width="1" vector-effect="non-scaling-stroke"/>
<rect class="zeile" x="8" y="55" width="22" height="2"/>
<rect class="zeile" x="44" y="55" width="12" height="2"/>
<text class="sv-klein" x="56" y="74" text-anchor="end">1.240,00 €</text>
</g>
</defs>
<!-- Schiene mit Stationen -->
<line class="str" x1="36" y1="200" x2="604" y2="200"/>
<line class="fortschritt" data-fortschritt x1="96" y1="200" x2="96" y2="200"/>
<g data-stationen>
<circle class="station-punkt" cx="96" cy="200" r="3.5"/>
<circle class="station-punkt" cx="248" cy="200" r="3.5"/>
<circle class="station-punkt" cx="400" cy="200" r="3.5"/>
<circle class="station-punkt" cx="552" cy="200" r="3.5"/>
</g>
<g class="sv-mono" text-anchor="middle" data-stationstexte>
<text class="station-text" x="96" y="226">01 Eingang</text>
<text class="station-text" x="248" y="226">02 Prüfen</text>
<text class="station-text" x="400" y="226">03 Freigabe</text>
<text class="station-text" x="552" y="226">04 Gebucht</text>
</g>
<!-- 01 Eingang: Stapel, 1D-Blend über „belege“ -->
<g data-stapel-gruppe>
<text class="sv-mono sv-kopf" x="64" y="30">Rechnungen / Monat</text>
<text class="sv-zahl" x="64" y="56" data-bind="belege">420</text>
<g data-stapel></g>
<g class="leer-hinweis">
<rect x="64" y="112" width="64" height="84" rx="2" fill="none" stroke="#a1a1aa" stroke-dasharray="3 3" vector-effect="non-scaling-stroke"/>
<text class="sv-mono" x="96" y="152" text-anchor="middle">leer</text>
<text class="sv-mono" x="96" y="164" text-anchor="middle">0 Belege</text>
</g>
</g>
<!-- 03 Freigabe-Tor: Boolean „vierAugen“ -->
<g class="tor" data-tor>
<path class="str pfosten" d="M372 196V104M428 196V104"/>
<path class="str" d="M366 104H434"/>
<circle class="kopf-kreis" data-kopf="0" cx="388" cy="86" r="11"/>
<circle class="kopf-kreis" data-kopf="1" cx="412" cy="86" r="11"/>
<text class="sv-klein" x="388" y="88.5" text-anchor="middle">MK</text>
<text class="sv-klein" x="412" y="88.5" text-anchor="middle">JS</text>
<path class="haken" data-haken="0" d="M383 101l3.5 3.5 6.5-7"/>
<path class="haken" data-haken="1" d="M407 101l3.5 3.5 6.5-7"/>
</g>
<text class="sv-mono tor-aus-text" x="400" y="160" text-anchor="middle">übersprungen</text>
<!-- 04 Ordner -->
<g>
<path class="blatt-f" d="M512 96h28l6 8h46v92h-80z"/>
<text class="sv-mono sv-kopf" x="520" y="30">Gebucht</text>
<text class="sv-zahl" x="520" y="56" data-bind="gebucht">1.284</text>
</g>
<!-- Wandernder Beleg -->
<g data-beleg-huelle clip-path="url(#schiene-clip)" style="clip-path:none">
<g data-beleg transform="translate(64 112)" opacity="0">
<rect class="blatt-f" width="64" height="84" rx="2"/>
<use href="#blatt-inhalt"/>
<line class="scan" data-scan x1="-6" x2="70" y1="0" y2="0" opacity="0"/>
</g>
</g>
<path class="blatt-f" d="M506 148h92v48h-92z" data-ordner-front/>
<path d="M516 162h34M516 170h22" stroke="#d9d9de" stroke-width="2"/>
<g class="stempel" data-stempel>
<circle cx="574" cy="170" r="13" fill="#e3f1ec" stroke="#0d7a5f" stroke-width="1" vector-effect="non-scaling-stroke"/>
<path d="M568 170l4 4 8-8" fill="none" stroke="#0d7a5f" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
</g>
<!-- 02 Prüfen: Linse über dem Beleg -->
<g>
<circle class="linse-glas" cx="248" cy="136" r="50"/>
<path d="M248 92v88M204 136h88" stroke="#161616" stroke-opacity=".12" stroke-width="1" vector-effect="non-scaling-stroke"/>
<circle cx="248" cy="136" r="22" fill="none" stroke="#161616" stroke-opacity=".12" stroke-width="1" vector-effect="non-scaling-stroke"/>
<text class="sv-mono ocr-text" x="248" y="62" text-anchor="middle">OCR · 3 Felder</text>
<circle class="linse-ring" data-ring cx="248" cy="136" r="57" style="transform-origin:248px 136px"/>
<path class="str" d="M248 80v8M248 184v8M192 136h8M296 136h8" />
</g>
<g data-funde>
<g class="fund" data-fund="0"><rect x="296" y="66" width="62" height="16" rx="8"/><circle class="ok" cx="305" cy="74" r="3"/><text class="sv-klein" x="312" y="76.5">Betrag</text></g>
<g class="fund" data-fund="1"><rect x="304" y="86" width="62" height="16" rx="8"/><circle class="ok" cx="313" cy="94" r="3"/><text class="sv-klein" x="320" y="96.5">USt-IdNr.</text></g>
<g class="fund" data-fund="2"><rect x="296" y="106" width="62" height="16" rx="8"/><circle class="ok" cx="305" cy="114" r="3"/><text class="sv-klein" x="312" y="116.5">Best. 4471</text></g>
</g>
</svg>
<figcaption class="sr">Illustration: Rechnungen laufen von Eingang über Prüfen und Freigabe in den Ordner Gebucht.</figcaption>
</figure>
<div class="eingaenge">
<div class="feld">
<div class="feld__kopf"><label for="belege-zahl">Rechnungen pro Monat</label><span class="mono">0 – 2.000</span></div>
<div class="regler-zeile">
<input type="range" id="belege" min="0" max="2000" step="10" value="420" aria-label="Rechnungen pro Monat, Regler" data-regler>
<input class="zahlfeld" id="belege-zahl" type="text" inputmode="numeric" autocomplete="off" value="420" aria-describedby="belege-fehler" aria-invalid="false" data-zahl>
</div>
<p class="fehlertext" id="belege-fehler" hidden data-fehler>Bitte eine ganze Zahl von 0 bis 2.000 eingeben.</p>
</div>
<button class="schalter" type="button" role="switch" aria-checked="true" data-schalter><span class="schalter__spur" aria-hidden="true"></span>Vier-Augen-Freigabe</button>
<button class="knopf knopf--primaer ausloeser" type="button" data-ausloeser><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 1.8v8.4L10 6z" fill="currentColor"/></svg><span data-knopf-text>Durchlauf ansehen</span></button>
</div>
<div class="ergebnis" aria-hidden="true">
<div class="hervor"><span class="mono">Gespart / Monat</span><b data-bind="stunden">0<small>Std.</small></b></div>
<div><span class="mono">Kosten / Monat</span><b data-bind="euro">0<small>€</small></b></div>
<div><span class="mono">Bis gebucht</span><b data-bind="tage">0,4<small>Tage</small></b></div>
<p class="leerhinweis" data-leerhinweis hidden>Ohne Rechnungen gibt es nichts zu sparen. Schieben Sie den Regler.</p>
</div>
<p class="sr" aria-live="polite" data-ansage></p>
<p class="annahme">Beispielrechnung: 12 Minuten je Rechnung von Hand, 2,5 Minuten mit Belegfluss, 1 Minute mehr mit Vier-Augen-Freigabe, Stundensatz 38 €.</p>
</section>
</main>
</div>
<aside class="inspektor" aria-label="View Model und Zustandsautomat der Demo">
<h2><span>View Model · Rechner</span><span>JS</span></h2>
<dl>
<dt>belege</dt><dd>Number</dd><dd class="w" data-insp="belege">420</dd>
<dt>vierAugen</dt><dd>Boolean</dd><dd class="w" data-insp="vierAugen">true</dd>
<dt>durchlauf</dt><dd>Trigger</dd><dd class="w" data-insp="durchlauf">0×</dd>
<dt>ersparnis</dt><dd>String</dd><dd class="w" data-insp="ersparnis">„0 Std.“</dd>
</dl>
<h2><span>State Machine · Fluss</span></h2>
<div class="sm" data-insp-sm><span data-sm="ruhe">ruhe</span><span data-sm="einlesen">einlesen</span><span data-sm="pruefen">prüfen</span><span data-sm="freigabe">freigabe</span><span data-sm="gebucht">gebucht</span></div>
<p>Nachbau des Rive-Prinzips, keine .riv-Datei.</p>
</aside>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
const zahlFmt = new Intl.NumberFormat('de-DE');
const kurve = t => 1 - (1 - t) * (1 - t); // easeOutQuad = cubic-bezier(0.25, 0.46, 0.45, 0.94) angenähert
const SEGMENT = 520, UI = 150;
/* ---------- View Model wie bei Rive: Eigenschaften mit .value, Trigger mit .trigger(), on() ---------- */
function eigenschaft(typ, wert) {
const hoerer = new Set();
let v = wert, neu = true;
return {
typ,
get value() { return v; },
set value(x) { if (x !== v) { v = x; neu = true; wecken(); } },
on(f) { hoerer.add(f); }, off(f) { f ? hoerer.delete(f) : hoerer.clear(); },
// Wie in der Rive-Runtime: gebundene Elemente erfahren Änderungen erst im nächsten Schritt
melden() { if (neu) { neu = false; hoerer.forEach(f => f({ data: v })); } }
};
}
function ausloeser() {
const hoerer = new Set();
let anstehend = false, zaehler = 0;
return {
typ: 'Trigger',
trigger() { anstehend = true; zaehler++; wecken(); },
verbrauchen() { const a = anstehend; anstehend = false; return a; },
get anzahl() { return zaehler; },
on(f) { hoerer.add(f); }, melden() {}
};
}
const vm = {
belege: eigenschaft('Number', 420),
vierAugen: eigenschaft('Boolean', true),
durchlauf: ausloeser(),
// berechnet wie ein Formel-Converter
stunden: eigenschaft('Number', 0),
euro: eigenschaft('Number', 0),
gebucht: eigenschaft('Number', 1284)
};
function rechnen() {
const n = vm.belege.value, mit = 2.5 + (vm.vierAugen.value ? 1 : 0);
vm.stunden.value = Math.round(n * (12 - mit) / 60);
vm.euro.value = Math.round(vm.stunden.value * 38);
}
/* ---------- Elemente ---------- */
const regler = $('[data-regler]'), zahl = $('[data-zahl]'), fehler = $('[data-fehler]');
const schalter = $('[data-schalter]'), knopf = $('[data-ausloeser]'), knopfText = $('[data-knopf-text]');
const statusEl = $('[data-status]'), statusText = $('[data-status-text]');
const stapel = $('[data-stapel]'), stapelGruppe = $('[data-stapel-gruppe]');
const beleg = $('[data-beleg]'), belegHuelle = $('[data-beleg-huelle]'), scan = $('[data-scan]');
const fortschritt = $('[data-fortschritt]'), ring = $('[data-ring]');
const punkte = $$('[data-stationen] circle'), stationsTexte = $$('[data-stationstexte] text');
const tor = $('[data-tor]'), koepfe = $$('[data-kopf]'), haken = $$('[data-haken]');
const funde = $$('[data-fund]'), stempel = $('[data-stempel]');
const leerHinweis = $('[data-leerhinweis]'), ansage = $('[data-ansage]');
const bind = n => $(`[data-bind="${n}"]`);
const NS = 'http://www.w3.org/2000/svg';
const MAX_BLAETTER = 8, STATION = { eingang: 96, pruefen: 248, freigabe: 400, gebucht: 552 };
// Stapel einmal anlegen; sichtbar sind 0 bis 8 Blätter (1D-Blend über „belege“)
for (let i = 0; i < MAX_BLAETTER; i++) {
const g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'stapel-blatt weg');
g.innerHTML = `<g transform="translate(${64 + (i % 2 ? 1.5 : -1)} ${112 - i * 5})"><rect class="blatt-f" width="64" height="84" rx="2"/>${i === MAX_BLAETTER - 1 ? '' : ''}</g>`;
stapel.appendChild(g);
}
const blaetter = [...stapel.children];
const blaetterFuer = n => n <= 0 ? 0 : Math.min(MAX_BLAETTER, Math.ceil(n / 250));
let sichtbareBlaetter = 0;
function stapelZeigen(k) {
sichtbareBlaetter = k;
blaetter.forEach((b, i) => {
b.classList.toggle('weg', i >= k);
// nur das oberste Blatt zeigt Inhalt
const inhalt = b.querySelector('use');
if (i === k - 1 && !inhalt) { const u = document.createElementNS(NS, 'use'); u.setAttribute('href', '#blatt-inhalt'); b.firstChild.appendChild(u); }
if (i !== k - 1 && inhalt) inhalt.remove();
});
stapelGruppe.classList.toggle('ist-leer', k === 0);
}
const stapelOben = () => 112 - Math.max(0, sichtbareBlaetter - 1) * 5;
/* ---------- Gezählte Zahlen (400 ms, reduziert sofort) ---------- */
const zaehler = {};
function zaehlen(name, ziel, format) {
const el = bind(name), z = zaehler[name] || (zaehler[name] = { wert: ziel, von: ziel, ziel, t0: 0 });
z.format = format; z.el = el;
if (reduziert() || z.ziel === ziel && z.wert === ziel) { z.wert = z.von = z.ziel = ziel; zeichneZahl(z); return; }
z.von = z.wert; z.ziel = ziel; z.t0 = performance.now(); z.laeuft = true; wecken();
}
function zeichneZahl(z) { z.el.innerHTML = z.format(z.wert); }
function zaehlerSchritt(jetzt) {
let aktiv = false;
for (const z of Object.values(zaehler)) {
if (!z.laeuft) continue;
const t = Math.min(1, (jetzt - z.t0) / 400);
z.wert = Math.round(z.von + (z.ziel - z.von) * kurve(t));
zeichneZahl(z);
if (t >= 1) z.laeuft = false; else aktiv = true;
}
return aktiv;
}
/* ---------- Bindungen: Datenmodell → Illustration und Text ---------- */
vm.belege.on(e => {
stapelZeigen(blaetterFuer(e.data));
zaehlen('belege', e.data, v => zahlFmt.format(v));
rechnen();
const leer = e.data === 0;
knopf.setAttribute('aria-disabled', String(leer));
leerHinweis.hidden = !leer;
$('[data-insp="belege"]').textContent = e.data;
});
vm.vierAugen.on(e => {
tor.classList.toggle('aus', !e.data);
zaehlen('tage', e.data ? 12 : 4, v => (v / 10).toLocaleString('de-DE', { minimumFractionDigits: 1 }) + '<small>Tage</small>');
rechnen();
$('[data-insp="vierAugen"]').textContent = e.data;
});
vm.stunden.on(e => {
zaehlen('stunden', e.data, v => zahlFmt.format(v) + '<small>Std.</small>');
$('[data-insp="ersparnis"]').textContent = `„${zahlFmt.format(e.data)} Std.“`;
planeAnsage();
});
vm.euro.on(e => zaehlen('euro', e.data, v => zahlFmt.format(v) + '<small>€</small>'));
vm.gebucht.on(e => zaehlen('gebucht', e.data, v => zahlFmt.format(v)));
let ansageZeit;
function planeAnsage() {
clearTimeout(ansageZeit);
ansageZeit = setTimeout(() => {
ansage.textContent = vm.belege.value === 0
? 'Keine Rechnungen eingetragen, keine Ersparnis.'
: `Ersparnis: etwa ${zahlFmt.format(vm.stunden.value)} Stunden und ${zahlFmt.format(vm.euro.value)} Euro pro Monat.`;
}, 700);
}
/* ---------- Zustandsautomat „Fluss“ (eine Ebene) ---------- */
const ZUSTAENDE = {
ruhe: { text: 'Bereit', dauer: Infinity, station: -1 },
einlesen: { text: 'Liest ein', dauer: SEGMENT, station: 0 },
pruefen: { text: 'Prüft', dauer: 1000, station: 1 },
freigabe: { text: 'Freigabe', dauer: SEGMENT + 640, station: 2 },
gebucht: { text: 'Gebucht', dauer: 0, station: 3 } // Dauer hängt am Weg (s. u.)
};
const sm = { zustand: 'ruhe', t: 0, vonX: STATION.pruefen };
function dauerGebucht() { return (sm.vonX === STATION.pruefen ? SEGMENT * 1.4 : SEGMENT) + 300 + 600; }
function wechsel(z) {
sm.zustand = z; sm.t = 0;
const info = ZUSTAENDE[z];
statusEl.dataset.z = z; statusText.textContent = info.text;
punkte.forEach((p, i) => p.classList.toggle('an', i <= info.station && info.station >= 0));
stationsTexte.forEach((p, i) => p.classList.toggle('an', i === info.station));
$$('[data-sm]').forEach(s => s.classList.toggle('an', s.dataset.sm === z));
if (z === 'ruhe') {
beleg.setAttribute('opacity', '0'); fortschritt.setAttribute('x2', STATION.eingang);
funde.forEach(f => f.classList.remove('an')); haken.forEach(h => h.classList.remove('an')); koepfe.forEach(k => k.classList.remove('ok'));
stempel.classList.remove('an'); knopf.removeAttribute('data-laeuft'); knopfText.textContent = 'Durchlauf ansehen';
if (vm.belege.value > 0) knopf.setAttribute('aria-disabled', 'false');
} else {
knopf.setAttribute('aria-disabled', 'true'); knopf.dataset.laeuft = ''; knopfText.textContent = 'Läuft …';
}
if (z === 'gebucht') sm.vonX = vm.vierAugen.value ? STATION.freigabe : STATION.pruefen;
}
// Bedingungen der Übergänge, wie im State-Machine-Editor
function uebergang() {
const z = sm.zustand, fertig = sm.t >= (z === 'gebucht' ? dauerGebucht() : ZUSTAENDE[z].dauer);
if (z === 'ruhe') { if (vm.durchlauf.verbrauchen() && vm.belege.value > 0) return 'einlesen'; return null; }
vm.durchlauf.verbrauchen(); // Auslöser während eines Durchlaufs verfallen wie in Rive
if (!fertig) return null;
if (z === 'einlesen') return 'pruefen';
if (z === 'pruefen') return vm.vierAugen.value ? 'freigabe' : 'gebucht';
if (z === 'freigabe') return 'gebucht';
if (z === 'gebucht') return 'ruhe';
return null;
}
// Zeitleisten je Zustand: setzen Lage des Belegs, Scanlinie, Funde, Häkchen
function belegLage(x, unten, deckkraft = 1) {
beleg.setAttribute('transform', `translate(${(x - 32).toFixed(2)} ${(unten - 84).toFixed(2)})`);
beleg.setAttribute('opacity', deckkraft);
fortschritt.setAttribute('x2', Math.max(STATION.eingang, Math.min(x, STATION.gebucht)).toFixed(2));
}
function zeitleiste() {
const z = sm.zustand, t = sm.t;
if (reduziert()) { // Endbild je Zustand, nichts wandert
const x = { einlesen: STATION.pruefen, pruefen: STATION.pruefen, freigabe: STATION.freigabe, gebucht: STATION.gebucht }[z];
if (x) belegLage(x, z === 'gebucht' ? 228 : 196, z === 'gebucht' ? 0 : 1);
scan.setAttribute('opacity', 0);
if (z === 'pruefen') funde.forEach(f => f.classList.add('an'));
if (z === 'freigabe') { haken.forEach(h => h.classList.add('an')); koepfe.forEach(k => k.classList.add('ok')); }
if (z === 'gebucht' && !stempel.classList.contains('an')) { stempel.classList.add('an'); vm.gebucht.value += 1; ansage.textContent = 'Durchlauf abgeschlossen: Rechnung geprüft und gebucht.'; }
return;
}
if (z === 'einlesen') {
const p = kurve(Math.min(1, t / SEGMENT)), y0 = stapelOben() + 84;
belegLage(STATION.eingang + (STATION.pruefen - STATION.eingang) * p, y0 + (196 - y0) * p - 18 * Math.sin(Math.PI * p));
} else if (z === 'pruefen') {
belegLage(STATION.pruefen, 196);
const s = (t % 480) / 480;
scan.setAttribute('y1', (s * 84).toFixed(2)); scan.setAttribute('y2', (s * 84).toFixed(2));
scan.setAttribute('opacity', t < 960 ? 1 : 0);
ring.style.transform = `rotate(${(t * .09).toFixed(2)}deg)`;
funde.forEach((f, i) => f.classList.toggle('an', t > 220 + i * 240));
} else if (z === 'freigabe') {
scan.setAttribute('opacity', 0);
const p = kurve(Math.min(1, t / SEGMENT));
belegLage(STATION.pruefen + (STATION.freigabe - STATION.pruefen) * p, 196);
[0, 1].forEach(i => { const an = t > SEGMENT + 80 + i * 240; haken[i].classList.toggle('an', an); koepfe[i].classList.toggle('ok', an); });
} else if (z === 'gebucht') {
scan.setAttribute('opacity', 0);
const weg = sm.vonX === STATION.pruefen ? SEGMENT * 1.4 : SEGMENT;
if (t < weg) {
belegLage(sm.vonX + (STATION.gebucht - sm.vonX) * kurve(t / weg), 196);
} else {
const p = kurve(Math.min(1, (t - weg) / 300));
belegLage(STATION.gebucht, 196 + 34 * p, 1);
if (p >= 1 && !stempel.classList.contains('an')) { stempel.classList.add('an'); vm.gebucht.value += 1; ansage.textContent = 'Durchlauf abgeschlossen: Rechnung geprüft und gebucht.'; }
}
}
}
/* ---------- advance(dt): ein Schritt wie in der Runtime ---------- */
let raf = 0, letzte = 0, angehalten = false;
function wecken() { if (!raf && !angehalten) { letzte = performance.now(); raf = requestAnimationFrame(schritt); } }
function schritt(jetzt) {
raf = 0;
const dt = Math.min(64, jetzt - letzte); letzte = jetzt;
// 1. Werte des View Models an Bindungen melden
Object.values(vm).forEach(p => p.melden());
// 2. Zustandsautomat
if (sm.zustand !== 'ruhe') sm.t += dt;
let ziel = uebergang(), schutz = 0;
while (ziel && schutz++ < 6) { wechsel(ziel); zeitleiste(); ziel = uebergang(); }
zeitleiste();
// 3. Zahlen zählen
const zaehlt = zaehlerSchritt(jetzt);
belegHuelle.style.clipPath = sm.zustand === 'gebucht' ? '' : 'none';
if (sm.zustand !== 'ruhe' || zaehlt) wecken();
}
/* ---------- Eingänge (Formular) → View Model ---------- */
function anteil() { regler.style.setProperty('--anteil', (regler.value / regler.max * 100) + '%'); }
regler.addEventListener('input', () => {
vm.belege.value = +regler.value; zahl.value = regler.value; anteil();
zahl.setAttribute('aria-invalid', 'false'); fehler.hidden = true;
});
function zahlPruefen(endgueltig) {
const roh = zahl.value.replace(/[.\s]/g, '').trim();
const ok = /^\d{1,4}$/.test(roh) && +roh <= 2000;
if (ok) {
vm.belege.value = +roh; regler.value = +roh; anteil();
zahl.setAttribute('aria-invalid', 'false'); fehler.hidden = true;
if (endgueltig) zahl.value = roh;
} else if (endgueltig || roh.length > 4) {
zahl.setAttribute('aria-invalid', 'true'); fehler.hidden = false;
}
}
zahl.addEventListener('input', () => zahlPruefen(false));
zahl.addEventListener('change', () => zahlPruefen(true));
zahl.addEventListener('keydown', e => { if (e.key === 'Enter') zahlPruefen(true); });
schalter.addEventListener('click', () => {
const an = schalter.getAttribute('aria-checked') !== 'true';
schalter.setAttribute('aria-checked', String(an));
vm.vierAugen.value = an;
});
knopf.addEventListener('click', () => {
if (knopf.getAttribute('aria-disabled') === 'true') return;
vm.durchlauf.trigger();
$('[data-insp="durchlauf"]').textContent = vm.durchlauf.anzahl + '×';
});
/* ---------- Ausgangszustand ---------- */
function setzeWerte(n, an) {
regler.value = n; zahl.value = n; anteil();
zahl.setAttribute('aria-invalid', 'false'); fehler.hidden = true;
schalter.setAttribute('aria-checked', String(an));
vm.belege.value = n; vm.vierAugen.value = an;
}
function grundzustand() {
wechsel('ruhe');
setzeWerte(420, true);
rechnen();
}
grundzustand();
wecken();
/* ---------- Vorführung: gedachte Nutzerin rechnet nach (ca. 14 s) ---------- */
let zeitgeber = [], laeuft = false;
const nach = (ms, f) => zeitgeber.push(setTimeout(f, ms));
function markiere(el, ms) { nach(0, () => el.classList.add('vorgefuehrt')); nach(ms, () => el.classList.remove('vorgefuehrt')); }
function gleiten(t0, von, bis, ms) {
const schritte = Math.max(1, Math.round(ms / 50));
for (let i = 1; i <= schritte; i++) nach(t0 + i * 50, () => {
const v = Math.round((von + (bis - von) * kurve(i / schritte)) / 10) * 10;
regler.value = v; regler.dispatchEvent(new Event('input', { bubbles: true }));
});
nach(t0, () => regler.classList.add('zieht'));
nach(t0 + ms + 60, () => regler.classList.remove('zieht'));
}
function druecken(t, el) {
nach(t, () => el.classList.add('gedrueckt'));
nach(t + UI, () => { el.classList.remove('gedrueckt'); el.click(); });
}
function eineRunde() {
zeitgeber.forEach(clearTimeout); zeitgeber = [];
wechsel('ruhe');
setzeWerte(120, false);
let t = 500;
gleiten(t, 120, 860, 1500); t += 1800; // Number: Stapel wächst, Zahlen zählen
nach(t, () => markiere(schalter, 400)); druecken(t + 120, schalter); t += 700; // Boolean: Tor erscheint
nach(t, () => markiere(knopf, 400)); druecken(t + 120, knopf); t += 4300; // Trigger: Durchlauf mit Freigabe
nach(t, () => markiere(schalter, 400)); druecken(t + 120, schalter); t += 600; // Freigabe aus
nach(t, () => markiere(knopf, 400)); druecken(t + 120, knopf); t += 3400; // kürzerer Weg
gleiten(t, 860, 0, 1100); t += 2600; // Leerzustand
nach(t, eineRunde);
}
function vorfuehren() { neustart(); html.classList.add('vorfuehrung'); laeuft = true; eineRunde(); }
function pause() { zeitgeber.forEach(clearTimeout); zeitgeber = []; laeuft = false; angehalten = true; cancelAnimationFrame(raf); raf = 0; }
function weiter() { angehalten = false; wecken(); if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; eineRunde(); } }
function neustart() { pause(); angehalten = false; html.classList.remove('vorfuehrung'); $$('.vorgefuehrt').forEach(e => e.classList.remove('vorgefuehrt')); grundzustand(); wecken(); }
})();
</script>
</body>
</html>