Agentenformular mit WebMCP
Termin- und Angebotsformular eines Heizungsbauers, das Menschen normal bedienen und KI-Agenten als WebMCP-Werkzeug lesen; eine umschaltbare Agenten-Sicht zeigt Schema, Lese-Werkzeug und eine optionale llms.txt.
Handwerk, Praxis, Fahrschule, Werkstatt: kurze Terminanfrage mit Rückruf; Agent prüft vorher freie Zeitfenster.
Ein Anfrage- oder Terminformular, das für Menschen ganz normal funktioniert und für KI-Agenten zusätzlich als Werkzeug beschrieben ist. Deklarativ über HTML-Attribute: toolname und tooldescription am <form>, toolparamdescription an den Feldern. Der Browser leitet daraus ein JSON-Schema ab. Imperativ registriert die Seite weitere Werkzeuge per document.modelContext.registerTool(), etwa eine Abfrage freier Termine. Beides nur mit Feature-Erkennung. Optional dazu eine llms.txt im Wurzelverzeichnis, die Agenten erklärt, was die Seite anbietet; ihre Wirkung ist nicht belegt, Google braucht sie für AI Overviews und AI Mode nicht.
Assistenten buchen zunehmend im Auftrag ihrer Nutzer. Ein beschriebenes Formular erspart ihnen das Raten über Bildschirmfotos und Klickpfade. Der Betrieb bekommt saubere Anfragen, der Mensch behält die Kontrolle: Ohne toolautosubmit füllt der Agent nur aus, abgeschickt wird erst nach Klick. Wer es heute einbaut, verliert nichts, denn ohne Unterstützung bleiben die Attribute wirkungslos.
Wann einsetzen
Termin- und Anfrageformulare mit klaren Feldern: Wartungstermin, Rückruf, Reservierung, Probestunde, Kostenvoranschlag. Seiten, deren Kundschaft schon mit Assistenten plant (Handwerk, Praxis, Fahrschule, Gastronomie, Kurse).
Wann nicht
Für Zahlungen, Verträge und sensible Daten (Gesundheit, Ausweise) kein automatisches Absenden und keine Werkzeuge ohne Bestätigung. Nicht als Ersatz für ein gutes Formular für Menschen. Wenn Spam ein Problem ist: Agenten-Anfragen brauchen dieselben Schutzmaßnahmen wie alle anderen.
Varianten
- Terminformular nur deklarativ (toolname, tooldescription, toolparamdescription), Mensch bestätigt den Versand
- Deklaratives Formular plus imperatives Lese-Werkzeug (freie Termine, Einzugsgebiet prüfen) mit annotations.readOnlyHint
- Suchformular mit toolautosubmit: Agent darf selbst absenden, weil nichts Folgenreiches passiert
- Antwort an den Agenten über SubmitEvent.respondWith(): Bestätigung mit Vorgangsnummer oder Liste der Feldfehler
- llms.txt mit Verweis auf Formular, Leistungen und Einzugsgebiet, kombinierbar mit einer Maschinenansicht (optional, ohne belegte Wirkung bei Google)
Umsetzung
Formular wie immer bauen (label, name, required, autocomplete), dann ergänzen: <form toolname="termin_anfragen" tooldescription="Was das Werkzeug tut, was danach passiert">, an jedem Feld toolparamdescription mit Format und Beispiel. Auswahlen als <select>. Kein toolautosubmit, wenn der Versand Folgen hat. Keine Honigtopf- oder versteckten Felder mit name im Werkzeug-Formular: Sie landen vermutlich im Schema; Spamschutz serverseitig. Im submit-Handler: e.preventDefault(); if (e.agentInvoked) e.respondWith(Promise.resolve('Anfrage KH-… eingegangen')); bei Fehlern die Feldmeldungen zurückgeben. Imperativ nur mit Feature-Erkennung: const mc = document.modelContext || navigator.modelContext; if (mc && typeof mc.registerTool === 'function') mc.registerTool({…, annotations: { readOnlyHint: true }}, { signal }). CSS für :tool-form-active nur in @supports selector(:tool-form-active). Wechselt das Formular (Variante, Schritt), toolname entfernen bzw. controller.abort(). Optional: llms.txt als statische Datei unter /llms.txt, ohne belegte Wirkung bei Google. Google nennt für AI Overviews und AI Mode ausdrücklich keine neuen maschinenlesbaren Dateien oder KI-Textdateien als nötig (developers.google.com/search/docs/appearance/ai-features, Stand 10.12.2025); für Sichtbarkeit zählen strukturierte Daten passend zum sichtbaren Text und ein aktuelles Unternehmensprofil. Dem Kunden nicht als Sichtbarkeits-Maßnahme verkaufen. Die Agenten-Sicht der Demo ist ein Erklärwerkzeug; auf Kundenseiten dafür mensch-maschine-umschalter nehmen oder weglassen.
Robust umsetzen
Telefon. Identisch. Felder mit autocomplete und inputmode, Ziele mindestens 48 px (Touch 52 px), Schrift 17 px gegen Zoom. Die Agenten-Sicht steht in der Demo auf dem Telefon über dem Formular.
Ohne Bewegung. Ansichtswechsel und Ausfüllen ohne Bewegung, Zustände wechseln sofort.
Barrierefrei. Die Attribute ändern nichts für Screenreader; Grundlage bleibt ein zugängliches Formular mit <label>, Fehlertexten über aria-describedby und Fokus auf das erste fehlerhafte Feld (WCAG 3.3.1, 3.3.2). Gute Labels helfen doppelt, denn Chrome nimmt sie als Beschreibung, wenn toolparamdescription fehlt. Füllt ein Agent aus, kündigt eine Statusmeldung (role="status") das an, und der Mensch prüft vor dem Senden.
Code
<!DOCTYPE html>
<!--
Agentenformular mit WebMCP
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/agentenformular-webmcp/
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>Agentenformular mit WebMCP · 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: '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; }
@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 „Handwerk: Werkstatt und Klartext“ – Materialfarben, ein Signalton nur für Handlungen */
--grund: #efedea; /* Zement hell */
--flaeche: #d3cdc2; /* cement */
--holz: #c3b4a5;
--salbei: #bdc1a9;
--feld: #f8f7f4; /* Zellen des Auftragszettels */
--text: #1a1a1a;
--leise: #59544d; /* 6,0 : 1 auf --grund */
--linie: #1a1a1a;
--signal: #fb6100; /* nur Anfragen / Anrufen */
--signal-tief: #e05600;
--fehler: #b3261e;
/* Maschinenschicht: Blaupause */
--blau: #1f45b5; /* Anmerkungen auf hellem Grund, 7,4 : 1 */
--pause: #10265a; /* Fläche der Agenten-Sicht */
--pause-2: #183274;
--pause-text: #eef2fd;
--pause-leise: #a6b6e2;
--pause-linie: rgba(166, 182, 226, .26);
--kurve: cubic-bezier(0.4, 0, 0.2, 1); /* Standard, Holzrausch .15s */
--zu: cubic-bezier(0.4, 0, 1, 1); /* Schließen schneller (accelerate) */
--t-farbe: 150ms; --t-sicht: 240ms; --t-rein: 400ms; --t-zu: 160ms;
--ziel: 48px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: flex; flex-direction: column; }
button, input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; }
.gothic { font-family: 'Special Gothic', 'Arial Narrow', sans-serif; }
.etikett { font-family: 'Special Gothic', 'Arial Narrow', sans-serif; font-stretch: 75%; font-weight: 600; font-size: .8125rem; letter-spacing: .04em; text-transform: uppercase; line-height: 1.2; }
.mono { font: 500 10.5px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
.unsichtbar { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ---------- Demo-Leiste (statisch, kein Pult) ---------- */
.demo { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 24px; padding: 8px clamp(16px, 3vw, 40px); border-bottom: 1px solid rgba(26, 26, 26, .16); }
.demo .gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; }
.varianten { display: flex; flex-wrap: wrap; gap: 2px; }
.varianten button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid transparent; border-radius: 99px; padding: 5px 10px; cursor: pointer; transition: color var(--t-farbe) var(--kurve), border-color var(--t-farbe) var(--kurve); }
.varianten button:hover { color: var(--text); }
.varianten button[aria-pressed="true"] { color: var(--text); border-color: var(--text); }
.varianten button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { color: var(--text); font-weight: 500; }
/* ---------- Kopf ---------- */
.kopf { display: flex; align-items: stretch; justify-content: space-between; gap: 16px; padding: 16px clamp(16px, 3vw, 40px); }
.marke { display: flex; flex-direction: column; justify-content: center; text-decoration: none; line-height: 1; }
.marke b { font-family: 'Special Gothic', sans-serif; font-weight: 700; font-stretch: 100%; font-size: 23px; letter-spacing: -.01em; }
.marke span { margin-top: 4px; font-family: 'Special Gothic', sans-serif; font-stretch: 75%; font-weight: 500; font-size: 12.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.kasten { display: flex; border: 1px solid var(--linie); }
.kasten a { display: flex; align-items: center; padding: 0 16px; min-height: 44px; text-decoration: none; font-size: 14.5px; border-left: 1px solid var(--linie); transition: background-color var(--t-farbe) var(--kurve); }
.kasten a:first-child { border-left: 0; }
.kasten a:hover { background: var(--flaeche); }
.kasten a[aria-current] { background: var(--text); color: var(--grund); }
.kasten a:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.anruf { display: inline-flex; align-items: center; gap: 10px; min-height: 46px; padding: 0 18px; background: var(--signal); color: var(--text); border-radius: 2px; text-decoration: none; font-weight: 700; font-size: 15px; transition: background-color var(--t-farbe) var(--kurve), scale var(--t-farbe) var(--kurve); }
.anruf:hover { background: var(--signal-tief); }
.anruf:active { scale: .97; }
.anruf:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
/* ---------- Ansicht umschalten ---------- */
.sicht { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px 20px; margin: 0 clamp(16px, 3vw, 40px); padding: 12px 0; border-top: 1px solid var(--linie); }
.sicht-links { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.schalter { display: inline-grid; grid-template-columns: 1fr 1fr; position: relative; padding: 3px; border: 1px solid var(--linie); border-radius: 99px; background: var(--feld); }
.schalter::before { content: ''; position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc(50% - 3px); border-radius: 99px; background: var(--text); transition: translate var(--t-sicht) var(--kurve), background-color var(--t-sicht) var(--kurve); }
.agent .schalter::before { translate: 100% 0; background: var(--pause); }
.schalter button { position: relative; z-index: 1; min-width: 92px; min-height: 36px; padding: 0 16px; border: 0; background: none; border-radius: 99px; cursor: pointer; font-family: 'Special Gothic', sans-serif; font-stretch: 75%; font-weight: 600; font-size: 14px; letter-spacing: .05em; text-transform: uppercase; transition: color var(--t-sicht) var(--kurve); }
.schalter button[aria-pressed="true"] { color: #fff; }
.schalter button:focus-visible { outline: 2px solid var(--blau); outline-offset: 2px; }
.sicht-hinweis { font-size: 14px; color: var(--leise); max-width: 46ch; }
.erkennung { display: inline-flex; align-items: center; gap: 8px; }
.erkennung i { width: 8px; height: 8px; border-radius: 50%; background: var(--holz); box-shadow: 0 0 0 3px rgba(195, 180, 165, .35); }
.erkennung[data-an] i { background: #2d8a4e; box-shadow: 0 0 0 3px rgba(45, 138, 78, .2); }
/* ---------- Sektion ---------- */
.bereich { flex: 1; display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); margin: 0 clamp(16px, 3vw, 40px); border-top: 1px solid var(--linie); }
.links { position: relative; border-right: 1px solid var(--linie); min-width: 0; }
.mensch-text { height: 100%; padding: clamp(24px, 3.2vw, 48px) clamp(20px, 3vw, 44px) clamp(20px, 2.6vw, 36px) 0; display: flex; flex-direction: column; transition: opacity var(--t-sicht) var(--kurve), translate var(--t-sicht) var(--kurve); }
.agent .mensch-text { opacity: 0; translate: 0 -8px; visibility: hidden; transition: opacity var(--t-zu) var(--zu), translate var(--t-zu) var(--zu), visibility 0s linear var(--t-zu); }
.augenbraue { display: flex; align-items: center; gap: 10px; color: var(--leise); }
.augenbraue::before { content: ''; width: 10px; height: 10px; background: var(--signal); }
h1 { margin-top: 16px; font-family: 'Special Gothic', sans-serif; font-weight: 700; font-stretch: 100%; font-size: clamp(2.25rem, 1.5rem + 3vw, 3.5rem); line-height: 1.05; letter-spacing: -.01em; text-wrap: balance; max-width: 12ch; }
.vorspann { margin-top: 18px; font-size: 1.0625rem; line-height: 1.55; max-width: 40ch; text-wrap: pretty; }
.wege { margin-top: clamp(22px, 3vw, 36px); border-top: 1px solid var(--linie); }
.wege div { display: grid; grid-template-columns: 7.2em minmax(0, 1fr); gap: 12px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid rgba(26, 26, 26, .22); }
.wege dt { color: var(--leise); }
.wege dd { font-size: 15.5px; line-height: 1.4; }
.wege dd a { font-weight: 700; text-decoration: none; }
.wege dd a:hover { text-decoration: underline; text-underline-offset: 4px; }
.schema-bild { margin-top: auto; padding-top: 22px; }
.schema-bild svg { display: block; width: 100%; max-width: 440px; height: auto; }
.schema-bild figcaption { margin-top: 6px; font-size: 12.5px; color: var(--leise); }
/* ---------- Agenten-Sicht (Blaupause) ---------- */
.maschine { position: absolute; inset: 14px clamp(14px, 2vw, 28px) 14px 0; background: var(--pause); color: var(--pause-text); border-radius: 3px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; opacity: 0; translate: 0 8px; visibility: hidden; transition: opacity var(--t-zu) var(--zu), translate var(--t-zu) var(--zu), visibility 0s linear var(--t-zu);
background-image: linear-gradient(var(--pause-linie) 1px, transparent 1px), linear-gradient(90deg, var(--pause-linie) 1px, transparent 1px); background-size: 24px 24px; background-position: -1px -1px; }
.maschine { bottom: auto; height: min(calc(100% - 28px), calc(100svh - 40px)); }
.agent .maschine { opacity: 1; translate: none; visibility: visible; transition: opacity var(--t-rein) var(--kurve), translate var(--t-rein) var(--kurve), visibility 0s; }
.m-kopf, .reiter, .protokoll { flex: none; }
.m-kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; background: var(--pause); border-bottom: 1px solid var(--pause-linie); }
.m-kopf .mono { color: var(--pause-leise); }
.m-kopf b { font-family: 'Special Gothic', sans-serif; font-stretch: 75%; font-weight: 600; font-size: 15px; letter-spacing: .05em; text-transform: uppercase; }
.reiter { display: flex; gap: 0; border-bottom: 1px solid var(--pause-linie); background: var(--pause); overflow-x: auto; scrollbar-width: none; }
.reiter button { flex: none; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--pause-leise); background: none; border: 0; border-right: 1px solid var(--pause-linie); padding: 0 14px; min-height: 40px; cursor: pointer; transition: color var(--t-farbe) var(--kurve), background-color var(--t-farbe) var(--kurve); }
.reiter button:hover { color: var(--pause-text); }
.reiter button[aria-selected="true"] { color: var(--pause); background: var(--pause-text); }
.reiter button:focus-visible { outline: 2px solid #fff; outline-offset: -4px; }
.tafel { flex: 1; min-height: 0; overflow: auto; padding: 14px 16px 16px; scrollbar-color: var(--pause-leise) transparent; }
.tafel[hidden] { display: none; }
.tafel p.erkl { font-size: 13.5px; line-height: 1.5; color: var(--pause-leise); max-width: 60ch; margin-bottom: 10px; }
.tafel p.erkl b { color: var(--pause-text); font-weight: 700; }
.tafel p.erkl code { color: var(--pause-text); font: 500 12.5px/1 'Geist Mono', monospace; }
pre { font: 400 12.5px/1.6 'Geist Mono', ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
.j-k { color: #9fc2ff; } .j-s { color: #f6e3c8; } .j-n { color: #ffb38a; } .j-b { color: #ffb38a; } .j-p { color: var(--pause-leise); }
.md-h { color: #fff; font-weight: 600; } .md-q { color: #f6e3c8; } .md-l { color: #9fc2ff; } .md-m { color: var(--pause-leise); }
.fussnote { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--pause-linie); font-size: 12.5px; line-height: 1.5; color: var(--pause-leise); }
.fussnote b { color: var(--pause-text); font-weight: 600; }
.protokoll { border-top: 1px solid var(--pause-linie); background: rgba(8, 20, 50, .55); padding: 10px 16px 12px; }
.protokoll-kopf { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 6px; }
.protokoll-kopf .mono { color: var(--pause-leise); }
.spielen { font: 600 13px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .06em; text-transform: uppercase; color: var(--pause); background: var(--pause-text); border: 0; border-radius: 99px; min-height: 34px; padding: 0 14px; cursor: pointer; transition: background-color var(--t-farbe) var(--kurve), scale var(--t-farbe) var(--kurve); }
.spielen:hover { background: #fff; }
.spielen:active { scale: .97; }
.spielen:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.spielen[disabled] { opacity: .5; cursor: default; }
.protokoll ol { list-style: none; font: 400 11.5px/1.55 'Geist Mono', ui-monospace, monospace; color: var(--pause-text); max-height: 7.8em; overflow: auto; }
.protokoll li { display: grid; grid-template-columns: 4.6em minmax(0, 1fr); gap: 8px; animation: rein var(--t-rein) var(--kurve) both; }
.protokoll li span:first-child { color: var(--pause-leise); }
.protokoll li.leer { display: block; color: var(--pause-leise); animation: none; }
/* ---------- Formular als Auftragszettel ---------- */
.rechts { padding: clamp(20px, 2.8vw, 40px) 0 clamp(20px, 2.6vw, 36px) clamp(20px, 3vw, 44px); display: flex; flex-direction: column; min-width: 0; }
.zettel-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.zettel-kopf h2 { font-family: 'Special Gothic', sans-serif; font-weight: 700; font-size: clamp(1.625rem, 1.2rem + 1.6vw, 2.25rem); line-height: 1.05; letter-spacing: -.01em; }
.zettel-kopf p { font-size: 14px; color: var(--leise); }
form[hidden] { display: none; }
.zettel { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--linie); border: 1px solid var(--linie); }
.zelle { position: relative; background: var(--feld); display: flex; flex-direction: column; min-height: 78px; transition: background-color var(--t-farbe) var(--kurve); }
.zelle.voll { grid-column: 1 / -1; }
.zelle:hover { background: #fff; }
.zelle:focus-within { background: #fff; box-shadow: inset 0 0 0 2px var(--text); z-index: 1; }
.zelle > label, .zelle > .titel { display: flex; gap: 8px; align-items: baseline; padding: 10px 14px 0; cursor: pointer; }
.zelle > label .nr, .zelle > .titel .nr { color: var(--leise); font-family: 'Geist Mono', monospace; font-size: 10.5px; letter-spacing: .06em; }
.zelle .opt { font-family: 'Atkinson Hyperlegible Next', sans-serif; font-stretch: 100%; text-transform: none; letter-spacing: 0; font-weight: 400; font-size: 12.5px; color: var(--leise); }
.zelle input, .zelle select, .zelle textarea { width: 100%; flex: 1; min-height: var(--ziel); padding: 6px 14px 10px; font-size: 1.0625rem; line-height: 1.35; border: 0; background: transparent; outline: none; appearance: none; border-radius: 0; }
.zelle textarea { min-height: 76px; resize: vertical; }
.zelle select { padding-right: 38px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%231a1a1a' stroke-width='1.6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 14px center; }
.zelle input::placeholder, .zelle textarea::placeholder { color: #85807a; }
.zelle .meldung { display: none; gap: 7px; align-items: flex-start; padding: 0 14px 10px; font-size: 13.5px; line-height: 1.35; color: var(--fehler); }
.zelle .meldung::before { content: '!'; flex: none; width: 15px; height: 15px; margin-top: 1px; border-radius: 50%; background: var(--fehler); color: #fff; font: 700 10.5px/15px 'Atkinson Hyperlegible Next', sans-serif; text-align: center; }
.zelle.fehler { background: #fbf1ef; box-shadow: inset 4px 0 0 var(--fehler); }
.zelle.fehler:focus-within { box-shadow: inset 4px 0 0 var(--fehler), inset 0 0 0 2px var(--fehler); }
.zelle.fehler .meldung { display: flex; animation: rein var(--t-rein) var(--kurve) both; }
.zelle .haken { position: absolute; right: 12px; top: 12px; width: 14px; height: 14px; opacity: 0; transition: opacity var(--t-farbe) var(--kurve); pointer-events: none; }
.zelle.gut .haken { opacity: 1; }
.zelle .ablage { display: flex; align-items: center; gap: 10px; padding: 6px 14px 12px; font-size: 15px; position: relative; }
.zelle .ablage input { position: absolute; inset: 0; opacity: 0; cursor: pointer; min-height: 0; padding: 0; }
.zelle .ablage b { font-weight: 700; }
.zelle .ablage small { color: var(--leise); font-size: 13px; }
/* Was der Agent sieht: Anmerkungen an jeder Zelle */
.anm { display: none; }
.agent .anm { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; padding: 2px 14px 10px; font: 400 11.5px/1.45 'Geist Mono', ui-monospace, monospace; color: var(--blau); animation: rein var(--t-rein) var(--kurve) both; }
.anm b { font-weight: 600; background: rgba(31, 69, 181, .1); padding: 1px 5px; border-radius: 2px; }
.anm span { color: #31437a; }
.agent .zettel { outline: 1px dashed var(--blau); outline-offset: 5px; }
.agent .zelle { background-image: linear-gradient(rgba(31, 69, 181, .06) 1px, transparent 1px), linear-gradient(90deg, rgba(31, 69, 181, .06) 1px, transparent 1px); background-size: 12px 12px; }
.agent .zelle.ausser { background-image: repeating-linear-gradient(-45deg, rgba(26, 26, 26, .05) 0 6px, transparent 6px 12px); }
/* Aktiv, während ein Agent das Werkzeug bedient: echter Zustand (Chrome) und nachgestellter Zustand */
.werkzeug-aktiv .zettel { outline: 1px dashed var(--blau); outline-offset: -1px; }
@supports selector(:tool-form-active) {
form:tool-form-active .zettel { outline: 1px dashed var(--blau); outline-offset: -1px; }
.absenden:tool-submit-active { outline: 2px dashed var(--signal-tief); outline-offset: 3px; }
}
.ausgefuellt { background: #eef2fd !important; }
.agent-hinweis { display: none; align-items: flex-start; gap: 12px; margin-bottom: 12px; padding: 12px 14px; border: 1px dashed var(--blau); background: #eef2fd; color: #14275f; font-size: 14.5px; line-height: 1.45; animation: rein var(--t-rein) var(--kurve) both; }
.agent-hinweis.an { display: flex; }
.agent-hinweis svg { flex: none; margin-top: 2px; }
.agent-hinweis b { font-weight: 700; }
.agent-hinweis button { margin-left: auto; flex: none; font: inherit; font-size: 13.5px; font-weight: 700; color: #14275f; background: none; border: 1px solid currentColor; border-radius: 99px; padding: 4px 12px; min-height: 32px; cursor: pointer; }
.agent-hinweis button:focus-visible { outline: 2px solid var(--blau); outline-offset: 2px; }
.senden { display: flex; align-items: center; gap: 12px 20px; flex-wrap: wrap; margin-top: 16px; }
.absenden { display: inline-flex; align-items: center; gap: 12px; min-height: 54px; padding: 0 24px; border: 0; border-radius: 2px; background: var(--signal); color: var(--text); font-weight: 700; font-size: 16.5px; cursor: pointer; transition: background-color var(--t-farbe) var(--kurve), scale var(--t-farbe) var(--kurve); }
.absenden:hover { background: var(--signal-tief); }
.absenden:active, .absenden.druck { scale: .97; }
.absenden:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.absenden .kreis { display: none; width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(26, 26, 26, .25); border-top-color: var(--text); animation: drehen .8s linear infinite; }
.absenden[aria-busy="true"] { pointer-events: none; }
.absenden[aria-busy="true"] .kreis { display: block; }
.absenden[aria-busy="true"] .pfeil { display: none; }
.senden p { font-size: 13.5px; line-height: 1.45; color: var(--leise); max-width: 40ch; }
.senden p a { color: var(--text); }
/* Bestätigung */
.fertig { display: none; border: 1px solid var(--linie); background: var(--feld); padding: clamp(20px, 3vw, 36px); }
.gesendet .fertig { display: block; animation: rein var(--t-rein) var(--kurve) both; }
.gesendet form, .gesendet .zettel-kopf, .gesendet .agent-hinweis { display: none !important; }
.fertig .etikett { color: var(--leise); display: flex; gap: 10px; align-items: center; }
.fertig .etikett::before { content: ''; width: 10px; height: 10px; background: var(--signal); }
.fertig h2 { margin: 12px 0 10px; font-family: 'Special Gothic', sans-serif; font-weight: 700; font-size: clamp(1.75rem, 1.3rem + 1.6vw, 2.5rem); line-height: 1.05; outline: none; }
.fertig > p { font-size: 1.0625rem; line-height: 1.55; max-width: 46ch; }
.fertig ol { list-style: none; margin: 18px 0; border-top: 1px solid var(--linie); }
.fertig li { display: grid; grid-template-columns: 2.6em minmax(0, 1fr) auto; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(26, 26, 26, .22); align-items: baseline; font-size: 15px; }
.fertig li em { font-style: normal; color: var(--leise); font-size: 13.5px; white-space: nowrap; }
.fertig li .nr { font-family: 'Geist Mono', monospace; font-size: 11px; color: var(--leise); }
.neu { font-weight: 700; font-size: 15px; background: none; border: 1px solid var(--linie); border-radius: 2px; min-height: var(--ziel); padding: 0 20px; cursor: pointer; transition: background-color var(--t-farbe) var(--kurve), color var(--t-farbe) var(--kurve); }
.neu:hover { background: var(--text); color: var(--grund); }
.neu:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
/* Fuß */
.fuss { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px 24px; padding: 14px clamp(16px, 3vw, 40px); border-top: 1px solid var(--linie); font-size: 13px; color: var(--leise); }
.fuss p > * + *::before { content: '·'; margin: 0 9px; opacity: .6; }
@keyframes rein { from { opacity: 0; translate: 0 8px; } }
@keyframes drehen { to { rotate: 360deg; } }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform 520ms var(--kurve); }
.vorfuehrung .zeiger { display: block; }
.zeiger.klick circle { animation: tipp 420ms var(--kurve); }
@keyframes tipp { from { r: 3; opacity: .5; } to { r: 16; opacity: 0; } }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .demo, .vorschau .fuss, .vorschau .kasten, .vorschau .schema-bild, .vorschau .sicht-hinweis { display: none; }
.vorschau .kopf { padding-block: 12px; }
.vorschau .zettel-kopf p { display: none; }
.vorschau .zelle { min-height: 70px; }
.vorschau .zelle textarea { min-height: 52px; }
.vorschau .senden p { display: none; }
.vorschau .maschine { bottom: auto; height: calc(100svh - 172px); }
.vorschau .anm span:last-child { flex-basis: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Breiten */
@media (max-width: 1100px) {
.kasten a:not([aria-current]):nth-child(-n+2) { display: none; }
.sicht-hinweis { display: none; }
}
@media (max-width: 860px) {
.bereich { grid-template-columns: minmax(0, 1fr); }
.links { border-right: 0; border-bottom: 1px solid var(--linie); }
.mensch-text { padding-right: 0; }
.agent .mensch-text { display: none; }
.maschine { position: static; display: none; margin: 14px 0; height: auto; max-height: 560px; }
.agent .maschine { display: flex; }
.rechts { padding-left: 0; }
.schema-bild { display: none; }
.demo .werte { display: none; }
.kasten { display: none; }
}
@media (max-width: 560px) {
.zettel { grid-template-columns: minmax(0, 1fr); }
.marke b { font-size: 20px; }
.anruf { padding: 0 12px; font-size: 14px; gap: 8px; }
.erkennung span { letter-spacing: .08em; }
.absenden { width: 100%; justify-content: center; }
.wege div { grid-template-columns: 6em minmax(0, 1fr); }
.fertig li { grid-template-columns: 2.2em minmax(0, 1fr); }
.fertig li em { grid-column: 2; }
.agent-hinweis { flex-wrap: wrap; }
}
/* Touch: keine Hover-Gesten, größere Ziele, keine Zoom-Sprünge unter iOS */
@media (hover: none) {
:root { --ziel: 52px; }
.zelle:hover { background: var(--feld); }
.absenden:hover, .anruf:hover { background: var(--signal); }
.zelle input, .zelle select, .zelle textarea { font-size: 17px; }
.schalter button { min-height: 42px; }
}
/* Reduzierte Bewegung: Zustände wechseln ohne Weg und ohne Dauer */
.reduziert *, .reduziert *::before, .reduziert *::after { transition-duration: .01ms !important; transition-delay: 0ms !important; animation-duration: .01ms !important; animation-delay: 0ms !important; }
.reduziert .mensch-text, .reduziert .maschine { translate: none !important; }
.reduziert .absenden .kreis { animation: none; border-right-color: var(--text); }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } input, select, textarea { border: 1px solid CanvasText !important; } }
</style>
</head>
<body>
<div class="demo mono">
<div class="gruppe">
<span>Variante</span>
<div class="varianten" role="group" aria-label="Formular-Variante">
<button type="button" data-v="termin" aria-pressed="true">Wartungstermin</button>
<button type="button" data-v="projekt" aria-pressed="false">Projektanfrage</button>
</div>
</div>
<p class="werte" data-werte></p>
</div>
<header class="kopf">
<a class="marke" href="#"><b>KLÜTMANN</b><span>Haustechnik · Hameln</span></a>
<nav class="kasten" aria-label="Hauptnavigation">
<a href="#">Heizung</a><a href="#">Bad</a><a href="#">Wärmepumpe</a><a href="#termin" aria-current="page">Termin</a>
</nav>
<a class="anruf ziffern" href="tel:+495151402760"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 1.5 6 4.8 4.6 6.4c.8 1.9 2.2 3.3 4.1 4.1l1.6-1.4 3.3 1.8-.6 2.7c-.1.4-.5.7-.9.7C6.5 14.1 1.9 9.5 1.6 3.9c0-.4.3-.8.7-.9Z" fill="currentColor"/></svg>05151 40 27 60</a>
</header>
<div class="sicht" data-sichtleiste>
<div class="sicht-links">
<span class="etikett" id="sicht-l">Ansicht</span>
<div class="schalter" role="group" aria-labelledby="sicht-l">
<button type="button" data-sicht="mensch" aria-pressed="true">Mensch</button>
<button type="button" data-sicht="agent" aria-pressed="false">Agent</button>
</div>
<p class="sicht-hinweis">Dasselbe Formular, einmal so, wie ein Mensch es sieht, einmal als Werkzeug für KI-Assistenten.</p>
</div>
<p class="mono erkennung" data-erkennung><i></i><span>WebMCP wird geprüft</span></p>
</div>
<main class="bereich" id="termin">
<div class="links">
<section class="mensch-text" aria-labelledby="t-titel" data-mensch>
<p class="etikett augenbraue" data-text="augenbraue">Termin · Wartung und Störung</p>
<h1 id="t-titel" data-text="titel">Wartung fällig? Tragen Sie es hier ein.</h1>
<p class="vorspann" data-text="vorspann">Wir melden uns innerhalb eines Werktags mit einem festen Termin. Fällt die Heizung ganz aus, rufen Sie bitte direkt an, auch am Wochenende.</p>
<dl class="wege">
<div><dt class="etikett">Telefon</dt><dd><a class="ziffern" href="tel:+495151402760">05151 40 27 60</a> · Mo–Fr 7–17 Uhr</dd></div>
<div><dt class="etikett">Notdienst</dt><dd class="ziffern">0171 552 18 40 · rund um die Uhr</dd></div>
<div><dt class="etikett">Gebiet</dt><dd>Hameln, Aerzen, Emmerthal, Bad Pyrmont, Hessisch Oldendorf, Bad Münder, Coppenbrügge, Salzhemmendorf</dd></div>
</dl>
<figure class="schema-bild" aria-hidden="true">
<svg viewBox="0 0 440 120" fill="none" stroke="#1a1a1a" stroke-width="1.2" stroke-linejoin="round">
<rect x="14" y="14" width="64" height="92" fill="#d3cdc2"/>
<rect x="24" y="26" width="44" height="18" fill="#f8f7f4"/>
<path d="M30 35h8M42 35h4M50 35h10" stroke-width="1"/>
<circle cx="46" cy="76" r="12" fill="#efedea"/><path d="M46 68v8l5 4"/>
<path d="M78 34H210V24h120" stroke="#c46a2a" stroke-width="2.2"/>
<path d="M78 88H230V98h100" stroke="#5d6b8a" stroke-width="2.2"/>
<g fill="#efedea"><rect x="330" y="14" width="80" height="92"/></g>
<path d="M342 14v92M354 14v92M366 14v92M378 14v92M390 14v92M402 14v92" stroke-width=".9"/>
<rect x="196" y="76" width="34" height="24" fill="#bdc1a9"/>
<path d="M206 88h14" stroke-width="1"/>
<circle cx="120" cy="34" r="5" fill="#efedea"/><circle cx="120" cy="88" r="5" fill="#efedea"/>
<g font-family="Geist Mono, monospace" font-size="9" fill="#59544d" stroke="none"><text x="14" y="118">01 KESSEL</text><text x="112" y="58">02 VENTILE</text><text x="190" y="118">03 PUMPE</text><text x="330" y="118">04 HEIZKÖRPER</text></g>
</svg>
<figcaption>Was wir bei der Wartung prüfen: Kessel, Ventile, Pumpe, Heizkörper.</figcaption>
</figure>
</section>
<!-- Agenten-Sicht: zeigt, was ein Agent über diese Seite erfährt (nur Demo-Werkzeug) -->
<section class="maschine" aria-label="Agenten-Sicht" data-maschine aria-hidden="true">
<div class="m-kopf"><b>Was ein Agent sieht</b><span class="mono" data-quelle>document.modelContext</span></div>
<div class="reiter" role="tablist" aria-label="Werkzeuge und Dateien">
<button type="button" role="tab" id="r-form" aria-controls="p-form" aria-selected="true" data-reiter="form">Formular</button>
<button type="button" role="tab" id="r-imp" aria-controls="p-imp" aria-selected="false" tabindex="-1" data-reiter="imp">Lese-Werkzeug</button>
<button type="button" role="tab" id="r-llms" aria-controls="p-llms" aria-selected="false" tabindex="-1" data-reiter="llms">/llms.txt</button>
</div>
<div class="tafel" role="tabpanel" id="p-form" aria-labelledby="r-form" tabindex="0">
<p class="erkl"><b>Deklarativ.</b> Der Browser liest <code>toolname</code>, <code>tooldescription</code> und je Feld <code>toolparamdescription</code> und baut daraus dieses Werkzeug. Live aus dem Formular abgeleitet.</p>
<pre data-json-form></pre>
<p class="fussnote" data-fuss-form></p>
</div>
<div class="tafel" role="tabpanel" id="p-imp" aria-labelledby="r-imp" tabindex="0" hidden>
<p class="erkl"><b>Imperativ.</b> Per Skript registriert, nur wenn <code>document.modelContext</code> existiert. Liest nur, ändert nichts: <code>readOnlyHint: true</code>.</p>
<pre data-json-imp></pre>
<p class="fussnote" data-fuss-imp></p>
</div>
<div class="tafel" role="tabpanel" id="p-llms" aria-labelledby="r-llms" tabindex="0" hidden>
<p class="erkl"><b>Optional.</b> Statische Datei im Wurzelverzeichnis, erklärt Agenten in Markdown, was es gibt und wo. Ohne belegte Wirkung bei Google: Für AI Overviews und AI Mode braucht es laut Google keine neuen Dateien. Für die Sichtbarkeit zählen strukturierte Daten, die zum sichtbaren Text passen.</p>
<pre data-llms></pre>
</div>
<div class="protokoll">
<div class="protokoll-kopf"><span class="mono">Protokoll</span><button type="button" class="spielen" data-spielen>Agent ausprobieren</button></div>
<ol data-protokoll aria-live="polite"><li class="leer">Noch nichts passiert. „Agent ausprobieren“ spielt einen Aufruf nach.</li></ol>
</div>
</section>
</div>
<div class="rechts" data-rechts>
<div class="zettel-kopf">
<h2 data-text="formtitel">Wartung oder Reparatur</h2>
<p class="ziffern" data-text="formmeta">9 Felder, 6 davon Pflicht · etwa 2 Minuten</p>
</div>
<div class="agent-hinweis" role="status" data-agent-hinweis>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="#1f45b5" stroke-width="1.4" aria-hidden="true"><rect x="2.5" y="4.5" width="13" height="10" rx="2"/><path d="M9 4.5V2M6.5 9h.01M11.5 9h.01" stroke-linecap="round" stroke-width="2"/></svg>
<p><b>Ein Assistent hat das Formular ausgefüllt.</b> Bitte prüfen Sie die Angaben und schicken Sie selbst ab.</p>
<button type="button" data-verwerfen>Verwerfen</button>
</div>
<!-- Variante 1: Wartungstermin. Kein toolautosubmit: der Mensch schickt ab. -->
<form novalidate data-form="termin" action="/termin" method="post"
toolname="termin_anfragen"
tooldescription="Fragt bei Klütmann Haustechnik in Hameln einen Termin für Wartung, Störung oder Reparatur an Heizung und Sanitär an. Der Betrieb ruft innerhalb eines Werktags zurück und bestätigt den genauen Termin. Bei komplettem Heizungsausfall stattdessen den Notdienst anrufen.">
<div class="zettel">
<div class="zelle">
<label for="t-anliegen" class="etikett"><span class="nr">01</span>Anliegen</label>
<select id="t-anliegen" name="anliegen" required aria-describedby="t-anliegen-f"
toolparamdescription="Art des Einsatzes. Bei Heizungsausfall im Winter den Notdienst anrufen.">
<option value="">Bitte wählen</option>
<option value="wartung">Jährliche Wartung</option>
<option value="stoerung">Störung, Heizung läuft unruhig oder zeigt Fehler</option>
<option value="sanitaer">Reparatur Sanitär (Wasserhahn, WC, Leitung)</option>
<option value="beratung">Beratung zum Heizungstausch</option>
</select>
<p class="meldung" id="t-anliegen-f"></p>
</div>
<div class="zelle">
<label for="t-anlage" class="etikett"><span class="nr">02</span>Anlage <span class="opt">(optional)</span></label>
<select id="t-anlage" name="anlage"
toolparamdescription="Art der Heizungsanlage, falls bekannt.">
<option value="unbekannt">Weiß ich nicht</option>
<option value="gas">Gas-Brennwert</option>
<option value="oel">Öl</option>
<option value="waermepumpe">Wärmepumpe</option>
<option value="pellets">Pellets</option>
</select>
</div>
<div class="zelle">
<label for="t-plz" class="etikett"><span class="nr">03</span>PLZ des Einsatzorts</label>
<input id="t-plz" name="plz" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" required placeholder="z. B. 31785" aria-describedby="t-plz-f"
toolparamdescription="Fünfstellige Postleitzahl des Einsatzorts, z. B. 31785. Einzugsgebiet ist der Landkreis Hameln-Pyrmont.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="t-plz-f"></p>
</div>
<div class="zelle">
<label for="t-woche" class="etikett"><span class="nr">04</span>Wunschwoche</label>
<select id="t-woche" name="woche" required class="ziffern" aria-describedby="t-woche-f" data-wochen
toolparamdescription="Kalenderwoche, in der der Termin stattfinden soll, im Format JJJJ-Www.">
<option value="">Bitte wählen</option>
</select>
<p class="meldung" id="t-woche-f"></p>
</div>
<div class="zelle">
<label for="t-fenster" class="etikett"><span class="nr">05</span>Zeitfenster</label>
<select id="t-fenster" name="zeitfenster" required class="ziffern" aria-describedby="t-fenster-f"
toolparamdescription="Bevorzugte Tageszeit für den Einsatz.">
<option value="">Bitte wählen</option>
<option value="vormittag">Vormittags, 7 bis 12 Uhr</option>
<option value="nachmittag">Nachmittags, 12 bis 16 Uhr</option>
<option value="egal">Ganztägig flexibel</option>
</select>
<p class="meldung" id="t-fenster-f"></p>
</div>
<div class="zelle">
<label for="t-name" class="etikett"><span class="nr">06</span>Vor- und Nachname</label>
<input id="t-name" name="name" type="text" autocomplete="name" minlength="2" required aria-describedby="t-name-f"
toolparamdescription="Vor- und Nachname der Person, die den Termin wahrnimmt.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="t-name-f"></p>
</div>
<div class="zelle">
<label for="t-tel" class="etikett"><span class="nr">07</span>Telefon für Rückruf</label>
<input id="t-tel" name="telefon" type="tel" inputmode="tel" autocomplete="tel" pattern="[0-9+ \/\-]{6,}" required aria-describedby="t-tel-f"
toolparamdescription="Telefonnummer für den Rückruf, z. B. 05151 123456.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="t-tel-f"></p>
</div>
<div class="zelle">
<label for="t-mail" class="etikett"><span class="nr">08</span>E-Mail <span class="opt">(optional)</span></label>
<input id="t-mail" name="email" type="email" inputmode="email" autocomplete="email" aria-describedby="t-mail-f"
toolparamdescription="E-Mail-Adresse für die schriftliche Terminbestätigung.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="t-mail-f"></p>
</div>
<div class="zelle voll">
<label for="t-hinweis" class="etikett"><span class="nr">09</span>Hinweise <span class="opt">(optional)</span></label>
<textarea id="t-hinweis" name="hinweise" rows="2" placeholder="Fehlercode, Baujahr, Zugang zum Heizraum"
toolparamdescription="Freitext für Fehlercode, Baujahr der Anlage oder Zugang zum Heizraum."></textarea>
</div>
</div>
<div class="senden">
<button class="absenden" type="submit" data-absenden><span class="kreis" aria-hidden="true"></span><span data-text="knopf">Termin anfragen</span><svg class="pfeil" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M1 6h13M9.5 1.5 14 6l-4.5 4.5"/></svg></button>
<p>Ihre Angaben nutzen wir nur für diesen Auftrag. Mehr in der <a href="#">Datenschutzerklärung</a>.</p>
</div>
</form>
<!-- Variante 2: Projektanfrage (Angebot). Ebenfalls ohne toolautosubmit. -->
<form novalidate data-form="projekt" action="/angebot" method="post" hidden
toolname="angebot_anfragen"
tooldescription="Fragt bei Klütmann Haustechnik in Hameln ein Angebot für ein Projekt an: Heizungstausch auf Wärmepumpe, Badsanierung, neue Heizflächen oder Warmwasser. Der Betrieb meldet sich innerhalb von zwei Werktagen und vereinbart eine Beratung vor Ort.">
<div class="zettel">
<div class="zelle">
<label for="p-vorhaben" class="etikett"><span class="nr">01</span>Vorhaben</label>
<select id="p-vorhaben" name="vorhaben" required aria-describedby="p-vorhaben-f"
toolparamdescription="Art des Projekts.">
<option value="">Bitte wählen</option>
<option value="waermepumpe">Wärmepumpe statt alter Heizung</option>
<option value="bad">Bad sanieren oder umbauen</option>
<option value="heizflaechen">Neue Heizkörper oder Fußbodenheizung</option>
<option value="warmwasser">Warmwasser oder Solarthermie</option>
</select>
<p class="meldung" id="p-vorhaben-f"></p>
</div>
<div class="zelle">
<label for="p-gebaeude" class="etikett"><span class="nr">02</span>Gebäude</label>
<select id="p-gebaeude" name="gebaeude" required aria-describedby="p-gebaeude-f"
toolparamdescription="Gebäudeart des Einsatzorts.">
<option value="">Bitte wählen</option>
<option value="efh">Einfamilienhaus</option>
<option value="dhh">Doppel- oder Reihenhaus</option>
<option value="mfh">Mehrfamilienhaus</option>
<option value="gewerbe">Gewerbe</option>
</select>
<p class="meldung" id="p-gebaeude-f"></p>
</div>
<div class="zelle">
<label for="p-plz" class="etikett"><span class="nr">03</span>PLZ des Objekts</label>
<input id="p-plz" name="plz" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" required placeholder="z. B. 31855" aria-describedby="p-plz-f"
toolparamdescription="Fünfstellige Postleitzahl des Objekts, z. B. 31855. Vorher mit einzugsgebiet_pruefen klären.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="p-plz-f"></p>
</div>
<div class="zelle">
<label for="p-zeitraum" class="etikett"><span class="nr">04</span>Zeitraum</label>
<select id="p-zeitraum" name="zeitraum"
toolparamdescription="Wann das Projekt umgesetzt werden soll.">
<option value="bald">So bald wie möglich</option>
<option value="3">In den nächsten 3 Monaten</option>
<option value="6-12">In 6 bis 12 Monaten</option>
<option value="beratung">Erst einmal nur Beratung</option>
</select>
</div>
<div class="zelle">
<label for="p-flaeche" class="etikett"><span class="nr">05</span>Wohnfläche in m² <span class="opt">(optional)</span></label>
<input id="p-flaeche" name="wohnflaeche" type="text" inputmode="numeric" pattern="[0-9]{2,4}" maxlength="4" placeholder="z. B. 140" class="ziffern" aria-describedby="p-flaeche-f"
toolparamdescription="Beheizte Wohnfläche in Quadratmetern, nur die Zahl.">
<p class="meldung" id="p-flaeche-f"></p>
</div>
<div class="zelle">
<label for="p-name" class="etikett"><span class="nr">06</span>Vor- und Nachname</label>
<input id="p-name" name="name" type="text" autocomplete="name" minlength="2" required aria-describedby="p-name-f"
toolparamdescription="Vor- und Nachname der Ansprechperson.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="p-name-f"></p>
</div>
<div class="zelle">
<label for="p-mail" class="etikett"><span class="nr">07</span>E-Mail für das Angebot</label>
<input id="p-mail" name="email" type="email" inputmode="email" autocomplete="email" required aria-describedby="p-mail-f"
toolparamdescription="E-Mail-Adresse, an die das Angebot geht.">
<svg class="haken" viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.2 3 3 6-6.4" fill="none" stroke="#1a1a1a" stroke-width="1.8"/></svg>
<p class="meldung" id="p-mail-f"></p>
</div>
<div class="zelle" data-ausser-schema>
<span class="etikett titel" id="p-fotos-t"><span class="nr">08</span>Fotos <span class="opt">(optional)</span></span>
<label class="ablage">
<svg width="20" height="20" viewBox="0 0 22 22" fill="none" stroke="#1a1a1a" stroke-width="1.4" aria-hidden="true"><rect x="2.5" y="4.5" width="17" height="13" rx="1"/><path d="m2.5 15 5-5 4 4 3-3 5 5"/><circle cx="15" cy="8.5" r="1.5"/></svg>
<span><b>Heizraum, Bad</b> <small data-dateien>JPG oder HEIC</small></span>
<input type="file" name="fotos" accept="image/*" multiple aria-labelledby="p-fotos-t" data-datei>
</label>
</div>
<div class="zelle voll">
<label for="p-text" class="etikett"><span class="nr">09</span>Beschreibung <span class="opt">(optional)</span></label>
<textarea id="p-text" name="beschreibung" rows="2" placeholder="Heizung heute, Baujahr, Förderung geplant?"
toolparamdescription="Freitext zum Bestand: heutige Heizung, Baujahr, geplante Förderung."></textarea>
</div>
</div>
<div class="senden">
<button class="absenden" type="submit"><span class="kreis" aria-hidden="true"></span><span>Angebot anfragen</span><svg class="pfeil" width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M1 6h13M9.5 1.5 14 6l-4.5 4.5"/></svg></button>
<p>Angebot kostenlos und unverbindlich. Mehr in der <a href="#">Datenschutzerklärung</a>.</p>
</div>
</form>
<div class="fertig" role="status" data-fertig>
<p class="etikett ziffern" data-vorgang>Auftrag angelegt · KH-26-0412</p>
<h2 tabindex="-1" data-danke>Danke. Wir rufen zurück.</h2>
<p data-antwort></p>
<ol data-schritte></ol>
<button type="button" class="neu" data-neu>Neue Anfrage</button>
</div>
</div>
</main>
<footer class="fuss">
<p><span>Klütmann Haustechnik GmbH</span><span>Meisterbetrieb, Handwerkskammer Hannover</span><span>Am Klütgrund 6, 31787 Hameln</span></p>
<p><span>Mo–Fr 7–17 Uhr</span><span>Notdienst 24 h</span></p>
</footer>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true" overflow="visible"><circle cx="3" cy="2" r="3" fill="#1a1a1a" opacity="0"/><path d="M2 1.5 16.5 9.6l-6.4 1.4-3.1 6Z" fill="#1a1a1a" stroke="#efedea" stroke-width="1.3" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const T = { farbe: 150, sicht: 240, rein: 400, senden: 900, tippen: 22 }; // ms · Kurve cubic-bezier(0.4, 0, 0.2, 1)
const schlaf = ms => new Promise(r => setTimeout(r, reduziert() ? Math.min(ms, 60) : ms));
/* ---------- Feature-Erkennung WebMCP ---------- */
// Seit dem Entwurf vom Juli 2026 an document; navigator ist der alte, veraltete Ort (Origin Trial liefert beide).
const mc = document.modelContext || navigator.modelContext || null;
const mcOrt = document.modelContext ? 'document.modelContext' : (navigator.modelContext ? 'navigator.modelContext' : '');
const deklarativ = !!(window.CSS && CSS.supports && CSS.supports('selector(:tool-form-active)'));
const imperativ = !!(mc && typeof mc.registerTool === 'function');
/* ---------- Varianten ---------- */
const VARIANTEN = {
termin: {
augenbraue: 'Termin · Wartung und Störung',
titel: 'Wartung fällig? Tragen Sie es hier ein.',
vorspann: 'Wir melden uns innerhalb eines Werktags mit einem festen Termin. Fällt die Heizung ganz aus, rufen Sie bitte direkt an, auch am Wochenende.',
formtitel: 'Wartung oder Reparatur', knopf: 'Termin anfragen',
antwort: 'Wir rufen Sie bis morgen 12 Uhr an und bestätigen den genauen Termin. Die Wartung dauert etwa eine Stunde.',
schritte: [['Rückruf', 'Termin und Zugang klären', 'bis morgen, 12 Uhr'], ['Bestätigung', 'per SMS oder E-Mail', 'nach dem Anruf'], ['Wartung', 'Kessel, Ventile, Pumpe, Protokoll', 'etwa 60 Min.']],
prefix: 'KH-26-04', autosubmit: false,
},
projekt: {
augenbraue: 'Projekt · Heizung und Bad',
titel: 'Neue Heizung, neues Bad? Erzählen Sie es uns.',
vorspann: 'Wir sehen uns das Haus vor Ort an und rechnen ehrlich, auch mit Förderung. Das Angebot ist kostenlos und unverbindlich.',
formtitel: 'Angebot anfragen', knopf: 'Angebot anfragen',
antwort: 'Wir melden uns innerhalb von zwei Werktagen und vereinbaren einen Termin für die Beratung vor Ort.',
schritte: [['Erstgespräch', 'Telefonisch, etwa 15 Minuten', 'in 2 Werktagen'], ['Vor-Ort-Termin', 'Heizraum, Heizflächen, Platz', 'nach Absprache'], ['Angebot', 'Mit Förderhinweisen, schriftlich', 'in 10 Werktagen']],
prefix: 'KH-26-P1', autosubmit: false,
},
};
const qv = new URLSearchParams(location.search).get('v');
let variante = VARIANTEN[qv] ? qv : 'termin';
const formVon = v => $(`[data-form="${v}"]`);
const form = () => formVon(variante);
// toolname/tooldescription stehen im Markup; die inaktive Variante legt sie ab (Entfernen meldet das Werkzeug ab).
$$('form[data-form]').forEach(f => { f.dataset.toolname = f.getAttribute('toolname'); f.dataset.tooldescription = f.getAttribute('tooldescription'); });
/* ---------- Wunschwochen aus dem heutigen Datum ---------- */
function isoWoche(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const tag = t.getUTCDay() || 7; t.setUTCDate(t.getUTCDate() + 4 - tag);
const j = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return [t.getUTCFullYear(), Math.ceil(((t - j) / 864e5 + 1) / 7)];
}
const WOCHEN = [];
(() => {
const h = new Date(); const mo = new Date(h); mo.setDate(h.getDate() + ((8 - (h.getDay() || 7)) % 7 || 7));
for (let i = 0; i < 4; i++) {
const a = new Date(mo); a.setDate(mo.getDate() + i * 7); const e = new Date(a); e.setDate(a.getDate() + 4);
const [j, w] = isoWoche(a);
const von = a.getMonth() === e.getMonth() ? `${a.getDate()}.` : `${a.getDate()}.${a.getMonth() + 1}.`;
WOCHEN.push({ wert: `${j}-W${String(w).padStart(2, '0')}`, text: `KW ${w} · ${von}–${e.getDate()}.${e.getMonth() + 1}.`, a });
}
const sel = $('[data-wochen]');
WOCHEN.forEach(w => sel.add(new Option(w.text, w.wert)));
})();
/* ---------- Schema ableiten (Regeln aus der Chrome-Doku, Declarative API) ---------- */
function beschreibung(el) {
const t = el.getAttribute('toolparamdescription');
if (t) return t;
const l = el.labels && el.labels[0];
if (l) return l.textContent.replace(/\s+/g, ' ').replace(/^\d+\s*/, '').trim();
return el.getAttribute('aria-description') || '';
}
function schemaVon(f) {
const props = {}, req = [], ausser = [];
[...f.elements].forEach(el => {
if (!el.name || el.disabled || /^(submit|button|reset)$/.test(el.type)) return;
if (el.type === 'file') { ausser.push(el.name); return; }
const p = { type: 'string' };
if (el.tagName === 'SELECT') {
const ops = [...el.options].filter(o => o.value !== '');
p.anyOf = ops.map(o => ({ type: 'string', const: o.value, title: o.text }));
p.enum = ops.map(o => o.value);
}
const d = beschreibung(el); if (d) p.description = d;
props[el.name] = p;
if (el.required) req.push(el.name);
});
return { def: { name: f.dataset.toolname, description: f.dataset.tooldescription, inputSchema: { type: 'object', properties: props, required: req } }, ausser };
}
/* ---------- Imperative Lese-Werkzeuge ---------- */
const GEBIET = { '31785': ['Hameln', 4], '31787': ['Hameln', 2], '31789': ['Hameln', 6], '31855': ['Aerzen', 11], '31860': ['Emmerthal', 12], '31812': ['Bad Pyrmont', 19], '31840': ['Hessisch Oldendorf', 13], '31848': ['Bad Münder', 17], '31863': ['Coppenbrügge', 16], '31020': ['Salzhemmendorf', 22] };
const plzSchema = { type: 'string', description: 'Fünfstellige Postleitzahl, z. B. 31785' };
const WERKZEUGE = {
termin: {
name: 'freie_termine_abfragen',
description: 'Liefert freie Zeitfenster für Wartung und Reparatur in den nächsten vier Wochen für eine Postleitzahl im Einzugsgebiet von Klütmann Haustechnik. Ändert nichts.',
inputSchema: { type: 'object', properties: { plz: plzSchema, woche: { type: 'string', description: 'Optional: Kalenderwoche im Format JJJJ-Www' } }, required: ['plz'] },
annotations: { readOnlyHint: true },
execute: async ({ plz, woche } = {}) => {
const g = GEBIET[String(plz || '').trim()];
if (!g) return `PLZ ${plz} liegt außerhalb des Einzugsgebiets (Landkreis Hameln-Pyrmont). Bitte telefonisch anfragen: 05151 40 27 60.`;
const n = Number(plz) % 3;
const frei = WOCHEN.filter(w => !woche || w.wert === woche).slice(0, 3).map((w, i) => ({ woche: w.wert, zeitfenster: ['vormittag', 'nachmittag', 'egal'][(i + n) % 3] }));
return JSON.stringify({ ort: g[0], frei });
},
},
projekt: {
name: 'einzugsgebiet_pruefen',
description: 'Prüft, ob eine Postleitzahl im Einzugsgebiet von Klütmann Haustechnik liegt, und nennt die Anfahrt für eine Beratung vor Ort. Ändert nichts.',
inputSchema: { type: 'object', properties: { plz: plzSchema }, required: ['plz'] },
annotations: { readOnlyHint: true },
execute: async ({ plz } = {}) => {
const g = GEBIET[String(plz || '').trim()];
return JSON.stringify(g ? { im_gebiet: true, ort: g[0], anfahrt_km: g[1], beratung_vor_ort: 'kostenlos' } : { im_gebiet: false, hinweis: 'Außerhalb des Landkreises Hameln-Pyrmont. Bitte telefonisch anfragen.' });
},
},
};
let abmelden = null;
async function imperativRegistrieren() {
if (abmelden) { try { abmelden(); } catch (e) {} abmelden = null; }
if (!imperativ) return false;
const w = WERKZEUGE[variante], ctrl = new AbortController();
try {
const r = await mc.registerTool({ name: w.name, description: w.description, inputSchema: w.inputSchema, annotations: w.annotations, execute: w.execute }, { signal: ctrl.signal });
// Ältere Origin-Trial-Stände gaben ein Objekt mit unregister() zurück
abmelden = () => { ctrl.abort(); if (r && typeof r.unregister === 'function') r.unregister(); };
return true;
} catch (e) { protokoll('Fehler', 'registerTool: ' + e.message); return false; }
}
if (mc && typeof mc.addEventListener === 'function') {
mc.addEventListener('toolactivated', e => { if (e.toolName === form().dataset.toolname) agentHat(true); protokoll('Ereignis', 'toolactivated · ' + e.toolName); });
mc.addEventListener('toolcancel', e => { agentHat(false); protokoll('Ereignis', 'toolcancel · ' + e.toolName); });
}
/* ---------- Darstellung der Agenten-Sicht ---------- */
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
function jsonHtml(o) {
return esc(JSON.stringify(o, null, 2)).replace(/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?/g, (m, s, k, b) =>
s ? (k ? `<span class="j-k">${s}</span><span class="j-p">${k}</span>` : `<span class="j-s">${s}</span>`) : b ? `<span class="j-b">${m}</span>` : `<span class="j-n">${m}</span>`);
}
function llms() {
const w = WOCHEN[0];
return `# Klütmann Haustechnik
> Meisterbetrieb für Heizung, Sanitär und Wärmepumpen in Hameln. Wartung, Störungsdienst und neue Anlagen im Landkreis Hameln-Pyrmont.
Termine und Angebote nehmen wir über zwei Formulare an. Beide sind als WebMCP-Werkzeuge beschrieben (termin_anfragen, angebot_anfragen). Abgeschickt wird immer von einem Menschen. Bei komplettem Heizungsausfall bitte anrufen: 0171 552 18 40 (rund um die Uhr).
## Termin und Kontakt
- [Wartung oder Reparatur](https://kluetmann-haustechnik.de/termin): Formular, Werkzeug termin_anfragen, freie Zeiten über freie_termine_abfragen
- [Angebot anfragen](https://kluetmann-haustechnik.de/angebot): Formular, Werkzeug angebot_anfragen, vorher einzugsgebiet_pruefen
- [Einzugsgebiet](https://kluetmann-haustechnik.de/einzugsgebiet.md): Hameln, Aerzen, Emmerthal, Bad Pyrmont und weitere Orte mit PLZ
## Leistungen
- [Heizungswartung](https://kluetmann-haustechnik.de/wartung.md): Ablauf, Dauer, Preise inkl. MwSt.
- [Wärmepumpe](https://kluetmann-haustechnik.de/waermepumpe.md): Beratung, Einbau, Förderung
- [Bad](https://kluetmann-haustechnik.de/bad.md): Sanierung und barrierearmer Umbau
## Optional
- [Über uns](https://kluetmann-haustechnik.de/ueber-uns.md): Team, Meisterbrief, Ausbildung
- [Impressum](https://kluetmann-haustechnik.de/impressum)`;
}
function mdHtml(t) {
return esc(t).split('\n').map(z => /^#/.test(z) ? `<span class="md-h">${z}</span>` : /^>/.test(z) ? `<span class="md-q">${z}</span>` :
z.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<span class="md-m">[</span>$1<span class="md-m">](</span><span class="md-l">$2</span><span class="md-m">)</span>')).join('\n');
}
function sichtAktualisieren() {
const f = form(), { def, ausser } = schemaVon(f);
$('[data-json-form]').innerHTML = jsonHtml(def);
$('[data-fuss-form]').innerHTML = `<b>toolautosubmit</b> nicht gesetzt: Der Agent füllt aus, abschicken muss ein Mensch. ` +
`Antwort an den Agenten über <b>respondWith()</b> im submit-Handler. ` +
(ausser.length ? `Nicht im Schema (Annahme, in der Doku nicht beschrieben): <b>${ausser.join(', ')}</b> (Dateifeld). ` : '') +
`Alle Auswahlfelder sind <b><select></b>, damit es feste Werte gibt.`;
const w = WERKZEUGE[variante];
$('[data-json-imp]').innerHTML = esc('await document.modelContext.registerTool(') + '\n' + jsonHtml({ name: w.name, description: w.description, inputSchema: w.inputSchema, annotations: w.annotations }).replace(/\n}$/, ',\n <span class="j-k">execute</span><span class="j-p">:</span> <span class="j-p">async (args) => …</span>\n}') + esc(', { signal: controller.signal });');
$('[data-fuss-imp]').innerHTML = `Abmelden: <b>controller.abort()</b>, etwa beim Variantenwechsel. Weitere Hinweise: <b>untrustedContentHint</b>, <b>consequentialHint</b> (Standard false).`;
$('[data-llms]').innerHTML = mdHtml(llms());
// Anmerkungen an den Zellen: was der Agent über jedes Feld erfährt
$$('.zelle', f).forEach(z => {
$('.anm', z)?.remove();
const el = $('input, select, textarea', z); if (!el || !el.name) return;
const a = document.createElement('p'); a.className = 'anm'; a.setAttribute('aria-hidden', 'true');
if (el.type === 'file') { z.classList.add('ausser'); a.innerHTML = `<b>${el.name}</b><span>Dateifeld · nicht im Schema</span>`; }
else {
const art = el.tagName === 'SELECT' ? `enum (${[...el.options].filter(o => o.value).length})` : 'string';
a.innerHTML = `<b>${el.name}</b><span>${art}${el.required ? ' · required' : ''}</span><span>„${esc(beschreibung(el))}“</span>`;
}
z.append(a);
});
}
/* ---------- Erkennung anzeigen ---------- */
function erkennungZeigen() {
const e = $('[data-erkennung]');
if (imperativ || deklarativ) {
e.dataset.an = '';
$('span', e).textContent = `WebMCP aktiv · ${[deklarativ && 'deklarativ', imperativ && mcOrt].filter(Boolean).join(' · ')}`;
} else $('span', e).textContent = 'WebMCP hier nicht verfügbar · Formular läuft normal';
$('[data-quelle]').textContent = imperativ ? 'live · ' + mcOrt : 'nachgestellt';
}
/* ---------- Werte ---------- */
function werteZeigen() {
const f = form(), { def } = schemaVon(f);
$('[data-werte]').innerHTML = `<span>toolname <b>${def.name}</b></span><span>Parameter <b>${Object.keys(def.inputSchema.properties).length}</b> · required <b>${def.inputSchema.required.length}</b></span><span>toolautosubmit <b>aus</b></span><span>Lese-Werkzeug <b>${WERKZEUGE[variante].name}</b></span><span>150 / 240 / 400 ms</span>`;
const anz = [...f.elements].filter(el => el.name && !/^(submit|button)$/.test(el.type)).length;
$('[data-text="formmeta"]').textContent = `${anz} Felder, ${def.inputSchema.required.length} davon Pflicht · etwa 2 Minuten`;
}
function varianteSetzen(v, url) {
if (!VARIANTEN[v]) v = 'termin';
variante = v;
$$('form[data-form]').forEach(f => {
const an = f.dataset.form === v;
f.hidden = !an;
if (an) { f.setAttribute('toolname', f.dataset.toolname); f.setAttribute('tooldescription', f.dataset.tooldescription); }
else { f.removeAttribute('toolname'); f.removeAttribute('tooldescription'); }
});
const d = VARIANTEN[v];
$$('[data-text]').forEach(el => { if (typeof d[el.dataset.text] === 'string') el.textContent = d[el.dataset.text]; });
$$('.varianten button').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', '?' + q.toString() + location.hash); }
zuruecksetzen();
sichtAktualisieren(); werteZeigen();
imperativRegistrieren().then(erkennungZeigen);
}
$$('.varianten button').forEach(b => b.addEventListener('click', () => { stopp(); varianteSetzen(b.dataset.v, true); }));
/* ---------- Mensch / Agent ---------- */
let sicht = 'mensch';
function sichtSetzen(s) {
sicht = s;
html.classList.toggle('agent', s === 'agent');
$$('[data-sicht]').forEach(b => b.setAttribute('aria-pressed', b.dataset.sicht === s));
$('[data-maschine]').setAttribute('aria-hidden', s !== 'agent');
$('[data-mensch]').toggleAttribute('inert', s === 'agent');
$('[data-maschine]').toggleAttribute('inert', s !== 'agent');
}
$$('[data-sicht]').forEach(b => b.addEventListener('click', () => { stopp(); sichtSetzen(b.dataset.sicht); }));
// Reiter mit Pfeiltasten
const reiter = $$('[role=tab]');
function reiterWaehlen(t, fokus) {
reiter.forEach(r => { const an = r === t; r.setAttribute('aria-selected', an); r.tabIndex = an ? 0 : -1; $('#' + r.getAttribute('aria-controls')).hidden = !an; });
if (fokus) t.focus();
}
reiter.forEach((r, i) => {
r.addEventListener('click', () => reiterWaehlen(r));
r.addEventListener('keydown', e => {
const n = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (n) { e.preventDefault(); reiterWaehlen(reiter[(i + n + reiter.length) % reiter.length], true); }
if (e.key === 'Home') { e.preventDefault(); reiterWaehlen(reiter[0], true); }
if (e.key === 'End') { e.preventDefault(); reiterWaehlen(reiter[reiter.length - 1], true); }
});
});
/* ---------- Protokoll ---------- */
const liste = $('[data-protokoll]');
function protokoll(art, text) {
$('.leer', liste)?.remove();
const li = document.createElement('li');
li.innerHTML = `<span>${esc(art)}</span><span>${esc(text)}</span>`;
liste.append(li); liste.scrollTop = liste.scrollHeight;
}
function protokollLeeren() { liste.innerHTML = '<li class="leer">Noch nichts passiert. „Agent ausprobieren“ spielt einen Aufruf nach.</li>'; }
/* ---------- Prüfen: erst nach dem Verlassen, danach live ---------- */
const TEXTE = {
anliegen: { valueMissing: 'Bitte wählen Sie, worum es geht.' },
vorhaben: { valueMissing: 'Bitte wählen Sie ein Vorhaben.' },
gebaeude: { valueMissing: 'Bitte die Gebäudeart wählen.' },
woche: { valueMissing: 'Bitte eine Wunschwoche wählen.' },
zeitfenster: { valueMissing: 'Bitte ein Zeitfenster wählen.' },
plz: { valueMissing: 'Bitte die Postleitzahl angeben.', patternMismatch: 'Die PLZ hat fünf Ziffern, z. B. 31785.', gebiet: 'Diese PLZ liegt außerhalb unseres Gebiets. Rufen Sie uns gern an.' },
name: { valueMissing: 'Wie dürfen wir Sie ansprechen?', tooShort: 'Bitte den vollständigen Namen angeben.' },
telefon: { valueMissing: 'Für den Rückruf brauchen wir Ihre Nummer.', patternMismatch: 'Bitte eine vollständige Nummer, z. B. 05151 123456.' },
email: { valueMissing: 'Bitte eine E-Mail-Adresse für das Angebot angeben.', typeMismatch: 'Die Adresse scheint unvollständig, z. B. name@beispiel.de.' },
wohnflaeche: { patternMismatch: 'Bitte nur die Zahl, z. B. 140.' },
};
function fehlerArt(el) {
const v = el.validity;
if (v.valueMissing) return 'valueMissing';
if (v.typeMismatch) return 'typeMismatch';
if (v.patternMismatch) return 'patternMismatch';
if (el.minLength > 0 && el.value.trim().length && el.value.trim().length < el.minLength) return 'tooShort';
if (el.name === 'plz' && !GEBIET[el.value.trim()]) return 'gebiet';
return '';
}
function pruefen(el, zeigen) {
const z = el.closest('.zelle'); if (!z || !TEXTE[el.name]) return true;
const art = fehlerArt(el);
if (zeigen) z.dataset.beruehrt = '1';
if (!z.dataset.beruehrt) return !art;
const m = $('.meldung', z);
if (art) { m.textContent = TEXTE[el.name][art]; z.classList.add('fehler'); z.classList.remove('gut'); el.setAttribute('aria-invalid', 'true'); }
else { m.textContent = ''; z.classList.remove('fehler'); z.classList.toggle('gut', el.tagName === 'INPUT' && el.value.trim() !== ''); el.removeAttribute('aria-invalid'); }
return !art;
}
function allesPruefen(f) {
const fehler = [];
[...f.elements].forEach(el => { if (el.name && TEXTE[el.name] && !pruefen(el, true)) fehler.push(el); });
return fehler;
}
$$('form[data-form]').forEach(f => {
f.addEventListener('focusout', e => { const el = e.target; if (!el.name || (el.value.trim() === '' && !el.closest('.zelle')?.dataset.beruehrt)) return; pruefen(el, true); });
f.addEventListener('input', e => { e.target.closest('.zelle')?.classList.remove('ausgefuellt'); pruefen(e.target, false); });
f.addEventListener('change', e => { if (e.target.tagName === 'SELECT') pruefen(e.target, true); if (e.target.type === 'file') $('[data-dateien]').textContent = e.target.files.length ? `${e.target.files.length} Bild(er) gewählt` : 'JPG oder HEIC'; });
f.addEventListener('submit', absenden);
});
/* ---------- Absenden: Mensch oder Agent ---------- */
let agentGefuellt = false, sendeUhr = null, nummer = 411;
function agentHat(an) {
agentGefuellt = an;
$('[data-agent-hinweis]').classList.toggle('an', an);
form().classList.toggle('werkzeug-aktiv', an);
if (!an) $$('.ausgefuellt').forEach(z => z.classList.remove('ausgefuellt'));
}
function absenden(e) {
e.preventDefault();
const f = e.currentTarget, agent = e.agentInvoked === true;
const fehler = allesPruefen(f);
const daten = Object.fromEntries([...new FormData(f)].filter(([k, v]) => typeof v === 'string' && v !== ''));
if (fehler.length) {
const text = 'Nicht gesendet. ' + fehler.map(el => `${el.name}: ${$('.meldung', el.closest('.zelle')).textContent}`).join(' ');
if (agent && typeof e.respondWith === 'function') e.respondWith(Promise.resolve(text));
if (agentGefuellt || agent) protokoll('Antwort', text);
if (!agent) fehler[0].focus();
return;
}
const vorgang = `${VARIANTEN[variante].prefix}${++nummer}`;
const antwort = new Promise(res => {
$('.absenden', f).setAttribute('aria-busy', 'true');
clearTimeout(sendeUhr);
sendeUhr = setTimeout(() => {
$('.absenden', f).removeAttribute('aria-busy');
bestaetigen(vorgang, daten, !agent && !vorfuehrt);
res(`Anfrage ${vorgang} eingegangen. ${VARIANTEN[variante].antwort}`);
}, reduziert() ? 200 : T.senden);
});
if (agent && typeof e.respondWith === 'function') e.respondWith(antwort);
if (agentGefuellt || agent) {
protokoll('submit', `agentInvoked: ${agent ? 'true' : 'true (nachgestellt)'} · Mensch hat geklickt`);
antwort.then(t => protokoll('Antwort', `respondWith → „${t.split('.')[0]}.“`));
}
}
function bestaetigen(vorgang, daten, fokus) {
const d = VARIANTEN[variante];
$('[data-vorgang]').textContent = `Auftrag angelegt · ${vorgang}`;
const vn = (daten.name || '').split(/\s+/)[0];
$('[data-danke]').textContent = variante === 'termin' ? (vn ? `Danke, ${vn}. Wir rufen zurück.` : 'Danke. Wir rufen zurück.') : (vn ? `Danke, ${vn}. Wir melden uns.` : 'Danke. Wir melden uns.');
$('[data-antwort]').textContent = d.antwort;
$('[data-schritte]').innerHTML = d.schritte.map(([t, s, w], i) => `<li><span class="nr">0${i + 1}</span><span><b>${t}</b> · ${s}</span><em>${w}</em></li>`).join('');
$('[data-rechts]').classList.add('gesendet');
agentHat(false);
if (fokus) $('[data-danke]').focus();
}
function zuruecksetzen() {
clearTimeout(sendeUhr);
$$('form[data-form]').forEach(f => {
f.reset(); $$('.absenden', f).forEach(b => b.removeAttribute('aria-busy'));
$$('.zelle', f).forEach(z => { z.classList.remove('fehler', 'gut', 'ausgefuellt'); delete z.dataset.beruehrt; });
$$('[aria-invalid]', f).forEach(el => el.removeAttribute('aria-invalid'));
$$('.meldung', f).forEach(m => m.textContent = '');
});
$('[data-dateien]').textContent = 'JPG oder HEIC';
$('[data-rechts]').classList.remove('gesendet');
agentHat(false);
}
$('[data-neu]').addEventListener('click', () => { zuruecksetzen(); $('select, input', form()).focus(); });
$('[data-verwerfen]').addEventListener('click', () => { zuruecksetzen(); protokoll('Mensch', 'Ausfüllen verworfen (wie toolcancel)'); });
/* ---------- Agent nachspielen ---------- */
const BEISPIEL = {
termin: { anliegen: 'wartung', anlage: 'gas', plz: '31787', woche: () => WOCHEN[1].wert, zeitfenster: 'vormittag', name: 'Jana Brinkmann', telefon: '05151 918273', hinweise: 'Gastherme Baujahr 2014, letzte Wartung 2023. Heizraum im Keller, Zugang über die Garage.' },
projekt: { vorhaben: 'waermepumpe', gebaeude: 'efh', plz: '31855', zeitraum: '6-12', wohnflaeche: '142', name: 'Ole Wiechmann', email: 'o.wiechmann@beispiel.de', beschreibung: 'Gasheizung von 2006, unten Fußbodenheizung, oben Heizkörper. Förderung soll beantragt werden.' },
};
let lauf = 0, vorfuehrt = false;
async function agentSpielen(mitKlick) {
const meinLauf = ++lauf, ok = () => meinLauf === lauf;
const knopf = $('[data-spielen]'); knopf.disabled = true;
zuruecksetzen(); protokollLeeren();
if (sicht !== 'agent') { sichtSetzen('agent'); await schlaf(T.sicht + 200); }
const f = form(), w = WERKZEUGE[variante], b = BEISPIEL[variante];
protokoll('GET', '/llms.txt → 200 · 2 Formulare, 1 Lese-Werkzeug'); await schlaf(700); if (!ok()) return;
reiterWaehlen($('#r-imp'));
const args = { plz: b.plz };
protokoll('Aufruf', `${w.name} ${JSON.stringify(args)}`); await schlaf(500); if (!ok()) return;
const erg = await w.execute(args);
protokoll('Ergebnis', erg.length > 90 ? erg.slice(0, 88) + '…' : erg); await schlaf(900); if (!ok()) return;
reiterWaehlen($('#r-form'));
protokoll('Ereignis', `toolactivated · ${f.dataset.toolname}`);
agentHat(true);
for (const [name, wert] of Object.entries(b)) {
if (!ok()) return;
const el = f.elements[name]; const v = typeof wert === 'function' ? wert() : wert;
const z = el.closest('.zelle'); z.classList.add('ausgefuellt');
if (el.tagName === 'SELECT' || reduziert()) el.value = v;
else { el.value = ''; for (let i = 1; i <= v.length; i++) { el.value = v.slice(0, i); if (i % 2 === 0) await schlaf(T.tippen); if (!ok()) return; } }
z.dataset.beruehrt = '1'; pruefen(el, true);
await schlaf(reduziert() ? 0 : 160);
}
protokoll('Warten', 'kein toolautosubmit · Mensch prüft und schickt ab');
knopf.disabled = false;
if (!mitKlick || !ok()) return;
await schlaf(1300); if (!ok()) return;
const ab = $('.absenden', f);
await zeigerZu(ab); if (!ok()) return;
ab.classList.add('druck'); setTimeout(() => ab.classList.remove('druck'), T.farbe);
f.requestSubmit(ab);
}
$('[data-spielen]').addEventListener('click', () => { stopp(); agentSpielen(false); });
/* ---------- Zeiger der Vorführung ---------- */
const zeiger = $('[data-zeiger]');
async function zeigerZu(el) {
const r = el.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + r.width * .62}px, ${r.top + r.height * .55}px)`;
await schlaf(560);
zeiger.classList.remove('klick'); void zeiger.offsetWidth; zeiger.classList.add('klick');
await schlaf(160);
}
/* ---------- Fundus-API ---------- */
let schleife = null;
function stopp() { clearTimeout(schleife); lauf++; vorfuehrt = false; html.classList.remove('vorfuehrung'); $('[data-spielen]').disabled = false; }
function neustart() { stopp(); zuruecksetzen(); protokollLeeren(); sichtSetzen('mensch'); reiterWaehlen($('#r-form')); }
async function runde() {
const meinLauf = lauf;
zuruecksetzen(); protokollLeeren(); sichtSetzen('mensch'); reiterWaehlen($('#r-form'));
zeiger.style.transform = `translate(${innerWidth * .5}px, ${innerHeight * .9}px)`;
await schlaf(1400); if (meinLauf !== lauf) return;
await zeigerZu($('[data-sicht="agent"]')); if (meinLauf !== lauf) return;
sichtSetzen('agent'); await schlaf(1600); if (meinLauf !== lauf) return;
lauf--; // agentSpielen erhöht den Zähler selbst wieder auf meinLauf
await agentSpielen(true); if (meinLauf !== lauf) return;
await schlaf(3200); if (meinLauf !== lauf) return;
schleife = setTimeout(runde, 200);
}
function vorfuehren() {
stopp(); vorfuehrt = true; html.classList.add('vorfuehrung');
runde();
}
function pause() { stopp(); }
function weiter() { vorfuehren(); }
varianteSetzen(variante, false);
sichtSetzen(new URLSearchParams(location.search).get('sicht') === 'agent' ? 'agent' : 'mensch');
erkennungZeigen();
})();
</script>
</body>
</html>