UI: Hell/Dunkel-Schalter + bessere Filter-UX

- Manueller Farbmodus-Button in der App-Bar: Auto -> Hell -> Dunkel,
  in localStorage gemerkt, ohne Aufblitzen (Inline-Head-Script),
  color-scheme + Adressleisten-Farbe ziehen mit. CSS auf data-theme-
  Override umgestellt (dark-Tokens für [data-theme=dark] UND System-
  Automatik, außer manuell „hell"). AAA in beiden Modi erhalten.
- Filter: leere Gruppen werden beim Filtern ausgeblendet -> Treffer
  stehen sofort oben, kein Suchen mehr zwischen leeren Boxen. Sichtbares
  Feedback (Treffer-Zähler im Header + sanfte Einblende-Animation,
  reduced-motion-safe), klarer Leer-Zustand bei 0 Treffern + Reset.
- QC: axe-core 0 Verletzungen hell+dunkel (inkl. AAA), 77 Tests grün,
  Toggle/Persistenz/Filter live verifiziert, keine JS-Fehler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 23:20:37 +02:00
parent 4b833f3785
commit 75b4c3b97d
3 changed files with 114 additions and 6 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View file

@ -24,7 +24,7 @@
--err:#8a231d; --err-bg:#fbeae8; --err-line:#e6b8b2;
--price:#11221b;
--line:#e3ded5; --line-2:#eceae2; --ctrl:#8a8474;
--focus:#0e5d42;
--focus:#0e5d42; color-scheme:light;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s8:32px; --s12:48px;
--radius:16px; --radius-sm:11px; --radius-pill:999px;
--appbar-h:60px;
@ -34,8 +34,11 @@
--shadow-lg:0 20px 50px -20px rgba(20,18,12,.30);
--ring:0 0 0 3px var(--brand-weak), 0 0 0 1.5px var(--brand);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dunkel-Tokens — AAA-verifiziert. ACHTUNG: bewusst ZWEIMAL geführt, damit
beides geht: manueller Schalter ([data-theme=dark]) UND System-Automatik
(prefers-color-scheme, außer der Nutzer hat manuell „hell" gewählt).
Beide Blöcke MÜSSEN identische Werte haben. */
:root[data-theme="dark"] {
--bg:#13120d; --panel:#1d1b15; --panel-2:#23211a; --panel-3:#2a2820;
--ink:#f2efe7; --ink-2:#d6d2c6; --muted:#cbc6b7;
--brand:#3db188; --brand-text:#6cd2a4; --on-brand:#06130c;
@ -44,7 +47,23 @@
--err:#f0a59c; --err-bg:#3a1f1c; --err-line:#5e2e2a;
--price:#eaf7f0;
--line:#2e2c24; --line-2:#272519; --ctrl:#767161;
--focus:#6cd2a4;
--focus:#6cd2a4; color-scheme:dark;
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
--shadow-md:0 12px 32px -14px rgba(0,0,0,.6);
--shadow-lg:0 24px 56px -22px rgba(0,0,0,.7);
--ring:0 0 0 3px rgba(108,210,164,.28), 0 0 0 1.5px var(--brand-text);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg:#13120d; --panel:#1d1b15; --panel-2:#23211a; --panel-3:#2a2820;
--ink:#f2efe7; --ink-2:#d6d2c6; --muted:#cbc6b7;
--brand:#3db188; --brand-text:#6cd2a4; --on-brand:#06130c;
--brand-weak:#18352a; --brand-weak-line:#2c5a47;
--warn:#f4cf86; --warn-bg:#3a2f12; --warn-line:#5c4a1e;
--err:#f0a59c; --err-bg:#3a1f1c; --err-line:#5e2e2a;
--price:#eaf7f0;
--line:#2e2c24; --line-2:#272519; --ctrl:#767161;
--focus:#6cd2a4; color-scheme:dark;
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
--shadow-md:0 12px 32px -14px rgba(0,0,0,.6);
--shadow-lg:0 24px 56px -22px rgba(0,0,0,.7);
@ -306,6 +325,21 @@
border:1px solid var(--warn-line); border-radius:6px; padding:1px 8px; margin-left:8px; font-weight:700; }
.leer { padding:var(--s4) var(--s6); color:var(--muted); font-style:italic; font-size:14px; }
/* Filter-Feedback: Treffer-Zähler, sanfte Einblendung, Leer-Zustand */
.filterstat { font-size:13.5px; color:var(--muted); margin-bottom:var(--s3); font-weight:600;
display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.filterstat b { color:var(--ink); font-weight:800; }
.linkbtn { background:none; border:none; color:var(--brand-text); font:inherit; font-weight:700;
cursor:pointer; padding:2px 4px; min-height:0; text-decoration:underline; border-radius:6px; }
.linkbtn:hover { background:var(--brand-weak); filter:none; transform:none; box-shadow:none; }
.linkbtn:focus-visible { box-shadow:var(--ring); }
@keyframes grp-in { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
#gruppen.filtern > section.grp { animation:grp-in .2s ease both; }
.keine-treffer { padding:var(--s12) var(--s6); text-align:center; background:var(--panel);
border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-sm); color:var(--muted); }
.keine-treffer .big { font-size:18px; font-weight:800; color:var(--ink); margin-bottom:6px; }
@media (prefers-reduced-motion: reduce) { #gruppen.filtern > section.grp { animation:none; } }
/* Quelle: sichtbar lassen, nur schöner (kein Roh-String mehr) */
.quelle { display:inline-flex; align-items:center; gap:4px; font-size:11.5px; color:var(--muted);
background:var(--panel-3); border:1px solid var(--line); border-radius:6px; padding:1px 7px; font-weight:600; }
@ -339,6 +373,12 @@
.grp-h-btn { padding-left:var(--s4); padding-right:var(--s4); }
}
</style>
<script>
/* Theme vor dem ersten Paint setzen — kein Aufblitzen des falschen Modus. */
try { var _t = localStorage.getItem("ang_theme");
if (_t === "dark" || _t === "light") document.documentElement.setAttribute("data-theme", _t); }
catch (e) {}
</script>
</head>
<body>
<a class="sr-only" href="#stage1">Zum Inhalt springen</a>
@ -359,6 +399,7 @@
</a>
<span class="place leer" id="appbarPlz" aria-live="polite">PLZ …</span>
<span class="spacer"></span>
<button class="iconbtn" id="themeToggle" type="button" title="Farbmodus" aria-label="Farbmodus umschalten"><span class="ti" aria-hidden="true">🌗</span></button>
<button class="iconbtn" id="appbarSettings" type="button" title="LLM-Konfiguration" aria-label="LLM-Konfiguration öffnen"></button>
</div>
</header>
@ -475,6 +516,7 @@
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
</div>
<div class="filterstat" id="filterstat" role="status" aria-live="polite" hidden></div>
<nav id="gruppennav" class="gruppenband" aria-label="Produktgruppen — zum Abschnitt springen"></nav>
<div id="korrektur-feedback" class="kfeedback" role="status" aria-live="polite"></div>
</header>
@ -761,17 +803,23 @@ function zeichneGruppen(){
const fH = $("#fhaendler").value;
const fT = $("#ftext").value.trim().toLowerCase();
const nurSicher = $("#fsicher").checked;
const filterAktiv = !!(fH || fT || nurSicher);
const box = $("#gruppen");
box.innerHTML = "";
box.classList.toggle("filtern", filterAktiv); // löst die Einblende-Animation aus (Feedback)
const navDaten = [];
let treffer = 0;
for (const g of d.gruppen){
const items = g.angebote.filter(a => passt(a, fH, fT, nurSicher));
// Beim Filtern leere Gruppen GANZ ausblenden -> Treffer stehen sofort oben,
// man muss sie nicht zwischen leeren Boxen suchen. Ohne Filter: alle zeigen.
if (!items.length && filterAktiv) continue;
const sec = document.createElement("section");
sec.className = "grp";
sec.dataset.grp = g.name;
const secId = "grp-" + g.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
sec.id = secId;
sec.id = "grp-" + g.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
const h = document.createElement("h3");
h.className = "grp-h";
@ -791,19 +839,73 @@ function zeichneGruppen(){
if (!items.length){
const p = document.createElement("div"); p.className = "leer";
p.textContent = (fH || fT || nurSicher) ? "keine Treffer im Filter" : "keine Angebote";
p.textContent = "keine Angebote";
sec.appendChild(p);
} else {
const ul = document.createElement("ul");
for (const a of items) ul.appendChild(zeile(a, g.name));
sec.appendChild(ul);
navDaten.push({ name:g.name, count:items.length, uns:items.filter(a=>a.unsicher).length });
treffer += items.length;
}
box.appendChild(sec);
}
// 0 Treffer trotz aktivem Filter -> klarer Leer-Zustand statt leerer Seite
if (filterAktiv && !box.children.length){
box.innerHTML =
`<div class="keine-treffer"><div class="big">Keine Treffer</div>` +
`Kein Angebot passt zu diesem Filter. ` +
`<button type="button" class="linkbtn" id="filterReset">Filter zurücksetzen</button></div>`;
const rb = $("#filterReset"); if (rb) rb.onclick = filterZuruecksetzen;
}
// Sichtbares Filter-Feedback im Header (Treffer-Zähler + Reset)
const fs = $("#filterstat");
if (filterAktiv){
fs.hidden = false;
fs.innerHTML =
`🔎 <b>${treffer}</b> Treffer in <b>${navDaten.length}</b> Gruppe${navDaten.length===1?"":"n"}` +
` <button type="button" class="linkbtn" id="filterReset2">Filter zurücksetzen</button>`;
const rb2 = $("#filterReset2"); if (rb2) rb2.onclick = filterZuruecksetzen;
} else {
fs.hidden = true; fs.innerHTML = "";
}
baueGruppenband(navDaten);
}
function filterZuruecksetzen(){
$("#fhaendler").value = "";
$("#ftext").value = "";
$("#fsicher").checked = false;
zeichneGruppen();
}
// ----- Farbmodus: Auto -> Hell -> Dunkel (manueller Schalter) ----------------
function gemerktesTheme(){ try { return localStorage.getItem("ang_theme") || "auto"; } catch(e){ return "auto"; } }
function systemDunkel(){ return matchMedia("(prefers-color-scheme: dark)").matches; }
function setzeTheme(t){
const root = document.documentElement;
if (t === "auto") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", t);
try { localStorage.setItem("ang_theme", t); } catch(e){}
const dunkelJetzt = t === "dark" || (t === "auto" && systemDunkel());
const ico = t === "auto" ? "🌗" : (t === "dark" ? "🌙" : "☀️");
const label = t === "auto" ? `Farbmodus: automatisch (${dunkelJetzt ? "dunkel" : "hell"})`
: t === "dark" ? "Farbmodus: dunkel" : "Farbmodus: hell";
const btn = $("#themeToggle");
if (btn){ btn.querySelector(".ti").textContent = ico; btn.setAttribute("aria-label", label + " — umschalten"); btn.title = label; }
// Adressleisten-Farbe (mobil) ans aktuelle Theme angleichen
let mc = document.querySelector('meta[name="theme-color"]:not([media])');
if (!mc){ mc = document.createElement("meta"); mc.setAttribute("name","theme-color"); document.head.appendChild(mc); }
mc.setAttribute("content", dunkelJetzt ? "#13120d" : "#0e5d42");
}
function zyklusTheme(){
const reihen = ["auto","light","dark"];
setzeTheme(reihen[(reihen.indexOf(gemerktesTheme()) + 1) % reihen.length]);
}
function baueGruppenband(navDaten){
const nav = $("#gruppennav");
nav.innerHTML = "";
@ -922,6 +1024,11 @@ $("#appbarSettings").onclick = () => {
c.scrollIntoView({behavior:"smooth", block:"center"});
c.querySelector("summary").focus();
};
$("#themeToggle").onclick = zyklusTheme;
// Im Auto-Modus dem System-Wechsel folgen (Icon/Adressleiste mitziehen)
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (gemerktesTheme() === "auto") setzeTheme("auto");
});
let _plzTimer;
$("#plz").addEventListener("input", () => {
setAppbarPlz();
@ -934,6 +1041,7 @@ for (const id of ["#fhaendler","#ftext","#fsicher"])
// ----- Init ------------------------------------------------------------------
(async function init(){
setzeTheme(gemerktesTheme()); // Button-Icon/Label an gemerkten Modus angleichen
const _p = new URLSearchParams(location.search);
const ab = _p.get("anbieter") || gemerkterAnbieter();
if (ab) $("#anbieter").value = ab;