diff --git a/public/embed/zeiten.js b/public/embed/zeiten.js
index 54c8d93..1bffadf 100644
--- a/public/embed/zeiten.js
+++ b/public/embed/zeiten.js
@@ -15,6 +15,22 @@
* Variablen (alle optional, an den Platzhalter oder :root): --wkz-offen, --wkz-zu, --wkz-unklar (Punktfarben), --wkz-offen-hell,
* --wkz-zu-hell, --wkz-unklar-hell, --wkz-offen-text, --wkz-zu-text, --wkz-unklar-text (Badge), --wkz-rand, --wkz-heute.
*
+ * Leisten und JSON-LD (kommen mit den Daten, ohne Platzhalter; das Team kann sie je Seite abschalten):
+ * Hinweis-Leiste Shadow-DOM-Host [data-wkz-leiste="hinweis"] als erstes Kind von
, im Fluss (schiebt den Inhalt). Zeigt den
+ * gueltigen Hinweis mit dem hoechsten Rang (Info, Aktion, Urlaub, Wichtig); role="region" aria-label="Hinweis",
+ * kein Schliessen-Knopf. Feste und klebende Elemente mit top: 0 (und die des Team-Selektors navSelektor) rutschen per
+ * Inline-top um max(0, Leistenhoehe - Scrollposition) nach unten (beim Entfernen wie vorher), scroll-padding-top des
+ * Dokuments waechst um die Hoehe. Ein festes Element, das schon ueber der Leiste endet (die Seite hat seinen Platz per
+ * body-Polster freigehalten, die Leiste steht darunter), bleibt, wo es ist. Mit prefers-reduced-motion ohne Einblendung.
+ * Kontakt-Leiste nur bei (max-width: 768px): Shadow-DOM-Host [data-wkz-leiste="kontakt"] unten fest (z-index 40) mit Anrufen, Route
+ * und WhatsApp (nur, was angegeben ist), dazu ein Abstandhalter [data-wkz-abstand] am Ende von und
+ * scroll-padding-bottom. Entfaellt, wenn die Seite selbst ein festes Element am unteren Rand mit tel:-Link hat.
+ * JSON-LD ein vorhandener application/ld+json-Block mit einem Unternehmen vor Ort (LocalBusiness oder Untertyp, auch in @graph)
+ * bekommt nur openingHoursSpecification und specialOpeningHoursSpecification neu; gibt es keinen, legt das Skript
+ * einen Block [data-wkz-jsonld] an. Unlesbare Bloecke bleiben unberuehrt.
+ * Jede Adresse aus der Schnittstelle wird im Browser noch einmal geprueft (https:, tel: nur ueber telefonLink); Links tragen
+ * rel="noopener noreferrer".
+ *
* Ein Abruf je Seite fuer alle Teile; keine Abhaengigkeiten, keine Cookies, kein Storage, keine Anfragen an Dritte. Kundentexte nur
* ueber textContent. Das Skript rechnet nie mit Feiertagen, Kalender oder Zeitzone des Browsers: "jetzt" ist Date.now() (weicht die
* Uhr laut Date-Kopf um mehr als 5 Minuten ab, die Serverzeit), alles andere kommt als Zeichenkette vom Server. Es laedt hoechstens
@@ -183,6 +199,143 @@
if (g.bis && g.bis !== g.von) datum += ' – ' + datumKurz(g.bis)
return { datum: datum, text: (g.name || artName(g.art) || 'Abweichung') + ': ' + g.text }
}
+
+ function istObjekt (v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
+
+ // Farben der Hinweis-Leiste je Art (Hintergrund, Schrift): Kontrast jeweils mindestens 4,5:1 (geprueft in test/zeitenEmbedStatic.test.js)
+ var HINWEIS_FARBEN = {
+ info: { bg: '#e0f2fe', fg: '#0c4a6e' },
+ aktion: { bg: '#dcfce7', fg: '#14532d' },
+ urlaub: { bg: '#fef3c7', fg: '#78350f' },
+ wichtig: { bg: '#b91c1c', fg: '#ffffff' }
+ }
+
+ // schema.org: LocalBusiness und seine Untertypen (Unternehmen vor Ort); Person, Organization, WebSite, WebPage u. a. gehoeren nicht dazu
+ var LOKALE_TYPEN = ' LocalBusiness AnimalShelter ArchiveOrganization AutomotiveBusiness AutoBodyShop AutoDealer AutoPartsStore AutoRental AutoRepair AutoWash GasStation MotorcycleDealer MotorcycleRepair ' +
+ 'ChildCare Dentist DryCleaningOrLaundry EmergencyService FireStation Hospital PoliceStation EmploymentAgency EntertainmentBusiness AdultEntertainment AmusementPark ArtGallery Casino ComedyClub MovieTheater NightClub ' +
+ 'FinancialService AccountingService AutomatedTeller BankOrCreditUnion InsuranceAgency FoodEstablishment Bakery BarOrPub Brewery CafeOrCoffeeShop Distillery FastFoodRestaurant IceCreamShop Restaurant Winery ' +
+ 'GovernmentOffice PostOffice HealthAndBeautyBusiness BeautySalon DaySpa HairSalon HealthClub NailSalon TattooParlor HomeAndConstructionBusiness Electrician GeneralContractor HVACBusiness HousePainter Locksmith MovingCompany Plumber RoofingContractor ' +
+ 'InternetCafe LegalService Attorney Notary Library LodgingBusiness BedAndBreakfast Campground Hostel Hotel Motel Resort SkiResort VacationRental ' +
+ 'MedicalBusiness CommunityHealth Dermatology DietNutrition Emergency Geriatric Gynecologic MedicalClinic Midwifery Nursing Obstetric Oncologic Optician Optometric Otolaryngologic Pediatric Pharmacy Physician Physiotherapy PlasticSurgery Podiatric PrimaryCare Psychiatric PublicHealth ' +
+ 'ProfessionalService RadioStation RealEstateAgent RecyclingCenter SelfStorage ShoppingCenter SportsActivityLocation BowlingAlley ExerciseGym GolfCourse PublicSwimmingPool SportsClub StadiumOrArena TennisComplex ' +
+ 'Store BikeStore BookStore ClothingStore ComputerStore ConvenienceStore DepartmentStore ElectronicsStore Florist FurnitureStore GardenStore GroceryStore HardwareStore HobbyShop HomeGoodsStore JewelryStore LiquorStore MensClothingStore MobilePhoneStore MovieRentalStore MusicStore OfficeEquipmentStore OutletStore PawnShop PetStore ShoeStore SportingGoodsStore TireShop ToyStore WholesaleStore ' +
+ 'TelevisionStation TouristInformationCenter TravelAgency '
+
+ // Eine Adresse aus der Schnittstelle: nur https mit Host, ohne Leer- und Steuerzeichen (sonst ''). Es steht keine feste Webadresse im Skript,
+ // nur diese Pruefung des Schemas.
+ function sichererLink (url) {
+ if (typeof url !== 'string' || url.length > 2000 || /[\s\u0000-\u001f\u007f]/.test(url)) return ''
+ return /^https:\/\/[^\/?#]+/i.test(url) ? url : ''
+ }
+
+ // Knopf eines Hinweises { art: 'link'|'anruf', ziel, text } als { art, href, text } oder null. Links: nur https; Anruf: tel: nur ueber telefonLink.
+ function hinweisKnopf (knopf) {
+ if (!istObjekt(knopf)) return null
+ var href = knopf.art === 'link' ? sichererLink(knopf.ziel) : knopf.art === 'anruf' ? telefonLink(knopf.ziel) : ''
+ if (!href) return null
+ var text = typeof knopf.text === 'string' ? knopf.text.replace(/^\s+|\s+$/g, '') : ''
+ return { art: knopf.art, href: href, text: text || (knopf.art === 'anruf' ? 'Anrufen' : 'Mehr erfahren') }
+ }
+
+ // Knoepfe der Kontakt-Leiste aus { telefon, routeUrl, whatsappUrl }: [{ art: 'anruf'|'route'|'whatsapp', href, text }], nur mit brauchbaren Angaben.
+ // telefon = Ziffern mit Laendervorwahl ohne "+" (E.164), daraus tel:+... ueber telefonLink; Route und WhatsApp nur als https-Adresse.
+ function kontaktKnoepfe (kontakt) {
+ var liste = []
+ if (!istObjekt(kontakt)) return liste
+ var tel = typeof kontakt.telefon === 'string' && /^[1-9][0-9]{5,14}$/.test(kontakt.telefon) ? telefonLink('+' + kontakt.telefon) : ''
+ if (tel) liste.push({ art: 'anruf', href: tel, text: 'Anrufen' })
+ var route = sichererLink(kontakt.routeUrl)
+ if (route) liste.push({ art: 'route', href: route, text: 'Route' })
+ var wa = sichererLink(kontakt.whatsappUrl)
+ if (wa) liste.push({ art: 'whatsapp', href: wa, text: 'WhatsApp' })
+ return liste
+ }
+
+ // Hat die Seite schon eine eigene Anruf-Leiste? elemente = die festen/klebenden Vorfahren aller a[href^="tel:"] der Seite als
+ // { position, bottom (berechnet), oben, unten (Rand im Fenster), fenster (Fensterhoehe), sichtbar }. Zaehlt: sichtbar, fest (oder klebend mit
+ // bottom) und in der unteren Haelfte, hoechstens 160 px ueber dem unteren Rand (auch ein schwebender Anruf-Knopf). Kopfzeilen, bildschirmhohe
+ // Seitenleisten und Ausgeblendetes zaehlen nicht.
+ function istEigeneAnrufLeiste (elemente) {
+ if (!Array.isArray(elemente)) return false
+ for (var i = 0; i < elemente.length; i++) {
+ var e = elemente[i]
+ if (!e || e.sichtbar !== true || !istZahl(e.oben) || !istZahl(e.unten) || !istZahl(e.fenster)) continue
+ if (e.position !== 'fixed' && !(e.position === 'sticky' && e.bottom !== 'auto')) continue
+ if (e.oben >= e.fenster / 2 && e.oben < e.fenster && e.unten >= e.fenster - 160) return true
+ }
+ return false
+ }
+
+ function typVon (t) { return typeof t === 'string' ? t.replace(/^.*[\/:#]/, '') : '' }
+
+ // Ein Knoten ist ein Unternehmen vor Ort, wenn einer seiner @type (Text oder Liste, auch mit Praefix oder als Adresse) LocalBusiness oder ein Untertyp ist.
+ function istLokalesUnternehmen (knoten) {
+ var typen = Array.isArray(knoten['@type']) ? knoten['@type'] : [knoten['@type']]
+ for (var i = 0; i < typen.length; i++) {
+ var t = typVon(typen[i])
+ if (/^[A-Za-z]+$/.test(t) && LOKALE_TYPEN.indexOf(' ' + t + ' ') >= 0) return true
+ }
+ return false
+ }
+
+ // Alle Objekte eines geparsten JSON-LD-Blocks: er selbst (Objekt oder Liste von Objekten) und die Knoten seines @graph.
+ function sammleKnoten (wert, aus, tiefe) {
+ if (tiefe > 4) return
+ if (Array.isArray(wert)) {
+ for (var i = 0; i < wert.length; i++) sammleKnoten(wert[i], aus, tiefe + 1)
+ return
+ }
+ if (!istObjekt(wert)) return
+ aus.push(wert)
+ if (Array.isArray(wert['@graph'])) sammleKnoten(wert['@graph'], aus, tiefe + 1)
+ }
+
+ function kopie (v) { return JSON.parse(JSON.stringify(v)) }
+
+ // Zeiten fuer schema.org: bloecke = geparste application/ld+json-Inhalte der Seite (unparsbare als null), jsonld = { openingHoursSpecification,
+ // specialOpeningHoursSpecification, neuerBlock } der Schnittstelle, seite = { name, adresse, url }. In jedem Unternehmen vor Ort der Bloecke
+ // werden NUR openingHoursSpecification und specialOpeningHoursSpecification ersetzt (leere Listen entfernen das Feld); die Bloecke werden
+ // dabei veraendert, geaendert = ihre Indizes (nur wo sich etwas aendert). Gibt es kein Unternehmen vor Ort, ist neu der neuerBlock des Servers
+ // mit name, address (wenn bekannt) und url (ohne Namen: null, ein LocalBusiness ohne name taugt nicht). Ohne jsonld bleibt alles, wie es ist.
+ function jsonLdEinbauen (bloecke, jsonld, seite) {
+ var erg = { geaendert: [], neu: null }
+ if (!istObjekt(jsonld)) return erg
+ if (!Array.isArray(bloecke)) bloecke = []
+ var zeiten = Array.isArray(jsonld.openingHoursSpecification) ? jsonld.openingHoursSpecification : []
+ var sonder = Array.isArray(jsonld.specialOpeningHoursSpecification) ? jsonld.specialOpeningHoursSpecification : []
+ var gefunden = false
+ var i, k
+ function setze (knoten, schluessel, liste) {
+ if (liste.length) knoten[schluessel] = kopie(liste)
+ else delete knoten[schluessel]
+ }
+ for (i = 0; i < bloecke.length; i++) {
+ var knoten = []
+ sammleKnoten(bloecke[i], knoten, 0)
+ var geaendert = false
+ for (k = 0; k < knoten.length; k++) {
+ if (!istLokalesUnternehmen(knoten[k])) continue
+ gefunden = true
+ var vorher = JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification])
+ setze(knoten[k], 'openingHoursSpecification', zeiten)
+ setze(knoten[k], 'specialOpeningHoursSpecification', sonder)
+ if (JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification]) !== vorher) geaendert = true
+ }
+ if (geaendert) erg.geaendert.push(i)
+ }
+ var s = istObjekt(seite) ? seite : {}
+ var name = typeof s.name === 'string' ? s.name.replace(/^\s+|\s+$/g, '') : ''
+ if (!gefunden && name && istObjekt(jsonld.neuerBlock)) {
+ var neu = kopie(jsonld.neuerBlock)
+ neu.name = name
+ if (typeof s.adresse === 'string' && s.adresse.replace(/^\s+|\s+$/g, '')) neu.address = s.adresse.replace(/^\s+|\s+$/g, '')
+ if (typeof s.url === 'string' && s.url) neu.url = s.url
+ if (Array.isArray(neu.openingHoursSpecification) && !neu.openingHoursSpecification.length) delete neu.openingHoursSpecification
+ if (Array.isArray(neu.specialOpeningHoursSpecification) && !neu.specialOpeningHoursSpecification.length) delete neu.specialOpeningHoursSpecification
+ erg.neu = neu
+ }
+ return erg
+ }
// @rein-ende
// @telefon-anfang (reine Funktion, test/zeitenEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel wie in public/embed/termine.js; fuer Kontakt-Leiste und Hinweis-Knoepfe)
@@ -225,7 +378,7 @@
// ---------------------------------------------------------------- Zustand (nur im Speicher der Seite)
- var daten = null // letzte gueltige Antwort { plaene, hinweise } oder null (aktiv: false, Fehler, noch kein Abruf)
+ var daten = null // letzte gueltige Antwort { plaene, hinweise, kontakt, jsonld, firma, team } oder null (aktiv: false, Fehler, noch kein Abruf)
var geladenMs = 0 // Date.now() beim letzten ERFOLGREICHEN Abruf (nicht body.stand: ein 304 liefert alte Daten)
var versatz = 0 // Serverzeit minus lokale Uhr, nur bei mehr als 5 Minuten Abweichung (sonst 0)
var versuchMs = 0 // Beginn des letzten Abrufs, ob erfolgreich oder nicht
@@ -234,6 +387,18 @@
var platzhalter = []
var siteKey = ''
var api = ''
+ var HANDY_MQ = '(max-width: 768px)' // Kontakt-Leiste nur auf schmalen Schirmen
+ var hinweisLeiste = null // { host, sig, nav, hoehe, fest } der Hinweis-Leiste auf der Seite (null: keine)
+ var kontaktLeiste = null // { host, abstand, sig } der Kontakt-Leiste samt Abstandhalter (null: keine)
+ var SCROLL_PADDING = { top: 'scroll-padding-top', bottom: 'scroll-padding-bottom' }
+ var scrollPadding = { top: null, bottom: null } // was die Seite vor uns in scroll-padding-top/-bottom hatte: { wert, prio, basis }
+ var handyAbfrage = null
+ var hoererAn = false
+ var beobachter = null
+ var scrollWartet = false
+ var groesseWartet = false
+ var ldSig = ''
+ var ldEigenes = null // der von uns angelegte JSON-LD-Block (nur wenn die Seite keinen eigenen hat)
function timer (fn, ms) {
var d = Number(ms)
@@ -283,7 +448,14 @@
daten = null
return
}
- daten = { plaene: body.plaene.filter(planGueltig), hinweise: Array.isArray(body.hinweise) ? body.hinweise : [] }
+ daten = {
+ plaene: body.plaene.filter(planGueltig),
+ hinweise: Array.isArray(body.hinweise) ? body.hinweise : [],
+ kontakt: istObjekt(body.kontakt) ? body.kontakt : null,
+ jsonld: istObjekt(body.jsonld) ? body.jsonld : null,
+ firma: istObjekt(body.firma) ? body.firma : {},
+ team: istObjekt(body.team) ? body.team : {}
+ }
}
function lade () {
@@ -302,7 +474,14 @@
laeuft = false
try { render() } finally { plane() }
}
- fetch(api + API_PFAD + encodeURIComponent(siteKey), opts).then(function (r) {
+ var anfrage
+ try {
+ anfrage = fetch(api + API_PFAD + encodeURIComponent(siteKey), opts)
+ } catch (e) {
+ fertig() // ein sofort werfender fetch beendet den Abruf wie ein Fehler (laeuft bleibt nie haengen)
+ return
+ }
+ anfrage.then(function (r) {
if (!r.ok) throw new Error('Status ' + r.status)
var kopf = r.headers && typeof r.headers.get === 'function' ? r.headers.get('Date') : null
return r.json().then(function (body) { uebernimm(body, kopf) })
@@ -446,13 +625,380 @@
else zeigeZustand(p, 'wkz-badge', eintrag.kurz, eintrag)
}
+ // Ein Fehler in einem Teil (Platzhalter, Hinweis-Leiste, Kontakt-Leiste, JSON-LD) darf die anderen nicht aufhalten
+ function sicher (fn) {
+ try { fn() } catch (e) { warne('Fehler beim Zeichnen: ' + (e && e.message ? e.message : e)) }
+ }
+
+ function fuelleSicher (p, d, t) { sicher(function () { fuelle(p, d, t) }) }
+
function render () {
sammle()
platzhalter = platzhalter.filter(function (p) { return p.root.isConnected !== false })
var d = frisch() ? daten : null
var t = jetzt()
- for (var i = 0; i < platzhalter.length; i++) fuelle(platzhalter[i], d, t)
- // Task 26: Hinweis-Leiste, Kontakt-Leiste und JSON-LD werden hier mit denselben Daten (d) gezeichnet
+ for (var i = 0; i < platzhalter.length; i++) fuelleSicher(platzhalter[i], d, t)
+ zeichneLeisten(d, t)
+ sicher(function () { zeichneJsonLd(d) })
+ }
+
+ // ---------------------------------------------------------------- Leisten (Shadow DOM, vom Skript gezeichnet)
+
+ var LEISTE_SCHRIFT = 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'
+
+ function hinweisCss () {
+ var teile = [
+ ':host{all:initial;display:block}',
+ '.wkz-leiste{box-sizing:border-box;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:4px 16px;margin:0;padding:8px 16px;font:500 15px/1.4 ' + LEISTE_SCHRIFT + ';text-align:center;background:var(--bg);color:var(--fg);animation:wkz-ein .3s ease-out}',
+ '.wkz-text{margin:0;min-width:0;overflow-wrap:anywhere}',
+ '.wkz-knopf{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 18px;border-radius:999px;background:var(--fg);color:var(--bg);font:inherit;font-weight:700;text-decoration:none}',
+ '.wkz-knopf:focus-visible{outline:3px solid var(--fg);outline-offset:3px}',
+ '@keyframes wkz-ein{from{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:none}}',
+ '@media (prefers-reduced-motion:reduce){.wkz-leiste{animation:none}}'
+ ]
+ for (var art in HINWEIS_FARBEN) {
+ if (Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, art)) teile.push('.wkz-' + art + '{--bg:' + HINWEIS_FARBEN[art].bg + ';--fg:' + HINWEIS_FARBEN[art].fg + '}')
+ }
+ return teile.join('\n')
+ }
+
+ function kontaktCss () {
+ return [
+ ':host{all:initial;display:block}',
+ '.wkz-leiste{box-sizing:border-box;display:flex;gap:8px;margin:0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));background:#fff;border-top:1px solid #d1d5db;box-shadow:0 -2px 10px rgba(0,0,0,.15);font:600 16px/1.2 ' + LEISTE_SCHRIFT + '}',
+ '.wkz-knopf{box-sizing:border-box;flex:1 1 0;min-width:44px;min-height:48px;display:flex;align-items:center;justify-content:center;padding:0 8px;border:2px solid #111827;border-radius:10px;background:#fff;color:#111827;font:inherit;text-decoration:none;text-align:center}',
+ '.wkz-anruf{background:#111827;color:#fff}',
+ '.wkz-knopf:focus-visible{outline:3px solid #2563eb;outline-offset:2px}'
+ ].join('\n')
+ }
+
+ // Host mit Shadow DOM und eigenem Stil (null, wo der Browser kein Shadow DOM kann: dann gibt es keine Leiste)
+ function neueLeiste (art, css) {
+ var host = document.createElement('div')
+ if (typeof host.attachShadow !== 'function') return null
+ host.setAttribute('data-wkz-leiste', art)
+ var wurzel = host.attachShadow({ mode: 'open' })
+ var st = document.createElement('style')
+ st.textContent = css
+ wurzel.appendChild(st)
+ return { host: host, wurzel: wurzel }
+ }
+
+ // Link aus einem { href, text } der reinen Funktionen (href ist dort schon geprueft); nie ueber HTML-Text
+ function linkKnopf (k, klasse, neuerTab) {
+ var a = el('a', klasse, k.text)
+ a.setAttribute('href', k.href)
+ a.setAttribute('rel', 'noopener noreferrer')
+ if (neuerTab) a.setAttribute('target', '_blank')
+ return a
+ }
+
+ function naechstesBild (fn) {
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
+ else fn()
+ }
+
+ function pixel (wert) { return /^-?\d+(\.\d+)?px$/.test(wert) ? parseFloat(wert) : 0 }
+
+ // scroll-padding-top/-bottom des Dokuments um hoehePx erhoehen (der Wert der Seite wird gemerkt und beim Entfernen zurueckgesetzt)
+ function erhoeheScrollPadding (seite, hoehePx) {
+ var prop = SCROLL_PADDING[seite]
+ var st = document.documentElement.style
+ if (!scrollPadding[seite]) {
+ scrollPadding[seite] = { wert: st.getPropertyValue(prop), prio: st.getPropertyPriority(prop), basis: pixel(getComputedStyle(document.documentElement).getPropertyValue(prop)) }
+ }
+ st.setProperty(prop, (scrollPadding[seite].basis + hoehePx) + 'px', 'important')
+ }
+
+ function stelleScrollPaddingWieder (seite) {
+ var merk = scrollPadding[seite]
+ if (!merk) return
+ var prop = SCROLL_PADDING[seite]
+ var st = document.documentElement.style
+ if (merk.wert) st.setProperty(prop, merk.wert, merk.prio)
+ else st.removeProperty(prop)
+ scrollPadding[seite] = null
+ }
+
+ function istFest (cs) { return cs.position === 'fixed' || cs.position === 'sticky' }
+
+ function merke (e, cs) {
+ var top = parseFloat(cs.top)
+ return { el: e, wert: e.style.getPropertyValue('top'), prio: e.style.getPropertyPriority('top'), basis: top === top ? top : 0 }
+ }
+
+ // Feste und klebende Elemente, die die Leiste verdecken wuerde: die des Team-Selektors nav (jedes feste/klebende, auch mit anderem top) und
+ // alle mit top: 0, sichtbar und hoechstens halb so hoch wie das Fenster (kein Menue-Panel, kein Overlay). Ein festes Element, das schon
+ // ueber der Leiste endet (die Seite hat ihren Kopf per Polster oder Rand von body freigehalten, die Leiste steht erst darunter), bleibt,
+ // wo es ist: barOben ist der Abstand der Leiste vom Seitenanfang. nav kann ungueltig sein: nur in try/catch.
+ function sucheFeste (nav, barOben) {
+ var liste = []
+ var bekannt = []
+ var i, e, cs
+ if (nav) {
+ var treffer = []
+ try { treffer = document.querySelectorAll(nav) } catch (fehler) { treffer = [] }
+ for (i = 0; i < treffer.length; i++) {
+ cs = getComputedStyle(treffer[i])
+ if (!istFest(cs)) continue
+ liste.push(merke(treffer[i], cs))
+ bekannt.push(treffer[i])
+ }
+ }
+ var alle = document.body.getElementsByTagName('*')
+ var hoeheFenster = window.innerHeight || 0
+ for (i = 0; i < alle.length && i < 4000; i++) {
+ e = alle[i]
+ if (bekannt.indexOf(e) >= 0 || e.hasAttribute('data-wkz-leiste')) continue
+ cs = getComputedStyle(e)
+ if (!istFest(cs) || parseFloat(cs.top) !== 0 || cs.display === 'none') continue
+ var r = e.getBoundingClientRect()
+ if (r.height <= 0 || r.height > hoeheFenster * 0.5) continue
+ if (cs.position === 'fixed' && r.bottom <= barOben + 0.5) continue
+ liste.push(merke(e, cs))
+ }
+ return liste
+ }
+
+ // Feste Elemente um max(0, Leistenhoehe - Scrollposition) nach unten ruecken (Inline-top, hat Vorrang vor den Regeln der Seite)
+ function verschiebe (l) {
+ var y = window.pageYOffset || document.documentElement.scrollTop || 0
+ var s = Math.max(0, l.hoehe - Math.max(0, y))
+ for (var i = 0; i < l.fest.length; i++) l.fest[i].el.style.setProperty('top', (l.fest[i].basis + s) + 'px', 'important')
+ }
+
+ function setzeFesteZurueck (l) {
+ for (var i = 0; i < l.fest.length; i++) {
+ var f = l.fest[i]
+ if (f.wert) f.el.style.setProperty('top', f.wert, f.prio)
+ else f.el.style.removeProperty('top')
+ }
+ l.fest = []
+ }
+
+ // Hoehe der Hinweis-Leiste neu messen, feste Elemente neu suchen (erst die alten zuruecksetzen: berechnet wird immer der Stand der Seite) und ruecken
+ function misstHinweis () {
+ var l = hinweisLeiste
+ if (!l) return
+ setzeFesteZurueck(l)
+ var r = l.host.getBoundingClientRect()
+ l.hoehe = r.height
+ l.fest = sucheFeste(l.nav, r.top + (window.pageYOffset || document.documentElement.scrollTop || 0))
+ erhoeheScrollPadding('top', l.hoehe)
+ verschiebe(l)
+ }
+
+ function misstKontakt () {
+ var l = kontaktLeiste
+ if (!l) return
+ var h = l.host.getBoundingClientRect().height
+ l.abstand.style.cssText = 'display:block;width:100%;height:' + h + 'px;margin:0;padding:0;border:0;visibility:hidden'
+ erhoeheScrollPadding('bottom', h)
+ }
+
+ function beiScroll () {
+ if (!hinweisLeiste || scrollWartet) return
+ scrollWartet = true
+ naechstesBild(function () {
+ scrollWartet = false
+ if (hinweisLeiste) verschiebe(hinweisLeiste)
+ })
+ }
+
+ // Fenster oder Leiste aendern ihre Groesse: hoechstens alle 120 ms neu messen und suchen (beim Ziehen am Fenster nicht in jedem Bild)
+ function beiGroesse () {
+ if (groesseWartet) return
+ groesseWartet = true
+ timer(function () {
+ groesseWartet = false
+ sicher(misstHinweis)
+ sicher(misstKontakt)
+ }, 120)
+ }
+
+ // Scroll (nur gemerkt, ein Bild spaeter gerueckt) und Groesse (Fenster oder Leiste) werden einmal beobachtet, sobald es eine Leiste gibt
+ function sorgeFuerHoerer () {
+ if (hoererAn) return
+ hoererAn = true
+ window.addEventListener('scroll', beiScroll, { passive: true })
+ window.addEventListener('resize', beiGroesse)
+ if (typeof ResizeObserver === 'function') beobachter = new ResizeObserver(beiGroesse)
+ }
+
+ function entferneLeiste (host) {
+ if (beobachter) beobachter.unobserve(host)
+ if (host.parentNode) host.parentNode.removeChild(host)
+ }
+
+ function zeigeHinweisLeiste (h, nav) {
+ var art = Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, h.art) ? h.art : 'info'
+ var knopf = hinweisKnopf(h.knopf)
+ var sig = JSON.stringify([art, h.text, knopf, nav])
+ var l = hinweisLeiste
+ var body = document.body
+ if (l && l.sig === sig && l.host.parentNode === body) {
+ // unveraendert: nur dafuer sorgen, dass sie erstes Kind von body bleibt (die Seite kann danach etwas davorgesetzt haben)
+ if (body.firstChild !== l.host) {
+ body.insertBefore(l.host, body.firstChild)
+ misstHinweis()
+ }
+ return
+ }
+ entferneHinweisLeiste()
+ var teile = neueLeiste('hinweis', hinweisCss())
+ if (!teile) return
+ var leiste = el('div', 'wkz-leiste wkz-' + art)
+ leiste.setAttribute('role', 'region')
+ leiste.setAttribute('aria-label', 'Hinweis')
+ leiste.appendChild(el('p', 'wkz-text', h.text))
+ if (knopf) leiste.appendChild(linkKnopf(knopf, 'wkz-knopf', false))
+ teile.wurzel.appendChild(leiste)
+ body.insertBefore(teile.host, body.firstChild)
+ hinweisLeiste = { host: teile.host, sig: sig, nav: nav, hoehe: 0, fest: [] }
+ sorgeFuerHoerer()
+ if (beobachter) beobachter.observe(teile.host)
+ misstHinweis()
+ }
+
+ function entferneHinweisLeiste () {
+ var l = hinweisLeiste
+ if (!l) return
+ hinweisLeiste = null
+ setzeFesteZurueck(l)
+ stelleScrollPaddingWieder('top')
+ entferneLeiste(l.host)
+ }
+
+ function zeigeKontaktLeiste (knoepfe) {
+ var sig = JSON.stringify(knoepfe)
+ var l = kontaktLeiste
+ var body = document.body
+ if (l && l.sig === sig && l.host.parentNode === body && l.abstand.parentNode === body) {
+ if (l.abstand.nextSibling) body.appendChild(l.abstand) // der Abstandhalter bleibt das Letzte in body
+ return
+ }
+ entferneKontaktLeiste()
+ var teile = neueLeiste('kontakt', kontaktCss())
+ if (!teile) return
+ var leiste = el('div', 'wkz-leiste')
+ leiste.setAttribute('role', 'region')
+ leiste.setAttribute('aria-label', 'Kontakt')
+ for (var i = 0; i < knoepfe.length; i++) leiste.appendChild(linkKnopf(knoepfe[i], 'wkz-knopf wkz-' + knoepfe[i].art, knoepfe[i].art !== 'anruf'))
+ teile.wurzel.appendChild(leiste)
+ teile.host.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:40;display:block;margin:0;padding:0;border:0'
+ var abstand = document.createElement('div')
+ abstand.setAttribute('data-wkz-abstand', '')
+ abstand.setAttribute('aria-hidden', 'true')
+ body.appendChild(teile.host)
+ body.appendChild(abstand)
+ kontaktLeiste = { host: teile.host, abstand: abstand, sig: sig }
+ sorgeFuerHoerer()
+ if (beobachter) beobachter.observe(teile.host)
+ misstKontakt()
+ }
+
+ function entferneKontaktLeiste () {
+ var l = kontaktLeiste
+ if (!l) return
+ kontaktLeiste = null
+ stelleScrollPaddingWieder('bottom')
+ entferneLeiste(l.host)
+ if (l.abstand.parentNode) l.abstand.parentNode.removeChild(l.abstand)
+ }
+
+ function beiHandyWechsel () { sicher(render) }
+
+ function istHandy () {
+ if (typeof window.matchMedia !== 'function') return false
+ if (!handyAbfrage) {
+ handyAbfrage = window.matchMedia(HANDY_MQ)
+ if (typeof handyAbfrage.addEventListener === 'function') handyAbfrage.addEventListener('change', beiHandyWechsel)
+ else if (typeof handyAbfrage.addListener === 'function') handyAbfrage.addListener(beiHandyWechsel)
+ }
+ return handyAbfrage.matches
+ }
+
+ // Die festen/klebenden Vorfahren aller sichtbaren tel:-Links, als Beschreibung fuer istEigeneAnrufLeiste
+ function eigeneAnrufLeiste () {
+ var links = document.querySelectorAll('a[href^="tel:"]')
+ var elemente = []
+ var fenster = window.innerHeight || 0
+ for (var i = 0; i < links.length && i < 50; i++) {
+ if (!links[i].getClientRects().length) continue // ausgeblendet
+ for (var n = links[i]; n && n !== document.documentElement; n = n.parentElement) {
+ var cs = getComputedStyle(n)
+ if (cs.position !== 'fixed' && cs.position !== 'sticky') continue
+ var r = n.getBoundingClientRect()
+ elemente.push({ position: cs.position, bottom: cs.bottom, oben: r.top, unten: r.bottom, fenster: fenster, sichtbar: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0 })
+ break
+ }
+ }
+ return istEigeneAnrufLeiste(elemente)
+ }
+
+ // Beide Leisten aus denselben Daten (d = null: Fehler, aktiv: false oder Daten zu alt: keine Leiste). Hinweis: der gueltige mit dem hoechsten
+ // Rang, nicht bei team.hinweisleisteAus. Kontakt: nur mit Daten, auf schmalen Schirmen, nicht bei team.kontaktleisteAus und nicht neben einer
+ // eigenen Anruf-Leiste der Seite.
+ function zeichneLeisten (d, t) {
+ var team = d && istObjekt(d.team) ? d.team : {}
+ sicher(function () {
+ var h = d && team.hinweisleisteAus !== true && document.body ? waehleHinweis(d.hinweise, t) : null
+ if (h && typeof h.text === 'string' && h.text !== '') zeigeHinweisLeiste(h, typeof team.navSelektor === 'string' ? team.navSelektor : '')
+ else entferneHinweisLeiste()
+ })
+ sicher(function () {
+ var knoepfe = d && team.kontaktleisteAus !== true && document.body ? kontaktKnoepfe(d.kontakt) : []
+ if (knoepfe.length && istHandy() && !eigeneAnrufLeiste()) zeigeKontaktLeiste(knoepfe)
+ else entferneKontaktLeiste()
+ })
+ }
+
+ // ---------------------------------------------------------------- JSON-LD
+
+ function ldSkripte () {
+ var alle = document.getElementsByTagName('script')
+ var liste = []
+ for (var i = 0; i < alle.length; i++) {
+ if (alle[i] !== ldEigenes && String(alle[i].getAttribute('type') || '').toLowerCase().replace(/\s+/g, '') === 'application/ld+json') liste.push(alle[i])
+ }
+ return liste
+ }
+
+ function seitenUrl () {
+ var o = window.location && window.location.origin
+ return typeof o === 'string' && o !== '' && o !== 'null' ? o + '/' : ''
+ }
+
+ // Zeiten ins JSON-LD der Seite: vorhandene Unternehmens-Bloecke bekommen nur die beiden Zeiten-Felder neu (Text per JSON.stringify), sonst legt das
+ // Skript einen eigenen Block an. Ohne jsonld in der Antwort (noch keine Zeiten gespeichert) bleibt die Seite voellig unberuehrt.
+ function zeichneJsonLd (d) {
+ var jd = d && istObjekt(d.jsonld) ? d.jsonld : null
+ if (!jd) return
+ var firma = istObjekt(d.firma) ? d.firma : {}
+ var seite = { name: firma.name, adresse: firma.adresse, url: seitenUrl() }
+ var sig = JSON.stringify([jd, seite])
+ if (sig === ldSig) return
+ var skripte = ldSkripte()
+ var bloecke = []
+ var i
+ for (i = 0; i < skripte.length; i++) {
+ try { bloecke.push(JSON.parse(skripte[i].textContent)) } catch (e) { bloecke.push(null) }
+ }
+ var erg = jsonLdEinbauen(bloecke, jd, seite)
+ for (i = 0; i < erg.geaendert.length; i++) skripte[erg.geaendert[i]].textContent = JSON.stringify(bloecke[erg.geaendert[i]])
+ if (erg.neu) {
+ if (!ldEigenes) {
+ ldEigenes = document.createElement('script')
+ ldEigenes.setAttribute('type', 'application/ld+json')
+ ldEigenes.setAttribute('data-wkz-jsonld', '')
+ ;(document.head || document.documentElement).appendChild(ldEigenes)
+ }
+ ldEigenes.textContent = JSON.stringify(erg.neu)
+ } else if (ldEigenes) {
+ if (ldEigenes.parentNode) ldEigenes.parentNode.removeChild(ldEigenes)
+ ldEigenes = null
+ }
+ ldSig = sig
}
// ---------------------------------------------------------------- Timer
@@ -477,8 +1023,7 @@
lade() // zeichnet und plant danach neu
return
}
- render()
- plane()
+ try { render() } finally { plane() }
}
// ---------------------------------------------------------------- Start
@@ -514,7 +1059,13 @@
return
}
document.addEventListener('visibilitychange', function () {
- if (document.visibilityState === 'visible') tick()
+ if (document.visibilityState !== 'visible') return
+ // Zeit ist vergangen: ist ein Neuladen faellig, erst mit den alten Daten zeichnen (Hinweis, Status und die 24-Stunden-Grenze stimmen sofort), dann laden
+ if (!laeuft && Date.now() - versuchMs >= RELOAD_MS) {
+ try { render() } finally { lade() }
+ return
+ }
+ tick()
})
lade()
}
diff --git a/test/ui/zeiten-embed.ui.mjs b/test/ui/zeiten-embed.ui.mjs
new file mode 100644
index 0000000..c327b42
--- /dev/null
+++ b/test/ui/zeiten-embed.ui.mjs
@@ -0,0 +1,532 @@
+// Playwright-Checks des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 26: Hinweis-Leiste, Kontakt-Leiste,
+// JSON-LD, Platzhalter) gegen den UI-Testserver (test/ui/zeitenTestServer.mjs): echte oeffentliche Schnittstelle, Nachbau von Appwrite.
+// Nicht Teil von npm test.
+// Aufruf: free -m && nice node test/ui/zeiten-embed.ui.mjs (es laeuft nur ein Browser)
+// Screenshots: /root/reports/oeffnungszeiten-ui/embed-*.png (bei Fehlern fehler-embed-.png). Exit-Code 1, wenn eine Pruefung scheitert.
+//
+// Seiten: Sie laufen auf srv.pageOrigin (http://localhost:PORT) und sprechen die Schnittstelle unter srv.url (http://127.0.0.1:PORT)
+// an: echte Cross-Origin-Anfragen wie auf Kundenseiten. Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (fest, srv.setzeUhr);
+// Pruefungen mit Zeitsprung installieren dieselbe Zeit im Browser (context.clock.install) und spulen mit fastForward vor.
+import { mkdir, readdir, rm } from 'node:fs/promises'
+import path from 'node:path'
+import { startZeitenUiServer, zeitenEingabe, hinweis, FIRMA, ADRESSE } from './zeitenTestServer.mjs'
+
+const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
+const { chromium } = await import(PW)
+
+const SHOTS = '/root/reports/oeffnungszeiten-ui'
+await mkdir(SHOTS, { recursive: true })
+for (const datei of await readdir(SHOTS)) if (/^(embed|fehler-embed)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
+
+const srv = await startZeitenUiServer()
+const KEY = srv.SITE_KEY
+const API = `${srv.url}/api/public/zeiten/v1/${KEY}`
+
+// ---------------------------------------------------------------- Helfer
+
+function assert(cond, msg) { if (!cond) throw new Error(msg) }
+function eq(ist, soll, msg) {
+ const a = JSON.stringify(ist)
+ const b = JSON.stringify(soll)
+ if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
+}
+const nahe = (a, b, tol = 1) => Math.abs(a - b) <= tol
+const fmt = (z) => (typeof z === 'number' ? z.toFixed(1) : String(z))
+
+const SKRIPT = ``
+const STATUS = '
Status: Ersatz-Status
'
+const lang = (n = 40) => Array.from({ length: n }, (_, i) => `
Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.
`).join('\n')
+
+/** Testseite: kopf = -Zusatz, stil = CSS, anfang = Anfang von (z. B. Kopfzeile), inhalt = . */
+function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}
Ende der Seite
`, skript = SKRIPT } = {}) {
+ return `
+Bäckerei Sonnenschein
+${kopf}
+${anfang}${inhalt}${skript}`
+}
+
+const KOPF_FEST = 'Bäckerei Sonnenschein'
+const KOPF_KLEBT = 'Bäckerei Sonnenschein'
+
+const HOST = (art) => `[data-wkz-leiste="${art}"]`
+const anzahl = (page, sel) => page.locator(sel).count()
+const hoehe = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().height, sel)
+const oben = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().top, sel)
+const scrolle = (page, y) => page.evaluate((v) => window.scrollTo(0, v), y)
+const shot = async (page, name) => {
+ await page.waitForTimeout(450) // die Einblendung der Leiste (0,3 s) ist fertig
+ await page.screenshot({ path: `${SHOTS}/embed-${name}.png` })
+}
+
+/** Wartet, bis das Skript die Platzhalter gefuellt hat (und damit in derselben Runde die Leisten gezeichnet). */
+const warteBereit = (page) => page.waitForFunction(() => { const s = document.getElementById('status'); return s && !s.textContent.includes('Ersatz') })
+
+/** Wartet auf die Antwort der Schnittstelle und ein paar Bilder danach (fuer Seiten, auf denen nichts gefuellt wird). */
+async function warteAufSchnittstelle(page, aktion) {
+ const antwort = page.waitForResponse((r) => r.url().includes('/api/public/zeiten/v1/'))
+ await aktion()
+ await antwort
+ await page.waitForTimeout(400)
+}
+
+const STATUS_OFFEN = 'Jetzt geöffnet · schließt um 18 Uhr'
+const STATUS_ZU = 'Geschlossen · öffnet morgen um 9 Uhr'
+const statusText = (page) => page.locator('#status').innerText()
+
+const results = []
+let browser = null
+const hosts = new Set()
+
+async function check(name, fn, { eingabe, team, viewport = { width: 1280, height: 800 }, uhr, context = {}, initScript } = {}) {
+ await srv.zuruecksetzen({ eingabe, team })
+ if (uhr) srv.setzeUhr(uhr)
+ const ctx = await browser.newContext({ viewport, ...context })
+ if (uhr) await ctx.clock.install({ time: new Date(uhr) })
+ const page = await ctx.newPage()
+ page.setDefaultTimeout(8000)
+ const fehler = []
+ page.on('pageerror', (e) => fehler.push(e.message))
+ page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
+ if (initScript) await page.addInitScript(initScript)
+ try {
+ await fn(page, ctx)
+ assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`)
+ results.push([name, true])
+ console.log(`ok ${name}`)
+ } catch (e) {
+ results.push([name, false])
+ console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
+ await page.screenshot({ path: `${SHOTS}/fehler-embed-${name}.png`, fullPage: true }).catch(() => {})
+ } finally {
+ await ctx.close()
+ }
+}
+
+const INFO = hinweis('info', 'Heute frische Brezeln aus dem Ofen.')
+const HANDY = { width: 390, height: 844 }
+
+// ---------------------------------------------------------------- Pruefungen
+
+async function pruefungen() {
+ await check('hinweisleiste_zeigt_gueltigen_hinweis', async (page) => {
+ await page.goto(srv.seite('hinweis.html', seitenHtml()))
+ await warteBereit(page)
+ const host = page.locator(HOST('hinweis'))
+ await host.waitFor({ state: 'attached' })
+ const region = page.getByRole('region', { name: 'Hinweis' })
+ assert(await region.isVisible(), 'Region "Hinweis" nicht sichtbar')
+ assert((await region.innerText()).includes('Heute frische Brezeln aus dem Ofen.'), 'Text fehlt')
+ eq(await region.locator('button, [role=button]').count(), 0, 'Schliessen-Knopf vorhanden')
+ assert(!(await region.innerText()).includes('Laden schließt'), 'der Hinweis von morgen gilt noch nicht')
+ const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
+ eq(farben, ['rgb(224, 242, 254)', 'rgb(12, 74, 110)'], 'Farben der Art Info')
+ // erstes Kind von body, im Fluss (schiebt den Inhalt)
+ assert(await page.evaluate(() => document.body.firstElementChild.hasAttribute('data-wkz-leiste')), 'Host ist nicht das erste Kind von body')
+ assert(await page.evaluate(() => ['static', 'relative'].includes(getComputedStyle(document.querySelector('[data-wkz-leiste="hinweis"]')).position)), 'Host muss im Fluss stehen')
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(h > 20, `Hoehe der Leiste ${fmt(h)}`)
+ assert(nahe(await oben(page, 'main'), h, 1.5), `main steht bei ${fmt(await oben(page, 'main'))} statt unter der Leiste (${fmt(h)})`)
+ await shot(page, 'hinweis-desktop')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Nur heute: Laden schließt um 14 Uhr.', { von: '2026-10-06', bis: '2026-10-06' })] }) })
+
+ await check('hinweis_wichtig_mit_knopf', async (page) => {
+ await page.goto(srv.seite('hinweis-wichtig.html', seitenHtml()))
+ await warteBereit(page)
+ const region = page.getByRole('region', { name: 'Hinweis' })
+ await region.waitFor()
+ assert((await region.innerText()).includes('Heute Betriebsfeier'), 'der Hinweis mit dem hoechsten Rang gewinnt')
+ const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
+ eq(farben, ['rgb(185, 28, 28)', 'rgb(255, 255, 255)'], 'Farben der Art Wichtig')
+ const knopf = region.getByRole('link', { name: 'Zur Aktion' })
+ eq(await knopf.getAttribute('href'), 'https://baeckerei.example/aktion?x=1', 'href')
+ eq(await knopf.getAttribute('rel'), 'noopener noreferrer', 'rel')
+ const box = await knopf.boundingBox()
+ assert(box.height >= 44 && box.width >= 44, `Tippflaeche ${box.width}x${box.height}`)
+ // sichtbarer Fokus
+ await knopf.focus()
+ const fokus = await knopf.evaluate((e) => getComputedStyle(e).outlineStyle)
+ assert(fokus !== 'none', 'kein sichtbarer Fokus')
+ await shot(page, 'hinweis-wichtig')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Heute Betriebsfeier: wir schließen um 14 Uhr.', { knopf: { art: 'link', ziel: 'https://baeckerei.example/aktion?x=1', text: 'Zur Aktion' } })] }) })
+
+ await check('hinweisleiste_aus', async (page) => {
+ await page.goto(srv.seite('hinweis-aus.html', seitenHtml({ anfang: KOPF_FEST })))
+ await warteBereit(page) // das Skript hat gelaufen und die Platzhalter gefuellt ...
+ eq(await anzahl(page, HOST('hinweis')), 0, 'Leiste trotz Team-Schalter')
+ // ... und nichts an der Seite veraendert
+ eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Kopf angefasst')
+ eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top angefasst')
+ assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nicht bei 0')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { hinweisleisteAus: true } })
+
+ await check('fester_header_rueckt', async (page) => {
+ await page.goto(srv.seite('fest.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `${STATUS}${lang()}` })))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
+ eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), 'important', 'Prioritaet des Inline-top')
+ // scroll-padding-top um die Hoehe erhoeht
+ const sp = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
+ assert(nahe(sp, h, 1), `scroll-padding-top ${fmt(sp)} statt ${fmt(h)}`)
+ // halb gescrollt: der Kopf folgt dem unteren Rand der Leiste
+ const halb = Math.round(h / 2)
+ await scrolle(page, halb)
+ await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
+ // weiter gescrollt: wieder bei 0
+ await scrolle(page, 400)
+ await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
+ // zurueck nach oben: wieder unter der Leiste
+ await scrolle(page, 0)
+ await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
+ await shot(page, 'fester-header')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
+
+ await check('fester_header_mit_polster_bleibt', async (page) => {
+ // Die Seite haelt Platz fuer ihren festen Kopf per body-Polster frei: die Leiste steht erst darunter, der Kopf muss bleiben, wo er ist
+ await page.goto(srv.seite('polster.html', seitenHtml({ anfang: KOPF_FEST, stil: 'body{padding-top:60px}' })))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
+ assert(nahe(await oben(page, HOST('hinweis')), 60, 1), `Leiste steht bei ${fmt(await oben(page, HOST('hinweis')))} statt unter dem Polster`)
+ assert(nahe(await oben(page, '#kopf'), 0, 0.5), `der Kopf wurde gerueckt (${fmt(await oben(page, '#kopf'))})`)
+ eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Inline-top des Kopfes angefasst')
+ await shot(page, 'fester-header-polster')
+ await scrolle(page, 200)
+ await page.waitForTimeout(100)
+ assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nach dem Scrollen nicht bei 0')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
+
+ await check('klebender_header', async (page) => {
+ await page.goto(srv.seite('klebt.html', seitenHtml({ anfang: KOPF_KLEBT })))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(nahe(await oben(page, '#kopf'), h, 1), `klebender Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
+ const halb = Math.round(h / 2)
+ await scrolle(page, halb)
+ await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
+ await scrolle(page, 500)
+ await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
+ await scrolle(page, 0)
+ await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
+
+ await check('team_navselektor', async (page) => {
+ // der Schwebende hat top: 10px (nicht top: 0): nur der Selektor des Teams findet ihn
+ const seite = seitenHtml({
+ stil: '.schweber{position:fixed;top:10px;left:16px;z-index:100;background:#222;color:#fff;padding:8px 12px;border-radius:8px}',
+ anfang: '',
+ })
+ await page.goto(srv.seite('nav.html', seite))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(nahe(await oben(page, '.schweber'), 10 + h, 1), `Schweber steht bei ${fmt(await oben(page, '.schweber'))} statt ${fmt(10 + h)}`)
+ await scrolle(page, 400)
+ await page.waitForFunction(() => Math.abs(document.querySelector('.schweber').getBoundingClientRect().top - 10) < 0.5)
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '.schweber' } })
+
+ await check('ungueltiger_selektor', async (page) => {
+ await page.goto(srv.seite('selektor.html', seitenHtml({ anfang: KOPF_FEST })))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leiste ist trotzdem da
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(nahe(await oben(page, '#kopf'), h, 1), 'der feste Kopf wird auch ohne gueltigen Selektor erkannt und gerueckt')
+ }, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '[[kaputt' } })
+
+ await check('hinweis_endet_und_stellt_wieder_her', async (page, ctx) => {
+ await page.goto(srv.seite('ende.html', seitenHtml({ anfang: KOPF_FEST })))
+ await warteBereit(page)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
+ const h = await hoehe(page, HOST('hinweis'))
+ assert(nahe(await oben(page, '#kopf'), h, 1), 'Kopf unter der Leiste')
+ // 23:55 Berlin: der Hinweis gilt bis 24:00 und verschwindet dann von selbst
+ await ctx.clock.fastForward(6 * 60 * 1000)
+ await page.locator(HOST('hinweis')).waitFor({ state: 'detached' })
+ eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Inline-top des Kopfes nicht zurueckgesetzt')
+ eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Prioritaet nicht zurueckgesetzt')
+ eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top nicht zurueckgesetzt')
+ assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf wieder bei 0')
+ assert(nahe(await oben(page, 'main'), 0, 0.5), 'Inhalt rueckt wieder nach oben')
+ }, { eingabe: zeitenEingabe({ hinweise: [hinweis('info', 'Nur heute: Kuchen zum halben Preis.', { von: '2026-10-05', bis: '2026-10-05' })] }), uhr: '2026-10-05T21:55:00.000Z' })
+
+ await check('kontaktleiste_nur_handy', async (page) => {
+ await page.goto(srv.seite('handy.html', seitenHtml()))
+ await warteBereit(page)
+ const host = page.locator(HOST('kontakt'))
+ await host.waitFor({ state: 'attached' })
+ const stil = await host.evaluate((e) => { const c = getComputedStyle(e); return [c.position, c.zIndex, c.bottom] })
+ eq(stil, ['fixed', '40', '0px'], 'position, z-index, bottom der Kontakt-Leiste')
+ const knoepfe = host.locator('a')
+ eq(await knoepfe.count(), 3, 'Anzahl Knoepfe')
+ eq(await knoepfe.allInnerTexts(), ['Anrufen', 'Route', 'WhatsApp'], 'Beschriftungen')
+ const hrefs = await knoepfe.evaluateAll((l) => l.map((a) => a.getAttribute('href')))
+ eq(hrefs[0], 'tel:+491711234567', 'tel:')
+ assert(hrefs[1].startsWith('https://www.google.com/maps/dir/'), `Route: ${hrefs[1]}`)
+ assert(hrefs[2].startsWith('https://wa.me/491711234567'), `WhatsApp: ${hrefs[2]}`)
+ for (const a of await knoepfe.elementHandles()) {
+ const b = await a.boundingBox()
+ assert(b.height >= 44 && b.width >= 44, `Tippflaeche ${b.width}x${b.height}`)
+ eq(await a.getAttribute('rel'), 'noopener noreferrer', 'rel')
+ }
+ // Abstandhalter am Ende von body in Hoehe der Leiste, scroll-padding-bottom
+ const h = await hoehe(page, HOST('kontakt'))
+ eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter')
+ assert(await page.evaluate(() => document.body.lastElementChild.hasAttribute('data-wkz-abstand')), 'Abstandhalter nicht am Ende von body')
+ assert(nahe(await hoehe(page, '[data-wkz-abstand]'), h, 1), 'Abstandhalter nicht so hoch wie die Leiste')
+ assert(nahe(await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom) || 0), h, 1), 'scroll-padding-bottom')
+ // ganz unten: der letzte Absatz liegt ueber der Leiste
+ await scrolle(page, 1e6)
+ await page.waitForFunction(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)
+ const unten = await page.evaluate(() => document.getElementById('ende').getBoundingClientRect().bottom)
+ const leisteOben = await oben(page, HOST('kontakt'))
+ assert(unten <= leisteOben + 0.5, `der letzte Absatz endet bei ${fmt(unten)}, die Leiste beginnt bei ${fmt(leisteOben)}`)
+ await shot(page, 'kontakt-handy')
+ // auf breitem Schirm weg (und wieder da: die Abfrage wird beobachtet)
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await host.waitFor({ state: 'detached' })
+ eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter blieb stehen')
+ eq(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom), '', 'scroll-padding-bottom nicht zurueckgesetzt')
+ await page.setViewportSize(HANDY)
+ await host.waitFor({ state: 'attached' })
+ eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter nach der Rueckkehr')
+ }, { viewport: HANDY })
+
+ await check('kontaktleiste_nicht_auf_dem_desktop', async (page) => {
+ await page.goto(srv.seite('desktop.html', seitenHtml()))
+ await warteBereit(page)
+ eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste auf dem Desktop')
+ eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter auf dem Desktop')
+ })
+
+ await check('kontaktleiste_aus_per_team', async (page) => {
+ await page.goto(srv.seite('kontakt-aus.html', seitenHtml()))
+ await warteBereit(page)
+ eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste trotz Team-Schalter')
+ eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter trotz Team-Schalter')
+ }, { viewport: HANDY, team: { kontaktleisteAus: true } })
+
+ await check('kontaktleiste_nur_mit_daten', async (page) => {
+ await page.goto(srv.seite('kontakt-nur-route.html', seitenHtml()))
+ await warteBereit(page)
+ const host = page.locator(HOST('kontakt'))
+ await host.waitFor({ state: 'attached' })
+ eq(await host.locator('a').allInnerTexts(), ['Route'], 'nur Route (kein Telefon, kein WhatsApp)')
+ }, { viewport: HANDY, eingabe: zeitenEingabe({ kontakt: { an: true, telefon: '', adresse: ADRESSE } }) })
+
+ await check('kontaktleiste_ohne_daten', async (page) => {
+ srv.ueberschreibe((a) => ({ ...a, kontakt: null })) // der Browser hat die Antwort eines frueheren Abrufs sonst 60 Sekunden im Zwischenspeicher
+ await page.goto(srv.seite('kontakt-leer.html', seitenHtml()))
+ await warteBereit(page)
+ eq(await anzahl(page, HOST('kontakt')), 0, 'Leiste ohne Daten')
+ eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter ohne Daten')
+ }, { viewport: HANDY })
+
+ await check('eigene_anrufleiste', async (page) => {
+ const eigene = '