Zum Inhalt
← Sektionen & Funktionen

Bewertungen mit Sternverteilung

Durchschnitt und Sternverteilung, deren Balken die Liste filtern, mit Reiseart, Sortierung, Antwort des Hauses und sichtbarem Echtheitshinweis.

Hotel, Praxis, Handwerk: Bewertungen sind eine eigene Sektion, die Zusammenfassung bleibt beim Lesen sichtbar.

Eine Bewertungs-Sektion für die eigene Website. Oben steht der Durchschnitt mit der Verteilung als waagerechte Balken von 5 bis 1 Stern. Jeder Balken ist ein Filter: Ein Klick zeigt nur diese Bewertungen, ein zweiter Klick hebt den Filter wieder auf. Dazu kommen eine Sortierung, die Trefferzahl und eine Zeile mit den aktiven Filtern. Unter einzelnen Bewertungen steht die Antwort des Hauses. Direkt bei den Bewertungen steht, ob und wie geprüft wird, dass sie von echten Gästen oder Kunden stammen.

Die Verteilung gibt Kritik ihren Platz: Zwei schlechte Bewertungen unter vielen guten wirken ehrlich statt bedrohlich. Wer gezielt nach Schwächen sucht, findet sie mit einem Klick. Antworten des Hauses zeigen, dass jemand zuhört.

Wann einsetzen

Hotel, Ferienwohnung, Restaurant, Praxis, Handwerk, Produktseiten in Shops: überall, wo eigene Bewertungen auf der Website stehen sollen.

Wann nicht

Bei fünf oder weniger Bewertungen: dann ohne Verteilung, nur die Stimmen zeigen. Wenn die Bewertungen nur von Google oder einer Plattform kommen und nicht gespiegelt werden dürfen: dann verlinken. Nicht als Ersatz für ein großes Zitat im Marken-Teil der Seite (dafür testimonials-neu-gedacht).

Varianten

  • Verteilung seitlich, Liste daneben (Hotel, Praxis)
  • Verteilung als schmales Band über der Liste (Produktseite im Shop)
  • Zusätzlich Filter nach Reiseart oder Kundentyp
  • Teilnoten (Zimmer, Frühstück, Lage) als kleine Werte unter dem Durchschnitt
  • Weitere Bewertungen per Knopf nachladen statt Seitennummern

Umsetzung

Bewertungen als Daten vom Server oder Bewertungsdienst. Verteilung als Liste von <button aria-pressed> mit Anzahl; Balkenbreite als CSS-Wert --anteil (transform: scaleX). Ein Sternfilter zur Zeit, zweiter Klick hebt ihn auf; Balken mit 0 Treffern sind deaktiviert. Die Verteilung rechnet mit der gewählten Reiseart neu, so stimmen Balken und Trefferzahl immer überein. Sortierung als natives <select>. Trefferzahl und aktive Filter als Chips mit × in einem role="status"-Bereich. Liste als <ol> mit <article> je Bewertung, Sterne mit role="img" und aria-label „4 von 5 Sternen“. Antwort des Hauses als <details> (Desktop offen, Telefon zu). Echtheitshinweis direkt unter der Verteilung, nicht hinter einem Klick. Zustände: Leerzustand mit „Filter lösen“, weniger als 6 Bewertungen ohne Verteilung, Fehler mit „Erneut versuchen“ und Skelett beim Laden.

Robust umsetzen

Telefon. Verteilung über der Liste, Balken volle Breite mit mindestens 44 px Höhe. Sortierung und Filter-Zeile darunter. Antworten des Hauses eingeklappt bis „Antwort lesen“.

Ohne Bewegung. Balken springen auf die neue Breite, die Liste erscheint ohne Versatz.

Barrierefrei. Balken sind echte Knöpfe mit aria-pressed und vollständiger Beschriftung („3 Sterne: 2 Bewertungen“). Sterne nie nur als Grafik: Text „4 von 5 Sternen“. Trefferzahl in role="status" (WCAG 4.1.3). Farbe nicht allein (1.4.1): Auswahl auch über Rahmen und Häkchen. Sortierung als natives select. Fokus bleibt nach dem Filtern auf dem Balken.

Code

<!DOCTYPE html>
<!--
  Bewertungen mit Sternverteilung
  Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/bewertungen-verteilung/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de-CH">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bewertungen mit Sternverteilung · 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: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  /* Rezept Boutique-Hotel: Sand, Creme, warmes Dunkel, Holz als Hauston */
  --grund: #F3EEE7; --creme: #FEF2E3; --text: #3E3A37; --linie: #D5D1C8; --spur: #E6DFD4;
  --meta: #66645F;      /* Sekundärtext, dunkler als #82847F für 4,5 : 1 auf Sand */
  --deko: #82847F;      /* Aman-Grau nur für Linien und Ornament */
  --holz: #6E5A47; --holz-hell: #8C7660;
  --ui: cubic-bezier(0.4, 0, 0.2, 1);   /* Buchungsstrecke laut Rezept */
  --t-balken: 200ms; --t-liste: 250ms; --t-farbe: 150ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button, select { font: inherit; color: inherit; }
.label { font: 600 11.5px/1.2 'Mona Sans', system-ui, sans-serif; font-stretch: 118%; letter-spacing: .07em; text-transform: uppercase; }
.mono { font: 500 10px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.serif { font-family: 'Newsreader', Georgia, serif; font-optical-sizing: auto; font-weight: 350; letter-spacing: -.015em; }
.serif em { font-style: italic; font-weight: 300; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Kopfzeile des Hauses (Kontext) */
.haus { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 18px clamp(18px, 4vw, 56px); border-bottom: 1px solid var(--linie); }
.wortmarke { display: flex; align-items: baseline; gap: 12px; }
.wortmarke b { font: 400 22px/1 'Newsreader', Georgia, serif; letter-spacing: .02em; }
.wortmarke span { color: var(--meta); }
.haus nav { display: flex; gap: 26px; color: var(--meta); }
.haus nav [aria-current] { color: var(--text); box-shadow: inset 0 -1px 0 var(--text); padding-bottom: 3px; }
.haus .buchen { background: var(--holz); color: var(--creme); padding: 11px 16px; border-radius: 2px; text-decoration: none; }

.sektion { max-width: 1240px; margin: 0 auto; padding: clamp(28px, 5vw, 72px) clamp(18px, 4vw, 56px) 96px; display: grid; grid-template-columns: minmax(290px, 360px) minmax(0, 1fr); gap: clamp(28px, 5vw, 80px); align-items: start; }

/* Zusammenfassung */
.zusammenfassung { position: sticky; top: 24px; display: grid; gap: 22px; }
.kopf h2 { font-size: clamp(34px, 3.4vw, 46px); line-height: 1.04; margin: 12px 0 0; text-wrap: balance; }
.kopf .label { color: var(--meta); display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.beispiel { font: 500 9.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--holz); border: 1px dashed var(--holz-hell); padding: 5px 7px 4px; border-radius: 2px; white-space: nowrap; }
.borte { display: block; width: 100%; height: 12px; color: var(--deko); opacity: .6; }

.schnitt { display: grid; grid-template-columns: auto 1fr; align-items: end; gap: 6px 18px; }
.schnitt .zahl { font: 300 88px/.82 'Newsreader', Georgia, serif; letter-spacing: -.03em; font-variant-numeric: lining-nums tabular-nums; }
.schnitt .von { display: grid; gap: 8px; padding-bottom: 4px; }
.schnitt .von small { font-size: 14px; color: var(--meta); }
.sterne { display: inline-flex; gap: 2px; vertical-align: middle; }
.sterne svg { width: 15px; height: 15px; display: block; }
.sterne .an { fill: var(--holz); } .sterne .aus { fill: none; stroke: var(--holz-hell); stroke-width: 1.2; opacity: .7; }
.schnitt .sterne svg { width: 18px; height: 18px; }

.verteilung { list-style: none; display: grid; gap: 2px; }
.balken {
  width: 100%; display: grid; grid-template-columns: 4.6em minmax(0, 1fr) 2.4em; align-items: center; gap: 12px;
  background: none; border: 0; padding: 7px 8px; margin: 0 -8px; width: calc(100% + 16px); border-radius: 3px; cursor: pointer; text-align: left;
  transition: background-color var(--t-farbe) var(--ui), opacity var(--t-balken) var(--ui);
}
.balken .stufe { font-size: 14px; display: flex; gap: 6px; align-items: center; }
.balken .stufe svg { width: 12px; height: 12px; fill: var(--holz); }
.balken .spur { position: relative; height: 8px; background: var(--spur); border-radius: 8px; overflow: hidden; }
.balken .fuell { position: absolute; inset: 0; background: var(--holz); border-radius: 8px; transform-origin: left center; transform: scaleX(var(--anteil, 0)); transition: transform var(--t-balken) var(--ui), background-color var(--t-farbe) var(--ui); }
.balken .anzahl { font-size: 14px; text-align: right; color: var(--meta); }
.balken:hover { background: rgba(110, 90, 71, .07); }
.balken:focus-visible { outline: 2px solid var(--holz); outline-offset: 1px; }
.balken[aria-pressed="true"] { background: var(--creme); box-shadow: inset 0 0 0 1px var(--holz); }
.balken[aria-pressed="true"] .stufe::after { content: ''; width: 9px; height: 5px; border: solid var(--holz); border-width: 0 0 1.6px 1.6px; rotate: -45deg; translate: 2px -2px; }
.verteilung:has([aria-pressed="true"]) .balken[aria-pressed="false"] .fuell { background: #B8AA9A; }
.balken:disabled { cursor: default; opacity: .45; }
.balken:disabled:hover { background: none; }

.teilnoten { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--linie); border-bottom: 1px solid var(--linie); }
.teilnoten div { padding: 11px 0 10px; display: grid; gap: 6px; }
.teilnoten div + div { padding-left: 12px; border-left: 1px solid var(--linie); }
.teilnoten dt { color: var(--meta); font-size: 10.5px; }
.teilnoten dd { font: 400 22px/1 'Newsreader', Georgia, serif; font-variant-numeric: lining-nums tabular-nums; }

.echtheit { background: var(--creme); border: 1px solid var(--linie); padding: 16px 18px; display: grid; gap: 8px; font-size: 13.5px; line-height: 1.5; }
.echtheit h3 { font-size: 11px; display: flex; gap: 8px; align-items: center; }
.echtheit h3 svg { width: 14px; height: 14px; flex: none; }
.echtheit p { text-wrap: pretty; }
.echtheit .mono { color: var(--meta); font-size: 9.5px; }

.wenige { font-size: 14.5px; line-height: 1.5; color: var(--meta); border-top: 1px solid var(--linie); padding-top: 14px; }

/* Liste */
.liste-kopf { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 24px; padding-bottom: 16px; border-bottom: 1px solid var(--text); }
.reiseart { display: flex; flex-wrap: wrap; gap: 6px; border: 0; }
.reiseart legend { width: 100%; margin-bottom: 8px; color: var(--meta); font-size: 10.5px; }
.reiseart label { position: relative; font-size: 14px; padding: 8px 13px; border: 1px solid var(--linie); border-radius: 999px; cursor: pointer; display: inline-flex; gap: 6px; align-items: baseline; transition: background-color var(--t-farbe) var(--ui), border-color var(--t-farbe) var(--ui), color var(--t-farbe) var(--ui); }
.reiseart label small { font-size: 12px; color: var(--meta); }
.reiseart input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.reiseart label:hover { border-color: var(--holz-hell); }
.reiseart label:has(input:checked) { background: var(--text); border-color: var(--text); color: var(--creme); }
.reiseart label:has(input:checked) small { color: rgba(254, 242, 227, .7); }
.reiseart label:has(input:focus-visible) { outline: 2px solid var(--holz); outline-offset: 2px; }
.sortierung { display: grid; gap: 8px; }
.sortierung .label { color: var(--meta); font-size: 10.5px; }
.sortierung select { appearance: none; font-size: 14px; background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%233E3A37' stroke-width='1.3'/%3E%3C/svg%3E") right 12px center / 10px no-repeat; border: 1px solid var(--linie); border-radius: 2px; padding: 9px 34px 9px 12px; cursor: pointer; }
.sortierung select:focus-visible { outline: 2px solid var(--holz); outline-offset: 2px; }

.stand { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 14px 0; min-height: 56px; border-bottom: 1px solid var(--linie); font-size: 14.5px; }
.stand strong { font-weight: 600; }
.chip { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; background: var(--creme); border: 1px solid var(--linie); border-radius: 999px; padding: 5px 6px 5px 12px; cursor: pointer; }
.chip span[aria-hidden] { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 50%; background: var(--spur); font-size: 12px; line-height: 1; }
.chip:hover span[aria-hidden] { background: var(--linie); }
.chip:focus-visible, .leise-knopf:focus-visible { outline: 2px solid var(--holz); outline-offset: 2px; }
.leise-knopf { background: none; border: 0; font-size: 13.5px; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--deko); cursor: pointer; color: var(--meta); }
.leise-knopf:hover { color: var(--text); }

.liste { list-style: none; }
.bew { padding: 26px 0 24px; border-bottom: 1px solid var(--linie); display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 6px 28px; }
.bew-meta { display: grid; gap: 4px; align-content: start; font-size: 13.5px; line-height: 1.4; color: var(--meta); padding-top: 3px; }
.bew-meta b { color: var(--text); font-weight: 600; font-size: 14.5px; }
.bew-meta .art { margin-top: 6px; font-size: 10px; color: var(--holz); }
.bew-inhalt { display: grid; gap: 10px; }
.bew-zeile { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 13px; color: var(--meta); }
.bew h3 { font-size: 23px; line-height: 1.2; font-weight: 400; }
.bew p.text { font-size: 16px; line-height: 1.62; max-width: 62ch; text-wrap: pretty; }
.bew-fuss { display: flex; gap: 16px; align-items: center; margin-top: 4px; font-size: 13px; color: var(--meta); }
.hilfreich { display: inline-flex; gap: 7px; align-items: center; background: none; border: 1px solid var(--linie); border-radius: 999px; padding: 6px 12px; font-size: 13px; cursor: pointer; transition: border-color var(--t-farbe) var(--ui), background-color var(--t-farbe) var(--ui); }
.hilfreich svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; }
.hilfreich:hover { border-color: var(--holz-hell); }
.hilfreich[aria-pressed="true"] { background: var(--creme); border-color: var(--holz); color: var(--holz); }
.hilfreich[aria-pressed="true"] svg { fill: var(--holz); stroke: var(--holz); }
.hilfreich:focus-visible { outline: 2px solid var(--holz); outline-offset: 2px; }

.antwort { margin-top: 8px; border-left: 2px solid var(--holz); padding: 2px 0 2px 18px; }
.antwort summary { list-style: none; cursor: pointer; display: flex; gap: 10px; align-items: center; color: var(--holz); width: fit-content; }
.antwort summary::-webkit-details-marker { display: none; }
.antwort summary::after { content: ''; width: 7px; height: 7px; border: solid currentColor; border-width: 0 1.4px 1.4px 0; rotate: 45deg; translate: 0 -2px; transition: rotate var(--t-balken) var(--ui); }
.antwort[open] summary::after { rotate: 225deg; translate: 0 2px; }
.antwort summary:focus-visible { outline: 2px solid var(--holz); outline-offset: 3px; }
.antwort p { font: 400 17px/1.55 'Newsreader', Georgia, serif; font-optical-sizing: auto; margin-top: 8px; max-width: 60ch; text-wrap: pretty; }
.antwort small { display: block; font-size: 13px; color: var(--meta); margin-top: 6px; }

/* Einblenden nach Filtern: nur Deckkraft und 6 px */
@keyframes rein { from { opacity: 0; translate: 0 6px; } }
.liste > li.neu { animation: rein var(--t-liste) var(--ui) both; animation-delay: calc(var(--i, 0) * 30ms); }
.reduziert .liste > li.neu { animation: none; }

.mehr { display: flex; justify-content: center; padding-top: 28px; }
.knopf { background: var(--text); color: var(--creme); border: 0; border-radius: 2px; padding: 15px 22px; cursor: pointer; transition: background-color var(--t-farbe) var(--ui); }
.knopf:hover { background: #2c2927; }
.knopf:focus-visible { outline: 2px solid var(--holz); outline-offset: 3px; }
.knopf.zweit { background: none; color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.knopf.zweit:hover { background: rgba(62, 58, 55, .06); }
.knopf[aria-busy="true"] { opacity: .65; cursor: progress; }

.leer, .fehler { padding: 48px 0 40px; display: grid; gap: 14px; justify-items: start; max-width: 48ch; }
.leer h3, .fehler h3 { font-size: 30px; line-height: 1.1; font-weight: 350; }
.leer p, .fehler p { font-size: 15.5px; line-height: 1.55; color: var(--meta); }
.leer .knoepfe, .fehler .knoepfe { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
.leer svg, .fehler svg { width: 54px; height: 54px; color: var(--deko); }

/* Ladezustand */
.skelett { padding: 26px 0; border-bottom: 1px solid var(--linie); display: grid; gap: 12px; }
.skelett i { display: block; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--spur), #EFE9E0, var(--spur)); background-size: 200% 100%; animation: glanz 1.2s linear infinite; }
.skelett i:nth-child(1) { width: 30%; } .skelett i:nth-child(2) { width: 55%; height: 18px; } .skelett i:nth-child(3) { width: 92%; } .skelett i:nth-child(4) { width: 78%; }
@keyframes glanz { to { background-position: -200% 0; } }
.reduziert .skelett i { animation: none; }
.zusammenfassung[aria-busy="true"] .schnitt, .zusammenfassung[aria-busy="true"] .verteilung, .zusammenfassung[aria-busy="true"] .teilnoten { opacity: .35; }
.zusammenfassung .schnitt, .zusammenfassung .verteilung, .zusammenfassung .teilnoten { transition: opacity var(--t-liste) var(--ui); }

/* Gedachter Klick in der Vorführung */
.tipp { position: relative; }
.tipp::before { content: ''; position: absolute; inset: -4px; border-radius: inherit; border: 1.5px solid var(--holz); animation: tipp 600ms var(--ui) forwards; pointer-events: none; }
@keyframes tipp { from { opacity: 1; scale: .96; } to { opacity: 0; scale: 1.04; } }
.reduziert .tipp::before { animation: none; opacity: 0; }

/* Variante „Band“: Verteilung als Band über der Liste (Produktseite im Shop) */
[data-variante="band"] .sektion { grid-template-columns: minmax(0, 1fr); gap: 34px; }
[data-variante="band"] .zusammenfassung { position: static; grid-template-columns: minmax(220px, .8fr) minmax(260px, 1.1fr) minmax(240px, 1fr); gap: 22px 48px; align-items: start; padding-bottom: 30px; border-bottom: 1px solid var(--linie); }
[data-variante="band"] .kopf { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: end; gap: 24px; flex-wrap: wrap; }
[data-variante="band"] .kopf h2 { margin: 10px 0 0; }
[data-variante="band"] .borte { display: none; }
[data-variante="band"] .teilnoten { grid-column: 1; grid-row: 3; }
[data-variante="band"] .schnitt { grid-column: 1; }
[data-variante="band"] .verteilung { grid-column: 2; grid-row: 2 / span 2; }
[data-variante="band"] .echtheit { grid-column: 3; grid-row: 2 / span 2; align-self: start; }
[data-variante="band"] .liste { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; }
[data-variante="band"] .bew { grid-template-columns: 1fr; }
[data-variante="band"] .bew-meta { grid-row: 2; display: flex; flex-wrap: wrap; gap: 4px 10px; padding-top: 0; }
[data-variante="band"] .bew-meta .art { margin-top: 0; }

/* Pult (Demo-Steuerung) */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 20; display: grid; gap: 8px; background: rgba(62, 58, 55, .94); color: var(--creme); padding: 12px 14px; border-radius: 4px; box-shadow: 0 10px 30px rgba(62, 58, 55, .25); max-width: calc(100vw - 32px); }
.pult .reihe { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.pult .mono { color: rgba(254, 242, 227, .7); min-width: 5.6em; }
.pult .gruppe { display: flex; border: 1px solid rgba(254, 242, 227, .25); border-radius: 2px; }
.pult button { background: none; border: 0; padding: 7px 10px; font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: rgba(254, 242, 227, .75); cursor: pointer; }
.pult button + button { border-left: 1px solid rgba(254, 242, 227, .25); }
.pult button[aria-pressed="true"] { background: var(--creme); color: var(--text); }
.pult button:focus-visible { outline: 2px solid var(--creme); outline-offset: 2px; }
.werte-zeile { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--linie); color: var(--meta); font-size: 9.5px; line-height: 1.6; }
.vorschau .werte-zeile { display: none; }

:root.reduziert .balken .fuell, :root.reduziert .balken, :root.reduziert .antwort summary::after { transition: none; }
@media (prefers-reduced-motion: reduce) { .liste > li.neu { animation: none; } }

/* Touch: grössere Ziele, kein Hover-Zustand */
@media (hover: none) {
  .balken { padding-block: 12px; }
  .balken:hover { background: none; }
  .balken[aria-pressed="true"] { background: var(--creme); }
  .reiseart label { padding: 10px 14px; }
  .hilfreich { padding: 9px 14px; }
}

@media (max-width: 1080px) {
  [data-variante="band"] .zusammenfassung { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); }
  [data-variante="band"] .echtheit { grid-column: 1 / -1; grid-row: auto; }
}
@media (max-width: 860px) {
  .haus nav { display: none; }
  .sektion, [data-variante="band"] .sektion { grid-template-columns: minmax(0, 1fr); gap: 30px; }
  .zusammenfassung, [data-variante="band"] .zusammenfassung { position: static; grid-template-columns: minmax(0, 1fr); }
  [data-variante="band"] .zusammenfassung > * { grid-column: 1; grid-row: auto; }
  [data-variante="band"] .liste { grid-template-columns: 1fr; }
  .bew { grid-template-columns: 1fr; }
  .bew-meta { grid-row: 2; display: flex; flex-wrap: wrap; gap: 2px 10px; padding-top: 0; }
  .bew-meta .art { margin-top: 0; }
}
@media (max-width: 520px) {
  .haus { padding-block: 14px; }
  .wortmarke span { display: none; }
  .schnitt .zahl { font-size: 76px; }
  .teilnoten { grid-template-columns: 1fr 1fr; }
  .teilnoten div:nth-child(3) { padding-left: 0; border-left: 0; }
  .teilnoten div:nth-child(n+3) { border-top: 1px solid var(--linie); }
  .liste-kopf { align-items: stretch; }
  .sortierung, .sortierung select { width: 100%; }
  .bew h3 { font-size: 21px; }
  .pult { left: 12px; right: 12px; bottom: 12px; }
}

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .haus { display: none; }
.vorschau .sektion { padding-top: 30px; gap: 48px; grid-template-columns: 330px minmax(0, 1fr); }
.vorschau .zusammenfassung { gap: 16px; }
.vorschau .kopf h2 { font-size: 36px; }
.vorschau .teilnoten { display: none; }
.vorschau .echtheit { padding: 12px 14px; font-size: 12.5px; }
.vorschau [data-variante="band"] .sektion { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.vorschau [data-variante="band"] .zusammenfassung { grid-template-columns: .8fr 1.1fr 1fr; padding-bottom: 18px; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
  <symbol id="stern" viewBox="0 0 20 20"><path d="M10 1.6l2.47 5.35 5.86.62-4.38 3.96 1.23 5.77L10 14.37 4.82 17.3l1.23-5.77L1.67 7.57l5.86-.62z"/></symbol>
</svg>

<header class="haus">
  <p class="wortmarke"><b>Haus Zwirn</b><span class="label">St. Gallen · Altstadt</span></p>
  <nav class="label" aria-label="Hauptnavigation"><span>Zimmer</span><span>Frühstück</span><span aria-current="page">Gästestimmen</span><span>Anreise</span></nav>
  <a class="buchen label" href="#bewertungen">Verfügbarkeit</a>
</header>

<main class="sektion" id="bewertungen" aria-labelledby="titel">
  <aside class="zusammenfassung" data-zusammenfassung aria-label="Zusammenfassung der Bewertungen">
    <div class="kopf">
      <div>
        <p class="label">Gästestimmen <span class="beispiel">Beispieldaten · erfunden</span></p>
        <h2 class="serif" id="titel">Was Gäste über das Haus <em>an der Spisergasse</em> sagen</h2>
      </div>
    </div>
    <svg class="borte" viewBox="0 0 360 12" preserveAspectRatio="none" aria-hidden="true">
      <defs><pattern id="spitze" width="18" height="12" patternUnits="userSpaceOnUse"><path d="M0 1h18M0 1c4.5 0 4.5 8 9 8s4.5-8 9-8" fill="none" stroke="currentColor" stroke-width=".9"/><circle cx="9" cy="5" r="1.1" fill="currentColor"/></pattern></defs>
      <rect width="360" height="12" fill="url(#spitze)"/>
    </svg>

    <div class="schnitt" data-schnitt>
      <p class="zahl" data-zahl aria-hidden="true">4.2</p>
      <div class="von">
        <span class="sterne" data-sterne-gross aria-hidden="true"></span>
        <small class="ziffern" data-anzahl>18 Bewertungen · Aufenthalte 2025 und 2026</small>
      </div>
      <p class="nur-sr" data-schnitt-sr>Durchschnitt 4.2 von 5 Sternen aus 18 Bewertungen.</p>
    </div>

    <ul class="verteilung" data-verteilung aria-label="Nach Sternen filtern"></ul>

    <dl class="teilnoten" data-teilnoten aria-label="Teilnoten"></dl>

    <section class="echtheit" aria-labelledby="echt-titel">
      <h3 class="label" id="echt-titel"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 2.5 3.6v4.1c0 3.2 2.3 5.8 5.5 6.8 3.2-1 5.5-3.6 5.5-6.8V3.6z" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="m5.4 8.1 1.8 1.8 3.4-3.6" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>So prüfen wir die Bewertungen</h3>
      <p>Bewerten kann nur, wer bei uns übernachtet hat: Den Link senden wir nach der Abreise an die Adresse der Buchung, er gilt einmal. Wir veröffentlichen jede Bewertung, auch kritische, ungekürzt. Entfernt werden nur Beleidigungen und persönliche Daten Dritter.</p>
      <p class="mono">Textbaustein · vom Kunden rechtlich prüfen lassen</p>
    </section>
  </aside>

  <section class="liste-bereich" aria-labelledby="liste-titel">
    <h2 class="nur-sr" id="liste-titel">Alle Bewertungen</h2>
    <div class="liste-kopf">
      <fieldset class="reiseart" data-reiseart>
        <legend class="label">Reiseart</legend>
      </fieldset>
      <label class="sortierung">
        <span class="label">Sortieren</span>
        <select data-sort>
          <option value="neu">Neueste zuerst</option>
          <option value="gut">Beste zuerst</option>
          <option value="kritisch">Kritischste zuerst</option>
          <option value="hilfreich">Hilfreichste zuerst</option>
        </select>
      </label>
    </div>
    <div class="stand" data-stand role="status" aria-live="polite"></div>
    <ol class="liste" data-liste></ol>
    <div data-zustand></div>
    <div class="mehr" data-mehr></div>
    <p class="mono werte-zeile" data-werte></p>
  </section>
</main>

<div class="pult">
  <div class="reihe"><span class="mono">Variante</span>
    <div class="gruppe" role="group" aria-label="Variante">
      <button type="button" data-v="seitlich" aria-pressed="true">Seitlich</button>
      <button type="button" data-v="band" aria-pressed="false">Band</button>
    </div>
  </div>
  <div class="reihe"><span class="mono">Zustand</span>
    <div class="gruppe" role="group" aria-label="Zustand der Daten">
      <button type="button" data-z="normal" aria-pressed="true">18 Stimmen</button>
      <button type="button" data-z="wenige" aria-pressed="false">Nur 4</button>
      <button type="button" data-z="fehler" aria-pressed="false">Dienst aus</button>
    </div>
  </div>
</div>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);

  // Erfundene Beispielbewertungen (Haus Zwirn gibt es nicht). Teilnoten: Zimmer, Frühstück, Lage, Ruhe.
  const ALLE = [
    { s: 5, art: 'paar', d: '2026-10-12', name: 'Lena M.', von: 'Freiburg im Breisgau', zimmer: 'Kettstich', titel: 'Ruhig mitten in der Altstadt', text: 'Wir waren skeptisch, so nah an der Spisergasse. Nachts hörten wir aber nur die Glocken der Kathedrale, und um sieben waren auch die still. Das Frühstück mit Birnbrot und Alpkäse war der beste Start in den Tag.', h: 6, t: [5, 5, 5, 5] },
    { s: 4, art: 'geschaeft', d: '2026-09-23', name: 'Matthias K.', von: 'Winterthur', zimmer: 'Hohlsaum', titel: 'Gut zum Arbeiten, Schreibtisch etwas klein', text: 'Drei Nächte für eine Messe. WLAN stabil, Kaffee ab halb sieben, alles zu Fuss erreichbar. Der Schreibtisch im Einzelzimmer ist schön, aber für Laptop und Unterlagen knapp.', h: 3, t: [4, 4, 5, 4], a: { text: 'Danke, Herr K. Sie haben recht: Seit Oktober steht im Hohlsaum ein breiterer Tisch aus der Schreinerei nebenan. Beim nächsten Messebesuch ist er für Sie bereit.', tage: 2 } },
    { s: 5, art: 'familie', d: '2026-08-17', name: 'Familie R.', von: 'Konstanz', zimmer: 'Kreuzstich', titel: 'Kinder willkommen, nicht nur geduldet', text: 'Unsere zwei (5 und 8) bekamen am ersten Abend ein Stickbild zum Ausmalen und eine Karte mit den schönsten Brunnen der Stadt. Das Familienzimmer ist gross genug, dass niemand über Koffer steigen muss.', h: 9, t: [5, 5, 4, 4] },
    { s: 3, art: 'paar', d: '2026-07-05', name: 'Sabine T.', von: 'Stuttgart', zimmer: 'Plattstich', titel: 'Schönes Zimmer, laute Gasse am Samstag', text: 'Das Zimmer ist liebevoll eingerichtet, das Bett sehr gut. Am Samstag war in der Gasse aber bis nach eins etwas los, und die Fenster sind alt. Ohrstöpsel lagen bereit, das war aufmerksam. Nächstes Mal fragen wir nach dem Hof.', h: 11, t: [4, 4, 5, 2], a: { text: 'Liebe Frau T., danke für die offenen Worte. Die Zimmer zur Gasse sind am Wochenende lebhafter. Wir sagen das jetzt schon bei der Buchung und bieten ein Zimmer zum Hof an, wenn eines frei ist. Neue Schallschutzfenster sind für den Winter bestellt.', tage: 1 } },
    { s: 5, art: 'allein', d: '2026-06-11', name: 'Ruth B.', von: 'Bern', zimmer: 'Hohlsaum', titel: 'Allein reisen ohne Abstellkammer-Gefühl', text: 'Kein Kämmerchen mit Bett, sondern ein richtiges Zimmer mit Fenster zum Hof. Am Abend gab es Tipps für die Stiftsbibliothek und ein Restaurant, in dem man auch allein gut sitzt.', h: 5, t: [5, 5, 5, 5] },
    { s: 4, art: 'paar', d: '2026-05-02', name: 'Jan und Elif', von: 'Lindau', zimmer: 'Kettstich', titel: 'Frühstück grossartig, Treppe steil', text: 'Die Altstadthäuser haben keinen Lift, das steht auch so auf der Website. Mit zwei schweren Koffern war die Treppe trotzdem sportlich; der Gastgeber hat einen davon getragen. Das Frühstück entschädigt für alles.', h: 4, t: [4, 5, 5, 4], a: { text: 'Danke Ihnen beiden! Koffer tragen wir gern, sagen Sie einfach Bescheid. Wer Treppen meiden möchte, bekommt bei uns das Zimmer im ersten Stock.', tage: 3 } },
    { s: 5, art: 'geschaeft', d: '2026-04-14', name: 'Petra W.', von: 'München', zimmer: 'Plattstich', titel: 'Diskret, schnell, persönlich', text: 'Spät angekommen, der Schlüssel lag wie vereinbart im Fach am Eingang. Am Morgen kannte man trotzdem meinen Namen. Die Rechnung kam am selben Tag per Mail, mit allem, was die Buchhaltung braucht.', h: 2, t: [5, 4, 5, 5] },
    { s: 5, art: 'familie', d: '2026-02-21', name: 'Anna und Mia', von: 'Zürich', zimmer: 'Kreuzstich', titel: 'Ein Wochenende mit Grosi', text: 'Drei Generationen, ein Zimmer: Das Kreuzstich hat ein Ausziehbett, das den Namen verdient. Grosi fand das Birchermüesli besser als ihr eigenes, und das will etwas heissen.', h: 3, t: [5, 5, 5, 4] },
    { s: 5, art: 'paar', d: '2026-01-18', name: 'Claudia und Ben', von: 'Innsbruck', zimmer: 'Plattstich', titel: 'Schnee auf den Dächern, Tee am Ofen', text: 'Im Januar ist die Altstadt still. Nach dem Spaziergang hinauf zu den Weihern gab es am Kachelofen Tee und Biber. Zimmer warm, Bett breit, nichts zu meckern.', h: 2, t: [5, 5, 5, 5] },
    { s: 4, art: 'allein', d: '2025-12-03', name: 'Kerstin L.', von: 'Leipzig', zimmer: 'Hohlsaum', titel: 'Fast perfekt, Dusche etwas eng', text: 'Sehr sauber, schöne Stoffe, ein Bett wie zu Hause. Die Dusche im Einzelzimmer ist schmal; wer gross ist, stösst mit den Ellbogen an. Sonst gern wieder.', h: 1, t: [4, 5, 5, 5] },
    { s: 5, art: 'paar', d: '2025-11-08', name: 'Marco P.', von: 'Lugano', zimmer: 'Kettstich', titel: 'Für ein Jubiläum genau richtig', text: 'Wir hatten beim Buchen erwähnt, dass wir zehn Jahre verheiratet sind. Auf dem Zimmer standen zwei Gläser und eine handgeschriebene Karte. Kleine Geste, grosse Wirkung.', h: 4, t: [5, 5, 5, 5], a: { text: 'Herzlichen Glückwunsch noch einmal, und auf die nächsten zehn!', tage: 1 } },
    { s: 1, art: 'geschaeft', d: '2025-10-20', name: 'Stefan G.', von: 'Frankfurt am Main', zimmer: 'Plattstich', titel: 'Buchung ging verloren', text: 'Meine Buchung über ein Portal war im Haus nicht bekannt. Ich bekam zwar ein Zimmer, musste aber eine Stunde warten und am Ende ein anderes nehmen als gebucht. Für eine Geschäftsreise so nicht tragbar.', h: 8, t: [2, 3, 5, 3], a: { text: 'Herr G., dafür entschuldigen wir uns. Die Buchung kam wegen einer Störung beim Portal erst am Folgetag bei uns an. Sie hätten das nicht ausbaden dürfen. Wir haben Ihnen die Nacht erstattet und gleichen Portal-Buchungen seither jeden Abend von Hand ab.', tage: 1 } },
    { s: 5, art: 'allein', d: '2025-09-14', name: 'Yvonne S.', von: 'Basel', zimmer: 'Mansarde Spitze', titel: 'Leseecke mit Blick über die Dächer', text: 'Unterm Dach sitzt man in einer Fensternische und sieht über die Ziegel bis zu den Türmen. Ich habe zwei Tage mehr gelesen als in einem ganzen Monat zu Hause.', h: 2, t: [5, 5, 5, 5] },
    { s: 2, art: 'paar', d: '2025-08-09', name: 'Thomas H.', von: 'Ulm', zimmer: 'Mansarde Spitze', titel: 'Im August zu warm unterm Dach', text: 'Die Suite unterm Dach sieht toll aus, aber nachts wurde es sehr warm, und der Ventilator war laut. Freundliches Team, gutes Frühstück. Für knapp 300 Franken hätten wir eine Klimaanlage erwartet.', h: 14, t: [2, 4, 5, 2], a: { text: 'Lieber Herr H., das tut uns leid. Das Dach steht unter Denkmalschutz, eine Klimaanlage dürfen wir dort nicht einbauen. Wir haben leise Ventilatoren angeschafft und schreiben bei der Mansarde jetzt dazu, dass es im Sommer warm werden kann. Die zweite Nacht haben wir Ihnen erstattet.', tage: 2 } },
    { s: 4, art: 'familie', d: '2025-08-02', name: 'Familie O.', von: 'Bregenz', zimmer: 'Kreuzstich', titel: 'Viel Platz, Parkplatz knapp', text: 'Mit dem Auto in die Altstadt ist mühsam, und das Parkhaus um die Ecke kostet extra. Zimmer und Frühstück top, die Kinder wollen wiederkommen.', h: 3, t: [5, 5, 4, 4], a: { text: 'Danke! Ein Tipp für das nächste Mal: Mit Ihrer Buchung gilt im Parkhaus ein Gästetarif, die Karte bekommen Sie bei uns am Empfang.', tage: 4 } },
    { s: 3, art: 'allein', d: '2025-07-06', name: 'Nadine F.', von: 'Köln', zimmer: 'Kettstich', titel: 'Hübsch, aber Einchecken erst ab 15 Uhr', text: 'Ich kam um elf an und durfte das Gepäck abstellen, mehr ging nicht. Das ist so angekündigt, also fair. Das Zimmer war dann schön kühl und ruhig. Das Frühstück dürfte länger gehen als bis zehn.', h: 2, t: [4, 3, 5, 5] },
    { s: 5, art: 'geschaeft', d: '2025-06-12', name: 'Henrik A.', von: 'Hamburg', zimmer: 'Hohlsaum', titel: 'Ruhiger als jedes grosse Hotel', text: 'Kein Lärm vom Flur, kein Frühstücksbuffet mit Gedränge. Man setzt sich, und der Kaffee kommt. Für zwei Nächte Arbeit genau das, was ich brauchte.', h: 1, t: [5, 4, 5, 5] },
    { s: 5, art: 'paar', d: '2025-05-24', name: 'Ursula und Peter', von: 'Graz', zimmer: 'Plattstich', titel: 'Zum dritten Mal hier', text: 'Man wird nicht wie ein Stammgast behandelt, sondern wie ein Gast, den man gern wiedersieht. Das ist ein Unterschied, und hier spürt man ihn.', h: 3, t: [5, 5, 5, 4], a: { text: 'Wir freuen uns jetzt schon auf das vierte Mal.', tage: 2 } }
  ].map((b, i) => ({ ...b, id: 'b' + (i + 1) }));
  const GASTGEBERIN = 'Ladina Caduff, Gastgeberin';

  const ARTEN = [['alle', 'Alle'], ['paar', 'Paar'], ['allein', 'Allein'], ['familie', 'Familie'], ['geschaeft', 'Geschäftlich']];
  const ARTNAME = Object.fromEntries(ARTEN);
  const ARTVON = { paar: 'Paaren', allein: 'Alleinreisenden', familie: 'Familien', geschaeft: 'Geschäftsreisenden' };
  const TEIL = ['Zimmer', 'Frühstück', 'Lage', 'Ruhe'];
  const SEITE = 5;

  // Swiss German: 4.2 und Monatsnamen (für deutsche Kundenseiten 'de-DE' → 4,2)
  const zahl1 = new Intl.NumberFormat('de-CH', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const monat = new Intl.DateTimeFormat('de-CH', { month: 'long', year: 'numeric' });
  const plural = new Intl.PluralRules('de-CH');
  const bewertungen = n => n + (plural.select(n) === 'one' ? ' Bewertung' : ' Bewertungen');

  const zustand = { stern: null, art: 'alle', sort: 'neu', zeige: SEITE, daten: 'normal', laden: false };
  const geholfen = new Set();
  let letzterSchnitt = 0;

  const verteilung = $('[data-verteilung]'), liste = $('[data-liste]'), stand = $('[data-stand]'), mehr = $('[data-mehr]');
  const zustandFeld = $('[data-zustand]'), zusammen = $('[data-zusammenfassung]'), sortSel = $('[data-sort]'), reiseart = $('[data-reiseart]');

  const sterneSvg = (n, gross) => Array.from({ length: 5 }, (_, i) => `<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><use href="#stern" class="${i < n ? 'an' : 'aus'}"/></svg>`).join('');
  $('[data-sterne-gross]').innerHTML = sterneSvg(4);

  // Balken und Reiseart einmal aufbauen, danach nur Werte ändern (Fokus bleibt stehen)
  verteilung.innerHTML = [5, 4, 3, 2, 1].map(s => `<li><button type="button" class="balken" data-stern="${s}" aria-pressed="false">
    <span class="stufe ziffern">${s} <svg viewBox="0 0 20 20" aria-hidden="true"><use href="#stern"/></svg><span class="nur-sr">${s === 1 ? 'Stern' : 'Sterne'}</span></span>
    <span class="spur" aria-hidden="true"><span class="fuell"></span></span>
    <span class="anzahl ziffern"></span></button></li>`).join('');
  const balken = [...verteilung.querySelectorAll('.balken')];
  reiseart.insertAdjacentHTML('beforeend', ARTEN.map(([w, n]) => `<label><input type="radio" name="reiseart" value="${w}"${w === 'alle' ? ' checked' : ''}>${n} <small class="ziffern" data-art-n="${w}"></small></label>`).join(''));

  const quelle = () => zustand.daten === 'wenige' ? ALLE.slice(0, 4) : ALLE;
  const nachArt = () => quelle().filter(b => zustand.art === 'alle' || b.art === zustand.art);
  const mittel = arr => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;

  function sortiert(arr) {
    const k = [...arr];
    if (zustand.sort === 'neu') k.sort((a, b) => b.d.localeCompare(a.d));
    if (zustand.sort === 'gut') k.sort((a, b) => b.s - a.s || b.d.localeCompare(a.d));
    if (zustand.sort === 'kritisch') k.sort((a, b) => a.s - b.s || b.d.localeCompare(a.d));
    if (zustand.sort === 'hilfreich') k.sort((a, b) => (b.h + geholfen.has(b.id)) - (a.h + geholfen.has(a.id)) || b.d.localeCompare(a.d));
    return k;
  }

  function zaehle(el, ziel) {
    const start = letzterSchnitt; letzterSchnitt = ziel;
    if (reduziert() || Math.abs(start - ziel) < .001) { el.textContent = zahl1.format(ziel); return; }
    const t0 = performance.now();
    const schritt = t => { const p = Math.min(1, (t - t0) / 200); el.textContent = zahl1.format(start + (ziel - start) * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(schritt); };
    requestAnimationFrame(schritt);
  }

  function zusammenfassung() {
    const basis = nachArt(), n = basis.length, gesamt = quelle().length;
    const wenige = gesamt <= 5;
    const s = mittel(basis.map(b => b.s));
    zaehle($('[data-zahl]'), s);
    $('[data-sterne-gross]').innerHTML = sterneSvg(Math.round(s));
    $('[data-anzahl]').textContent = zustand.art === 'alle' ? `${bewertungen(n)} · Aufenthalte ${wenige ? '2026' : '2025 und 2026'}` : `${bewertungen(n)} von ${ARTVON[zustand.art]}`;
    $('[data-schnitt-sr]').textContent = `Durchschnitt ${zahl1.format(s)} von 5 Sternen aus ${bewertungen(n)}.`;
    verteilung.hidden = wenige;
    let hinweis = zusammen.querySelector('.wenige');
    if (wenige && !hinweis) { verteilung.insertAdjacentHTML('afterend', '<p class="wenige">Erst wenige Stimmen. Die Verteilung zeigen wir ab sechs Bewertungen, damit einzelne Noten nicht täuschen.</p>'); }
    if (!wenige && hinweis) hinweis.remove();
    balken.forEach(b => {
      const st = +b.dataset.stern, c = basis.filter(x => x.s === st).length;
      b.style.setProperty('--anteil', n ? c / n : 0);
      b.querySelector('.anzahl').textContent = c;
      b.setAttribute('aria-pressed', String(zustand.stern === st));
      b.setAttribute('aria-label', `${st} ${st === 1 ? 'Stern' : 'Sterne'}: ${bewertungen(c)}${zustand.stern === st ? ', Filter aktiv' : ''}`);
      b.disabled = c === 0 && zustand.stern !== st;
    });
    const td = $('[data-teilnoten]');
    td.innerHTML = TEIL.map((t, i) => `<div><dt class="label">${t}</dt><dd>${n ? zahl1.format(mittel(basis.map(b => b.t[i]))) : '–'}</dd></div>`).join('');
    ARTEN.forEach(([w]) => { reiseart.querySelector(`[data-art-n="${w}"]`).textContent = quelle().filter(b => w === 'alle' || b.art === w).length; });
  }

  function karte(b, i) {
    const wann = monat.format(new Date(b.d));
    const antwortTag = b.a ? monat.format(new Date(new Date(b.d).getTime() + b.a.tage * 864e5)) : '';
    const offen = !matchMedia('(max-width: 640px)').matches;
    const n = b.h + (geholfen.has(b.id) ? 1 : 0);
    return `<li class="neu" style="--i:${Math.min(i, 6)}"><article class="bew" aria-labelledby="t-${b.id}">
      <div class="bew-meta"><b>${b.name}</b><span>${b.von}</span><span>Zimmer ${b.zimmer}</span><span class="art label">${ARTNAME[b.art]}</span></div>
      <div class="bew-inhalt">
        <div class="bew-zeile"><span class="sterne" role="img" aria-label="${b.s} von 5 Sternen">${sterneSvg(b.s)}</span><span>Aufenthalt <time datetime="${b.d}">${wann}</time></span><span class="label" style="font-size:10px">Aufenthalt bestätigt</span></div>
        <h3 class="serif" id="t-${b.id}">${b.titel}</h3>
        <p class="text">${b.text}</p>
        ${b.a ? `<details class="antwort"${offen ? ' open' : ''}><summary class="label">Antwort des Hauses</summary><p>${b.a.text}</p><small>${GASTGEBERIN} · ${b.a.tage === 1 ? 'einen Tag' : b.a.tage + ' Tage'} später</small></details>` : ''}
        <div class="bew-fuss"><button type="button" class="hilfreich" data-hilfreich="${b.id}" aria-pressed="${geholfen.has(b.id)}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 7.2V14H2.5V7.2zM5 7.2l2.6-5c1.2 0 1.9.8 1.7 2L8.9 6h3.9c.9 0 1.5.8 1.3 1.7l-1.1 5c-.2.8-.8 1.3-1.6 1.3H5"/></svg>Hilfreich <span class="ziffern">(${n})</span></button></div>
      </div></article></li>`;
  }

  function zeichne(neu = true) {
    zustandFeld.innerHTML = ''; mehr.innerHTML = '';
    if (zustand.daten === 'fehler') return fehler();
    zusammenfassung();
    const basis = nachArt();
    const treffer = sortiert(basis.filter(b => !zustand.stern || b.s === zustand.stern));
    const gesamt = quelle().length;
    // Stand: Trefferzahl und aktive Filter
    const chips = [];
    if (zustand.stern) chips.push(`<button type="button" class="chip" data-weg="stern">${zustand.stern} ${zustand.stern === 1 ? 'Stern' : 'Sterne'} <span aria-hidden="true">×</span><span class="nur-sr">Filter entfernen</span></button>`);
    if (zustand.art !== 'alle') chips.push(`<button type="button" class="chip" data-weg="art">${ARTNAME[zustand.art]} <span aria-hidden="true">×</span><span class="nur-sr">Filter entfernen</span></button>`);
    stand.innerHTML = `<span class="ziffern">${chips.length ? `<strong>${treffer.length}</strong> von ${bewertungen(gesamt)}` : `<strong>${bewertungen(gesamt)}</strong>`}</span>${chips.join('')}${chips.length > 1 ? '<button type="button" class="leise-knopf" data-weg="alle">Alle Filter aufheben</button>' : ''}`;
    if (!treffer.length) return leer();
    const sicht = treffer.slice(0, zustand.zeige);
    liste.innerHTML = sicht.map(karte).join('');
    if (!neu) liste.querySelectorAll('li').forEach(li => li.classList.remove('neu'));
    const rest = treffer.length - sicht.length;
    if (rest > 0) mehr.innerHTML = `<button type="button" class="knopf label" data-weiter>Weitere ${Math.min(SEITE, rest)} anzeigen <span class="ziffern">(${rest} übrig)</span></button>`;
  }

  function leer() {
    liste.innerHTML = '';
    const st = zustand.stern;
    zustandFeld.innerHTML = `<div class="leer">
      <svg viewBox="0 0 54 54" aria-hidden="true"><circle cx="27" cy="27" r="25" fill="none" stroke="currentColor" stroke-width="1"/><use href="#stern" x="17" y="16" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1"/></svg>
      <h3 class="serif">${st ? `Keine Bewertung mit ${st} ${st === 1 ? 'Stern' : 'Sternen'} von ${ARTVON[zustand.art]}.` : `Noch keine Bewertung von ${ARTVON[zustand.art]}.`}</h3>
      <p>${st ? (st <= 2 ? 'Das ist eine gute Nachricht. ' : '') + 'Lösen Sie einen Filter, um die übrigen Stimmen zu sehen.' : 'Sobald jemand nach dem Aufenthalt bewertet, erscheint die Stimme hier.'}</p>
      <div class="knoepfe">${st ? `<button type="button" class="knopf label" data-weg="stern">Filter „${st} ${st === 1 ? 'Stern' : 'Sterne'}“ lösen</button>` : ''}<button type="button" class="knopf ${st ? 'zweit ' : ''}label" data-weg="art">Alle Reisearten</button></div>
    </div>`;
  }

  function fehler() {
    liste.innerHTML = ''; stand.innerHTML = '<span>Bewertungen nicht geladen</span>';
    zusammen.setAttribute('aria-busy', 'true');
    zustandFeld.innerHTML = `<div class="fehler" role="alert">
      <svg viewBox="0 0 54 54" aria-hidden="true"><circle cx="27" cy="27" r="25" fill="none" stroke="currentColor" stroke-width="1"/><path d="M27 15v15M27 36v2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
      <h3 class="serif">Die Bewertungen sind gerade nicht erreichbar.</h3>
      <p>Der Bewertungsdienst antwortet nicht. Ihre Buchung ist davon nicht betroffen. Versuchen Sie es in einem Moment noch einmal.</p>
      <div class="knoepfe"><button type="button" class="knopf label" data-nochmal>Erneut versuchen</button></div>
    </div>`;
  }

  function laden(dann, ms = 600) {
    liste.innerHTML = ''; zustandFeld.innerHTML = '<div aria-hidden="true">' + '<div class="skelett"><i></i><i></i><i></i><i></i></div>'.repeat(2) + '</div>';
    stand.innerHTML = '<span>Bewertungen werden geladen …</span>';
    nach(ms, dann);
  }

  // Bedienung
  verteilung.addEventListener('click', e => {
    const b = e.target.closest('.balken'); if (!b || b.disabled) return;
    const st = +b.dataset.stern;
    zustand.stern = zustand.stern === st ? null : st; zustand.zeige = SEITE;
    zeichne();
  });
  reiseart.addEventListener('change', e => { zustand.art = e.target.value; zustand.zeige = SEITE; zeichne(); });
  sortSel.addEventListener('change', () => { zustand.sort = sortSel.value; zeichne(); });
  document.addEventListener('click', e => {
    const weg = e.target.closest('[data-weg]');
    if (weg) {
      const w = weg.dataset.weg;
      if (w === 'stern' || w === 'alle') zustand.stern = null;
      if (w === 'art' || w === 'alle') { zustand.art = 'alle'; reiseart.querySelector('input[value="alle"]').checked = true; }
      zeichne();
      (verteilung.querySelector('[aria-pressed="true"]') || stand).focus?.();
      return;
    }
    const h = e.target.closest('[data-hilfreich]');
    if (h) {
      const id = h.dataset.hilfreich; geholfen.has(id) ? geholfen.delete(id) : geholfen.add(id);
      const b = ALLE.find(x => x.id === id);
      h.setAttribute('aria-pressed', String(geholfen.has(id)));
      h.querySelector('.ziffern').textContent = `(${b.h + (geholfen.has(id) ? 1 : 0)})`;
      return;
    }
    if (e.target.closest('[data-weiter]')) {
      const k = e.target.closest('[data-weiter]'); k.setAttribute('aria-busy', 'true'); k.textContent = 'Lädt …';
      const vorher = liste.children.length;
      nach(reduziert() ? 0 : 350, () => {
        zustand.zeige += SEITE; zeichne();
        [...liste.children].forEach((li, i) => { if (i < vorher) li.classList.remove('neu'); else li.style.setProperty('--i', i - vorher); });
        liste.children[vorher]?.querySelector('h3')?.setAttribute('tabindex', '-1');
        liste.children[vorher]?.querySelector('h3')?.focus({ preventScroll: true });
      });
      return;
    }
    if (e.target.closest('[data-nochmal]')) {
      laden(() => { setzeDaten('normal'); });
    }
  });

  // Pult: Variante (?v=) und Datenzustand
  const VARIANTEN = ['seitlich', 'band'];
  const WERTE = {
    gemein: 'Kurve cubic-bezier(0.4, 0, 0.2, 1) · Balken 200 ms · Liste 250 ms, 6 px, Versatz 30 ms · Verteilung ab 6 Stimmen',
    seitlich: ' · Zusammenfassung sticky, 360 px',
    band: ' · Band dreispaltig, Liste zweispaltig'
  };
  function variante(v) {
    if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
    document.body.dataset.variante = v;
    document.querySelectorAll('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    $('[data-werte]').textContent = WERTE.gemein + WERTE[v];
    const q = new URLSearchParams(location.search);
    if (q.get('v') !== v) { q.set('v', v); history.replaceState(null, '', '?' + q.toString().replace(/=(&|$)/g, '$1')); }
  }
  function setzeDaten(z) {
    zustand.daten = z; zustand.stern = null; zustand.art = 'alle'; zustand.zeige = SEITE;
    reiseart.querySelector('input[value="alle"]').checked = true;
    reiseart.disabled = z === 'fehler'; sortSel.disabled = z === 'fehler';
    zusammen.removeAttribute('aria-busy');
    document.querySelectorAll('[data-z]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.z === z)));
    zeichne();
  }
  document.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => variante(b.dataset.v)));
  document.querySelectorAll('[data-z]').forEach(b => b.addEventListener('click', () => { if (html.classList.contains('vorfuehrung')) neustart(); setzeDaten(b.dataset.z); }));

  // Vorführung: Balken klicken, Reiseart wählen, Leerzustand, Filter lösen, sortieren (ca. 13 s)
  let zeitgeber = [], laeuft = false;
  function nach(ms, f) { zeitgeber.push(setTimeout(f, ms)); }
  function tippe(el) { if (!el) return; el.classList.remove('tipp'); void el.offsetWidth; el.classList.add('tipp'); setTimeout(() => el.classList.remove('tipp'), 650); }
  function klick(sel) { const el = typeof sel === 'string' ? $(sel) : sel; tippe(el); el?.click(); }
  function art(w) { const inp = reiseart.querySelector(`input[value="${w}"]`); tippe(inp.parentElement); inp.checked = true; inp.dispatchEvent(new Event('change', { bubbles: true })); }
  function sort(w) { tippe(sortSel); sortSel.value = w; sortSel.dispatchEvent(new Event('change')); }
  function eineRunde() {
    setzeDaten('normal'); sort('neu');
    nach(900, () => klick('.balken[data-stern="1"]'));        // 1 Treffer, mit Antwort des Hauses
    nach(3400, () => art('familie'));                          // Leerzustand
    nach(6000, () => klick('.leer [data-weg="stern"]'));       // Filter lösen: 3 Familien-Bewertungen
    nach(7600, () => klick('.balken[data-stern="5"]'));
    nach(9200, () => art('alle'));                             // 5 Sterne bleiben: 10 Treffer
    nach(10600, () => { klick('.chip[data-weg="stern"]'); sort('kritisch'); });
    nach(13200, () => sort('neu'));
    nach(14200, eineRunde);
  }
  function pause() { zeitgeber.forEach(clearTimeout); zeitgeber = []; laeuft = false; }
  function neustart() { pause(); html.classList.remove('vorfuehrung'); setzeDaten('normal'); sortSel.value = 'neu'; zustand.sort = 'neu'; zeichne(); }
  function vorfuehren() { neustart(); html.classList.add('vorfuehrung'); laeuft = true; eineRunde(); }

  variante(new URLSearchParams(location.search).get('v'));
  zeichne();

})();
</script>
</body>
</html>