<!DOCTYPE html>
<html lang="de" prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article# fb: http://ogp.me/ns/fb#">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta http-equiv="Content-Language" content="de-DE">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="apple-mobile-web-app-title" content="Nachrichten">
    
    <!-- ADDITIONAL DEVICE COMPATIBILITY -->
    <meta name="MobileOptimized" content="width">
    <meta name="HandheldFriendly" content="true">
    <meta name="x-ua-compatible" content="ie=edge">
    <meta name="ImageToolbar" content="false">
    <meta name="MSSmartTagsPreventParsing" content="true">
    <meta name="format-detection" content="telephone=no,email=no,address=no">
    
    <!-- PRECONNECT FOR PERFORMANCE -->
    <!-- PERFORMANCE-BUGFIX: Die früheren <link rel="prefetch"> auf die kompletten
         Startseiten von tagesschau.de / spiegel.de / faz.net wurden ENTFERNT.
         Diese luden beim Seitenaufruf jeweils ganze fremde HTML-Dokumente (mehrere
         MB) im Hintergrund herunter, die für die Darstellung NICHT gebraucht werden
         – das verlangsamte den Erstaufruf und verbrauchte (mobiles) Datenvolumen.
         Stattdessen nur noch preconnect zu den tatsächlich genutzten Proxy-Origins,
         damit die ersten echten Datenanfragen schneller starten. -->
    <link rel="preconnect" href="https://api.rss2json.com" crossorigin>
    <link rel="preconnect" href="https://api.allorigins.win" crossorigin>
    <link rel="preconnect" href="https://api.codetabs.com" crossorigin>
    <link rel="dns-prefetch" href="//api.rss2json.com">
    <link rel="dns-prefetch" href="//api.allorigins.win">
    <link rel="dns-prefetch" href="//api.codetabs.com">

    <!-- HOCHWERTIGE TYPOGRAFIE (Redesign): Inter für UI/Text, Lora (Serif) für
         Schlagzeilen -> professioneller „Newsroom“-Look. display=swap verhindert
         unsichtbaren Text während des Ladens (kein FOIT), also kein Ladezeit-Nachteil. -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Lora:ital,wght@0,500;0,600;0,700;1,500&display=swap">

    <!-- POLYFILLS FÜR BROWSER-KOMPATIBILITÄT -->
    <script>
        // Promise Polyfill für alte Browser
        if (!window.Promise) {
            window.Promise = function(executor) {
                this._state = 'pending';
                this._value = undefined;
                this._callbacks = [];
                var that = this;
                executor(function(value) {
                    that._resolve(value);
                }, function(reason) {
                    that._reject(reason);
                });
            };
            window.Promise.prototype._resolve = function(value) {
                if (this._state === 'pending') {
                    this._state = 'fulfilled';
                    this._value = value;
                    this._callbacks.forEach(function(cb) {
                        cb.onFulfilled && cb.onFulfilled(value);
                    });
                }
            };
            window.Promise.prototype._reject = function(reason) {
                if (this._state === 'pending') {
                    this._state = 'rejected';
                    this._value = reason;
                    this._callbacks.forEach(function(cb) {
                        cb.onRejected && cb.onRejected(reason);
                    });
                }
            };
            window.Promise.prototype.then = function(onFulfilled, onRejected) {
                return new window.Promise(function(resolve, reject) {
                    this._callbacks.push({
                        onFulfilled: function(value) {
                            try {
                                resolve(onFulfilled ? onFulfilled(value) : value);
                            } catch (e) {
                                reject(e);
                            }
                        },
                        onRejected: function(reason) {
                            try {
                                reject(onRejected ? onRejected(reason) : reason);
                            } catch (e) {
                                reject(e);
                            }
                        }
                    });
                }.bind(this));
            };
        }

        // Fetch Polyfill für IE
        if (!window.fetch) {
            window.fetch = function(url, options) {
                return new window.Promise(function(resolve, reject) {
                    var xhr = new XMLHttpRequest();
                    xhr.open(options && options.method || 'GET', url);
                    if (options && options.headers) {
                        for (var key in options.headers) {
                            xhr.setRequestHeader(key, options.headers[key]);
                        }
                    }
                    xhr.onload = function() {
                        resolve({
                            ok: xhr.status >= 200 && xhr.status < 300,
                            status: xhr.status,
                            json: function() { return window.Promise.resolve(JSON.parse(xhr.responseText)); },
                            text: function() { return window.Promise.resolve(xhr.responseText); }
                        });
                    };
                    xhr.onerror = function() { reject(new Error('Network error')); };
                    xhr.send(options && options.body);
                });
            };
        }

        // localStorage Fallback für alte Browser
        if (!window.localStorage) {
            window.localStorage = {
                getItem: function(key) { return null; },
                setItem: function(key, value) {},
                removeItem: function(key) {},
                clear: function() {}
            };
        }

        // IntersectionObserver Polyfill für Lazy Loading
        if (!window.IntersectionObserver) {
            window.IntersectionObserver = function(callback, options) {
                this.callback = callback;
                this.options = options || {};
                this.elements = [];
                this.observer = null;
                // Simpler Fallback: Trigger immediately
                setTimeout(() => {
                    this.elements.forEach(el => {
                        this.callback([{ target: el, isIntersecting: true }]);
                    });
                }, 100);
            };
            window.IntersectionObserver.prototype.observe = function(el) {
                this.elements.push(el);
            };
            window.IntersectionObserver.prototype.unobserve = function(el) {
                this.elements = this.elements.filter(e => e !== el);
            };
            window.IntersectionObserver.prototype.disconnect = function() {
                this.elements = [];
            };
        }

        // Performance.now Polyfill
        if (!window.performance || !window.performance.now) {
            window.performance = window.performance || {};
            window.performance.now = function() {
                return Date.now();
            };
        }

        // History API Polyfill for older browsers (basic fallback)
        if (!window.history || !window.history.pushState) {
            window.history.pushState = function(state, title, url) {
                window.location.hash = url;
            };
            window.history.replaceState = function(state, title, url) {
                window.location.hash = url;
            };
            window.addEventListener('hashchange', function() {
                handleRoute();
            });
        }
    </script>
    
    <title>Nachrichten Kaipfstr | Politik, Meinungen & Fakten – Live News-Aggregator für Deutschland</title>
    
    <!-- COMPREHENSIVE INDEXING META TAGS FOR 10000% SEO -->
    <meta name="description" content="Nachrichten Kaipfstr: unabhängiger Nachrichten-Aggregator. Live-Meldungen zu Politik, Wirtschaft, Gesellschaft und Kultur aus öffentlich verfügbaren RSS-Feeds von Tagesschau, Deutschlandfunk, Bundesregierung, Spiegel, FAZ, Zeit und mehr.">
    <meta name="abstract" content="Live Nachrichten Aggregator Deutschland - Politik, Tagesschau, Breaking News und aktuelle Analysen in Echtzeit">
    
    <!-- MAXIMUM KEYWORD DENSITY FOR GOOGLE RANKING -->
    <meta name="keywords" content="Nachrichten, Politik, Meinungsmache, Tagesschau, News Feed, Kaipfstr, Breaking News, Deutschland, Analyse, Live News, Eilmeldungen, RSS Aggregator, Aktuelle Nachrichten, Deutschland News, Politik News, News heute, aktuelle News, Nachrichten live, News App, Nachrichten Deutschland, Nachrichten aktuell, News Kaipfstr, Kaipfstr News, Nachrichten Feed, Nachrichten Aggregator, deutsche Nachrichten, news aggregator, news aggregation service, nachrichten kaipfstr.de, kaipfstr.de nachrichten, nachrichten portal, news portal, deutsche news">
    
    <meta name="author" content="Kaipfstr Redaktion">
    <meta name="creator" content="Kaipfstr News Aggregator Service">
    <meta name="publisher" content="Nachrichten Kaipfstr">
    <meta name="copyright" content="Copyright © 2026 Nachrichten Kaipfstr. Alle Rechte vorbehalten.">
    
    <!-- GOOGLE SPECIFIC INDEXING -->
    <meta name="google" content="index, follow, snippet, archive">
    <meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet, translate, notranslate">
    <meta name="googlebot-news" content="index, follow, snippet">
    
    <!-- BING & YAHOO OPTIMIZATION -->
    <meta name="bingbot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet">
    <meta name="msvalidate.01" content="nachrichten-kaipfstr">
    <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet, odp, all, noodp, noydir">
    
    <!-- REVISIT & CACHE POLICY -->
    <meta name="revisit-after" content="12 hours">
    <meta name="cache-control" content="public, max-age=3600">
    <meta name="pragma" content="cache">
    <meta name="expires" content="604800">
    
    <!-- LANGUAGE & LOCALE -->
    <meta name="language" content="Deutsch, German, DE">
    <meta name="content-language" content="de, de-DE">
    <meta http-equiv="content-language" content="de-DE">
    
    <!-- GEOGRAPHICAL TARGETING -->
    <meta name="geo.position" content="51.1657;10.4515">
    <meta name="geo.region" content="DE">
    <meta name="geo.country" content="Germany, Deutschland, DE">
    <meta name="geo.placename" content="Deutschland, Germany">
    <meta name="ICBM" content="51.1657, 10.4515">
    <meta name="location" content="Deutschland">
    
    <!-- DISTRIBUTION & CLASSIFICATION -->
    <meta name="distribution" content="global">
    <meta name="rating" content="general, safe for kids">
    <meta name="classification" content="News, News Aggregation, Politics, Analysis">
    <meta name="category" content="Nachrichten, Politik, Gesellschaft, Wirtschaft">
    <meta name="subcategory" content="Breaking News, Politics, Society, Economics">
    <meta name="news_keywords" content="Politik, Wirtschaft, Gesellschaft, Kultur, Deutschland, Breaking News, Eilmeldungen, News Feed, Nachrichten heute">
    
    <!-- DUBLIN CORE METADATA FOR ARCHIVING & INDEXING -->
    <meta name="DC.title" content="Nachrichten Kaipfstr - Live News Aggregator">
    <meta name="DC.creator" content="Kaipfstr Redaktion">
    <meta name="DC.subject" content="Nachrichten, Politik, Deutschland, Live News">
    <meta name="DC.description" content="Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen mit Live Meldungen">
    <meta name="DC.publisher" content="Nachrichten Kaipfstr">
    <meta name="DC.language" content="de">
    <meta name="DC.format" content="text/html">
    <meta name="DC.type" content="Text, Website">
    <meta name="DC.coverage" content="Deutschland, Germany">
    <meta name="DC.rights" content="© 2026 Nachrichten Kaipfstr">
    <meta name="DC.issued" content="2026-01-28">
    
    <!-- ADDITIONAL INDEXING BOOST -->
    <meta name="robots" content="index, follow, archive, snippet, odp, all">
    <meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet">
    <meta name="bingbot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet">
    <meta name="DC.title" content="Nachrichten Kaipfstr">
    <meta name="DC.creator" content="Kaipfstr Redaktion">
    <meta name="DC.subject" content="Nachrichten">
    <meta name="DC.description" content="Live Nachrichten aus Deutschland">
    <meta name="DC.publisher" content="Nachrichten Kaipfstr">
    <meta name="DC.language" content="de">
    <meta name="DC.format" content="text/html">
    <meta name="DC.type" content="Text">
    <meta name="DC.coverage" content="Deutschland">
    
    <!-- ADDITIONAL SEO & INDEXING BOOSTS -->
    <meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
    <meta name="bingbot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
    <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet, odp">
    <meta name="revisit-after" content="1 hour">
    <meta name="author" content="Kaipfstr Redaktion">
    <meta name="publisher" content="Nachrichten Kaipfstr">
    <meta name="language" content="de-DE">
    <meta name="geo.region" content="DE">
    <meta name="geo.country" content="Germany">
    <meta name="geo.placename" content="Deutschland">
    <meta name="ICBM" content="51.1657, 10.4515"> <!-- Germany coordinates -->
    <meta name="distribution" content="global">
    <meta name="rating" content="general">
    <meta name="classification" content="News, Politics, Germany">
    <meta name="category" content="Nachrichten, Politik, Gesellschaft">
    <meta name="news_keywords" content="Politik, Wirtschaft, Gesellschaft, Kultur, Deutschland, Breaking News">
    <meta name="DC.title" content="Nachrichten Kaipfstr">
    <meta name="DC.creator" content="Kaipfstr Redaktion">
    <meta name="DC.subject" content="Nachrichten, Politik, Deutschland">
    <meta name="DC.description" content="Live Nachrichten aus Deutschland - Unabhängig und schnell">
    <meta name="DC.publisher" content="Nachrichten Kaipfstr">
    <meta name="DC.language" content="de">
    <meta name="DC.format" content="text/html">
    <meta name="DC.type" content="Text">
    <meta name="DC.coverage" content="Deutschland">
    <meta name="DC.rights" content="© 2026 Nachrichten Kaipfstr">
    <meta name="copyright" content="© 2026 Nachrichten Kaipfstr">
    <meta name="generator" content="Nachrichten Kaipfstr Aggregation Service">
    <meta name="designer" content="Kaipfstr Redaktion">
    <meta name="web_author" content="Kaipfstr Redaktion">
    <meta name="reply-to" content="info@nachrichten.kaipfstr.de">
    <meta name="owner" content="Kaipfstr">
    <meta name="target" content="all">
    <meta name="audience" content="all">
    <meta name="page-type" content="News Aggregation">
    <meta name="page-topic" content="German News">
    <meta name="content-language" content="de">
    <meta name="content-type" content="text/html; charset=UTF-8">
    <meta name="expires" content="never">
    <meta name="pragma" content="no-cache">
    <meta name="cache-control" content="public, max-age=3600">
    
    <!-- ADDITIONAL INDEXING BOOSTS -->
    <meta name="robots" content="index, follow, archive, snippet, odp, all">
    <meta name="googlebot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet">
    <meta name="bingbot" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1, archive, snippet">
    <meta name="slurp" content="index, follow, archive">
    <meta name="duckduckbot" content="index, follow, archive">
    <meta name="baiduspider" content="index, follow, archive">
    <meta name="yandex" content="index, follow, archive">
    <meta name="sogou" content="index, follow, archive">
    <meta name="exabot" content="index, follow, archive">
    <meta name="ia_archiver" content="index, follow, archive">
    <meta name="archive.org_bot" content="index, follow, archive">
    <meta name="webcrawler" content="index, follow, archive">
    <meta name="mj12bot" content="index, follow, archive">
    <meta name="dotbot" content="index, follow, archive">
    <meta name="ahrefsbot" content="index, follow, archive">
    <meta name="semrushbot" content="index, follow, archive">
    <meta name="rogerbot" content="index, follow, archive">
    <meta name="archivebot" content="index, follow, archive">
    <meta name="special_archiver" content="index, follow, archive">
    <meta name="wombat" content="index, follow, archive">
    <meta name="heritrix" content="index, follow, archive">
    <meta name="collection@infegy.com" content="index, follow, archive">
    <meta name="domaincrawler" content="index, follow, archive">
    <meta name="spbot" content="index, follow, archive">
    <meta name="turnitinbot" content="index, follow, archive">
    <meta name="ezooms" content="index, follow, archive">
    <meta name="sistrix" content="index, follow, archive">
    <meta name="seokicks" content="index, follow, archive">
    <meta name="rasa" content="index, follow, archive">
    <meta name="coccocbot" content="index, follow, archive">
    <meta name="linkdexbot" content="index, follow, archive">
    <meta name="woobot" content="index, follow, archive">
    <meta name="gigabot" content="index, follow, archive">
    <meta name="charlotte" content="index, follow, archive">
    <meta name="nutch" content="index, follow, archive">
    <meta name="heritrix" content="index, follow, archive">
    <meta name="archive.org_bot" content="index, follow, archive">
    <meta name="special_archiver" content="index, follow, archive">
    <meta name="wombat" content="index, follow, archive">
    <meta name="collection@infegy.com" content="index, follow, archive">
    <meta name="domaincrawler" content="index, follow, archive">
    <meta name="spbot" content="index, follow, archive">
    <meta name="turnitinbot" content="index, follow, archive">
    <meta name="ezooms" content="index, follow, archive">
    <meta name="sistrix" content="index, follow, archive">
    <meta name="seokicks" content="index, follow, archive">
    <meta name="rasa" content="index, follow, archive">
    <meta name="coccocbot" content="index, follow, archive">
    <meta name="linkdexbot" content="index, follow, archive">
    <meta name="woobot" content="index, follow, archive">
    <meta name="gigabot" content="index, follow, archive">
    <meta name="charlotte" content="index, follow, archive">
    <meta name="nutch" content="index, follow, archive">
    <meta name="mj12bot" content="index, follow, archive">
    <meta name="dotbot" content="index, follow, archive">
    <meta name="ahrefsbot" content="index, follow, archive">
    <meta name="semrushbot" content="index, follow, archive">
    <meta name="rogerbot" content="index, follow, archive">
    <meta name="archivebot" content="index, follow, archive">
    <meta name="webcrawler" content="index, follow, archive">
    <meta name="ia_archiver" content="index, follow, archive">
    <meta name="baiduspider" content="index, follow, archive">
    <meta name="yandex" content="index, follow, archive">
    <meta name="sogou" content="index, follow, archive">
    <meta name="exabot" content="index, follow, archive">
    <meta name="duckduckbot" content="index, follow, archive">
    <meta name="slurp" content="index, follow, archive">
    <meta name="bingbot" content="index, follow, archive">
    <meta name="googlebot" content="index, follow, archive">
    
    <!-- EVEN MORE BOT DIRECTIVES FOR MAXIMUM INDEXING -->
    <meta name="ccbot" content="index, follow, archive">
    <meta name="msnbot" content="index, follow, archive">
    <meta name="mediapartners-google" content="index, follow">
    <meta name="ia_archiver" content="index, follow, archive">
    <meta name="archive.org_bot" content="index, follow, archive">
    <meta name="archivebot" content="index, follow, archive">
    <meta name="clariabot" content="index, follow, archive">
    <meta name="coccoc" content="index, follow, archive">
    <meta name="mojeekbot" content="index, follow, archive">
    <meta name="naver" content="index, follow, archive">
    <meta name="nibblebot" content="index, follow, archive">
    <meta name="seznambot" content="index, follow, archive">
    <meta name="thumbshots" content="index, follow, archive">
    <meta name="tineye" content="index, follow, archive">
    <meta name="yahoo" content="index, follow, archive">
    <meta name="yacy" content="index, follow, archive">
    <meta name="yandexbot" content="index, follow, archive">
    <meta name="zebot" content="index, follow, archive">
    <meta name="1-and-1" content="index, follow, archive">
    <meta name="360spider" content="index, follow, archive">
    <meta name="accoona" content="index, follow, archive">
    
    <!-- CANONICAL & ALTERNATE VERSIONS -->
    <link rel="canonical" href="https://nachrichten.kaipfstr.de">
    <link rel="alternate" hreflang="de" href="https://nachrichten.kaipfstr.de">
    <link rel="alternate" hreflang="de-DE" href="https://nachrichten.kaipfstr.de/de-DE">
    <link rel="alternate" hreflang="de-AT" href="https://nachrichten.kaipfstr.de/de-AT">
    <link rel="alternate" hreflang="de-CH" href="https://nachrichten.kaipfstr.de/de-CH">
    <link rel="alternate" hreflang="x-default" href="https://nachrichten.kaipfstr.de">
    
    <!-- QUELLEN-ABONNEMENTLISTE (OPML) -->
    <!-- BUGFIX (Faktentreue): Hier standen vier <link rel="alternate">-Verweise auf
         /rss, /atom, /rss/politik und /rss/tagesschau. Zu keiner dieser Adressen
         existiert eine Datei – alle vier lieferten 404. Solche toten Feed-Verweise
         schaden der Indexierung und täuschen Feedreadern Inhalte vor, die es nicht
         gibt. Da diese Seite rein statisch ist, kann sie keinen eigenen, laufend
         aktualisierten Sammel-Feed erzeugen. Ehrliche Lösung: eine ECHTE, prüfbare
         OPML-Abonnementliste mit allen 21 Originalquellen, die sich in jeden
         Feedreader importieren lässt. -->
    <link rel="alternate" type="text/x-opml" title="Alle Quellen als OPML-Abonnementliste" href="https://nachrichten.kaipfstr.de/feeds.opml">
    
    <!-- SITEMAP & SEARCH -->
    <link rel="sitemap" type="application/xml" title="Sitemap" href="https://nachrichten.kaipfstr.de/sitemap.xml">
    <link rel="search" type="application/opensearchdescription+xml" title="Nachrichten Kaipfstr Suche" href="https://nachrichten.kaipfstr.de/opensearch.xml">
    
    <!-- CANONICAL LINK FOR HOMEPAGE -->
    <!-- SEO-BUGFIX: Der zweite <link rel="canonical"> wurde entfernt.
         Zwei widersprüchliche Canonical-URLs verwirren Google und können
         dazu führen, dass die Seite gar nicht kanonisiert wird. Es darf
         pro Dokument nur EINE Canonical-URL geben (siehe Zeile oben). -->
    <!-- BUGFIX (Faktentreue): Auch dieser Verweis zeigte auf /atom – eine Adresse
         ohne Datei (404). Ersetzt durch die real vorhandene Sitemap-Referenz. -->
    <link rel="alternate" type="application/xml" title="Seitenverzeichnis" href="https://nachrichten.kaipfstr.de/sitemap.xml">

    <meta property="og:type" content="website">
    <meta property="og:url" content="https://nachrichten.kaipfstr.de">
    <meta property="og:title" content="Nachrichten Kaipfstr | Politik, Meinungen & Fakten - Live Feed - Deutschlands Nachrichten Aggregator">
    <meta property="og:description" content="Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen. Unabhängig, faktenbasiert und live. Politik, Tagesschau und exklusive Meinungen auf nachrichten.kaipfstr.de. Breaking News und Eilmeldungen aus Deutschland.">
    <meta property="og:image" content="https://nachrichten.kaipfstr.de/assets/og-image.png">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta property="og:image:type" content="image/jpeg">
    <meta property="og:locale" content="de_DE">
    <meta property="og:locale:alternate" content="de_AT">
    <meta property="og:locale:alternate" content="de_CH">
    <meta property="og:site_name" content="Nachrichten Kaipfstr">
    <meta property="article:author" content="Kaipfstr Redaktion">
    <meta property="article:publisher" content="https://nachrichten.kaipfstr.de">
    <meta property="article:published_time" content="2026-01-28T00:00:00+01:00">
    <meta property="article:modified_time" content="2026-01-28T12:00:00+01:00">
    <meta property="article:section" content="News">
    <meta property="article:tag" content="Nachrichten, Politik, Deutschland">
    
    <!-- FACEBOOK SPECIFIC META TAGS -->
    <meta property="fb:app_id" content="1234567890">
    
    <!-- EXTENDED OPEN GRAPH FOR SHARING -->
    <!-- FAKTENTREUE-BUGFIX: Hier standen og:video-Angaben auf /assets/video.mp4.
         Diese Datei existiert nicht und die Seite bietet auch kein Video an.
         Falsche Medienangaben fuehren bei sozialen Netzwerken zu fehlerhaften
         Vorschauen und sind schlicht unwahr. Ersetzt durch die korrekte Angabe,
         dass das Vorschaubild ein Bild ist. -->
    <meta property="og:image:alt" content="Nachrichten Kaipfstr - Nachrichten-Aggregator fuer deutsche RSS-Quellen">
    
    <!-- TWITTER CARD FOR MAXIMUM TWITTER INDEXING -->
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="Nachrichten Kaipfstr - Deutschlands Live News Aggregator">
    <meta name="twitter:description" content="Alle aktuellen Nachrichten aus Deutschland auf einen Blick. Politik, Tagesschau, Breaking News und Analysen.">
    <meta name="twitter:image" content="https://nachrichten.kaipfstr.de/assets/og-image.png">
    <meta name="twitter:image:alt" content="Nachrichten Kaipfstr - Live News Aggregator">
    <meta name="twitter:site" content="@kaipfstr_news">
    <meta name="twitter:creator" content="@kaipfstr_redaktion">
    <!-- FAKTENTREUE-BUGFIX: twitter:player verwies auf /player - eine Adresse,
         die es nicht gibt. Es existiert kein eingebetteter Player. Entfernt,
         damit keine funktionsunfaehige Wiedergabe angekuendigt wird. -->
    
    <!-- LINKEDIN META TAGS -->
    <meta property="linkedin:url" content="https://nachrichten.kaipfstr.de">
    <meta property="linkedin:title" content="Nachrichten Kaipfstr - News Feed">
    
    <!-- PINTEREST META TAGS -->
    <meta property="pinterest:media" content="https://nachrichten.kaipfstr.de/assets/og-image.png">
    <meta property="pinterest:description" content="Nachrichten Kaipfstr - Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen">
    
    <!-- WHATSAPP & MESSAGING APPS -->
    <meta property="wa:title" content="Nachrichten Kaipfstr">
    <meta property="wa:description" content="Live Nachrichten aus Deutschland">
    
    <!-- APPLE SPECIFIC META TAGS FOR IOS INDEXING -->
    <meta name="apple-itunes-app" content="app-id=123456789">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <meta name="apple-mobile-web-app-title" content="Nachrichten">
    <meta name="apple-app-site-association" content="https://nachrichten.kaipfstr.de/.well-known/apple-app-site-association">
    
    <!-- PERFORMANCE & OPTIMIZATION FOR MOBILE & DESKTOP -->
    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📰</text></svg>">
    <link rel="shortcut icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📰</text></svg>">
    <link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📰</text></svg>">
    <link rel="apple-touch-icon-precomposed" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📰</text></svg>">
    
    <meta name="theme-color" content="#0a0a0a">
    <meta name="msapplication-TileColor" content="#0a0a0a">
    <meta name="msapplication-config" content="https://nachrichten.kaipfstr.de/browserconfig.xml">
    <meta name="msapplication-navbutton-color" content="#0a0a0a">
    <meta name="msapplication-starturl" content="https://nachrichten.kaipfstr.de">
    <meta name="window-target" content="_top">
    
    <!-- PERFORMANCE HINTS -->
    <!-- PERFORMANCE-BUGFIX: <link rel="preload" as="fetch"> auf die nackte
         rss2json-Basis-URL entfernt. Die App fragt diese URL nie unverändert ab
         (sie hängt immer ?rss_url=... an), daher blieb der Preload ungenutzt,
         erzeugte eine Browser-Warnung und verschwendete eine Verbindung.
         DNS-Prefetch/Preconnect reichen hier völlig aus. -->
    <link rel="dns-prefetch" href="//api.rss2json.com">
    <link rel="dns-prefetch" href="//api.allorigins.win">
    <link rel="dns-prefetch" href="//www.tagesschau.de">
    <link rel="dns-prefetch" href="//meinungsmache.de">
    <link rel="dns-prefetch" href="//www.deutschlandfunk.de">
    <link rel="dns-prefetch" href="//www.bundesregierung.de">
    <link rel="dns-prefetch" href="//www.spiegel.de">
    <link rel="dns-prefetch" href="//www.faz.net">
    <link rel="dns-prefetch" href="//www.zeit.de">
    <link rel="dns-prefetch" href="//www.sueddeutsche.de">
    <link rel="dns-prefetch" href="//www.bild.de">
    <link rel="dns-prefetch" href="//www.stern.de">
    <link rel="dns-prefetch" href="//www.rtl.de">
    <link rel="dns-prefetch" href="//www.cnn.de">
    <link rel="dns-prefetch" href="//www.dw.de">
    
    <!-- NETWORK HINTS FOR FASTER LOADING -->
    <link rel="preconnect" href="https://api.rss2json.com" crossorigin>
    <link rel="preconnect" href="https://api.allorigins.win" crossorigin>
    <link rel="preconnect" href="https://www.tagesschau.de" crossorigin>
    <link rel="preconnect" href="https://www.spiegel.de" crossorigin>
    <link rel="preconnect" href="https://www.faz.net" crossorigin>
    <link rel="preconnect" href="https://www.zeit.de" crossorigin>
    
    <!-- GOOGLE ANALYTICS & TRACKING READINESS -->
    <meta name="google-site-verification" content="verification-code-here">
    <meta name="facebook-domain-verification" content="verification-code-here">
    <meta name="yandex-verification" content="verification-code-here">
    
    <!-- ADDITIONAL SECURITY & STANDARDS -->
    <!-- SEO-BUGFIX: "no-cache, no-store" widersprach den Caching- & SEO-Zielen
         (revisit-after, max-age=3600) weiter oben. Eine öffentliche News-Startseite
         soll kurzzeitig gecacht werden dürfen, damit Wiederholbesuche & Crawler
         schnell sind. Auf eine konsistente, moderate Cache-Policy gesetzt.
         Hinweis: Verbindliches Caching gehört ohnehin in HTTP-Header (nicht Meta). -->
    <meta http-equiv="Cache-Control" content="public, max-age=600, stale-while-revalidate=3600">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <!-- CSP: erlaubt jetzt zusätzlich Google Fonts (Stylesheet von fonts.googleapis.com,
         Schriftdateien von fonts.gstatic.com). Vorher blockierte style-src externe
         Stylesheets, wodurch keine hochwertige Web-Schrift geladen werden konnte. -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self' https:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https: https://fonts.gstatic.com; connect-src 'self' https:;">
    <meta http-equiv="X-Content-Type-Options" content="nosniff">
    <meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
    <meta http-equiv="X-XSS-Protection" content="1; mode=block">
    <meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
    <meta http-equiv="Permissions-Policy" content="geolocation=(), microphone=(), camera=(), payment=()">
    
    <!-- ADDITIONAL MANIFEST FOR PWA INDEXING -->
    <!-- BUGFIX: Zuvor wurde ein data:-Manifest verlinkt, das von der eigenen
         Content-Security-Policy (default-src 'self' https:) blockiert wurde.
         Jetzt: echte Datei /manifest.webmanifest (CSP-konform). -->
    <link rel="manifest" href="/manifest.webmanifest">
    <link rel="author" href="/humans.txt">
    
    <!-- STRUCTURED DATA READINESS FOR JSON-LD INJECTION -->
    <script type="application/ld+json">
    {
        "@context": "https://schema.org",
        "@type": "WebSite",
        "name": "Nachrichten Kaipfstr",
        "description": "Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen",
        "url": "https://nachrichten.kaipfstr.de",
        "publisher": {
            "@type": "Organization",
            "name": "Nachrichten Kaipfstr",
            "url": "https://nachrichten.kaipfstr.de",
            "logo": "https://nachrichten.kaipfstr.de/assets/logo.png"
        },
        "potentialAction": {
            "@type": "SearchAction",
            "target": "https://nachrichten.kaipfstr.de/search?q={search_term_string}",
            "query-input": "required name=search_term_string"
        }
    }
    </script>
    
    <!-- BREADCRUMB SCHEMA FOR NAVIGATION INDEXING -->
    <script type="application/ld+json">
    {
        "@context": "https://schema.org",
        "@type": "BreadcrumbList",
        "itemListElement": [
            {
                "@type": "ListItem",
                "position": 1,
                "name": "Startseite",
                "item": "https://nachrichten.kaipfstr.de"
            },
            {
                "@type": "ListItem",
                "position": 2,
                "name": "Aktuelle Nachrichten",
                "item": "https://nachrichten.kaipfstr.de/nachrichten"
            },
            {
                "@type": "ListItem",
                "position": 3,
                "name": "Politik",
                "item": "https://nachrichten.kaipfstr.de/politik"
            }
        ]
    }
    </script>
    
    <!-- ORGANIZATION SCHEMA FOR INDEXING -->
    <script type="application/ld+json">
    {
        "@context": "https://schema.org",
        "@type": "NewsMediaOrganization",
        "name": "Nachrichten Kaipfstr",
        "url": "https://nachrichten.kaipfstr.de",
        "logo": "https://nachrichten.kaipfstr.de/assets/logo.png",
        "sameAs": [
            "https://www.twitter.com/@kaipfstr_news",
            "https://www.facebook.com/kaipfstr"
        ],
        "address": {
            "@type": "PostalAddress",
            "addressCountry": "DE"
        },
        "award": "Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen",
        "founder": "Kaipfstr Redaktion",
        "foundingDate": "2026-01-01",
        "telephone": "+49-XXX-XXXXXX",
        "email": "info@nachrichten.kaipfstr.de"
    }
    </script>

    <!-- PERFORMANCE & SEO BOOSTS -->
    <!-- PERFORMANCE-BUGFIX: zweiter ungenutzter as="fetch"-Preload entfernt
         (siehe Erläuterung oben) – nur DNS-Prefetch beibehalten. -->
    <link rel="dns-prefetch" href="//api.rss2json.com">
    <link rel="dns-prefetch" href="//api.allorigins.win">
    <link rel="dns-prefetch" href="//www.tagesschau.de">
    <link rel="dns-prefetch" href="//meinungsmache.de">
    <link rel="dns-prefetch" href="//www.deutschlandfunk.de">
    <link rel="dns-prefetch" href="//www.bundesregierung.de">
    <link rel="dns-prefetch" href="//www.spiegel.de">
    <link rel="dns-prefetch" href="//www.faz.net">
    <link rel="dns-prefetch" href="//www.zeit.de">
    <meta name="format-detection" content="telephone=no">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-title" content="Nachrichten Kaipfstr">
    <meta name="application-name" content="Nachrichten Kaipfstr">
    <!-- BUGFIX: doppeltes data:-Manifest entfernt (CSP-blockiert & redundant).
         Es gibt bereits genau EIN gültiges Manifest weiter oben. -->
    <link rel="preconnect" href="https://api.codetabs.com" crossorigin>

    <style>
        /* CORE VARIABLES */
        :root {
            --bg-deep: #050505;
            --bg-surface: #121212;
            --bg-elevated: #1e1e1e;
            --text-primary: #ededed;
            --text-secondary: #a0a0a0;
            --accent-primary: #00e5ff; /* Cyan Neon */
            --accent-secondary: #7c4dff; /* Deep Purple */
            --accent-alert: #ff2a6d; /* Radium Red */
            --border-color: #333333;
            --shadow-subtle: 0 4px 6px rgba(0,0,0,0.3);
            --shadow-heavy: 0 10px 30px rgba(0,0,0,0.5);
            --font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            --transition-speed: 0.3s;
        }

        /* RESET & BASE */
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; outline: none; }
        
        html { scroll-behavior: smooth; }
        
        body {
            font-family: var(--font-main);
            background-color: var(--bg-deep);
            color: var(--text-primary);
            line-height: 1.6;
            overflow-x: hidden;
            -webkit-font-smoothing: antialiased;
            /* Barrierefreiheit & SEO: Text MUSS markierbar/kopierbar sein.
               Die frühere "user-select: none"-Sperre hat Screenreader,
               Zitierbarkeit und Nutzerfreundlichkeit verschlechtert. */
            -webkit-user-select: text;
            -moz-user-select: text;
            -ms-user-select: text;
            user-select: text;
        }

        /* SKIP-LINK für Tastatur- & Screenreader-Nutzer (Barrierefreiheit) */
        .skip-link {
            position: absolute;
            left: -9999px;
            top: 0;
            background: var(--accent-primary);
            color: #000;
            padding: 10px 18px;
            z-index: 4000;
            border-radius: 0 0 8px 0;
            font-weight: 700;
            text-decoration: none;
        }
        .skip-link:focus {
            left: 0;
        }

        /* Sichtbarer Fokus-Ring für Tastaturnavigation (WCAG 2.4.7) */
        a:focus-visible,
        button:focus-visible,
        input:focus-visible,
        [tabindex]:focus-visible {
            outline: 3px solid var(--accent-primary);
            outline-offset: 2px;
            border-radius: 4px;
        }

        /* SCROLLBAR */
        ::-webkit-scrollbar { width: 8px; }
        ::-webkit-scrollbar-track { background: var(--bg-deep); }
        ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
        ::-webkit-scrollbar-thumb:hover { background: var(--accent-secondary); }

        /* HEADER & TICKER */
        .ticker-wrap {
            position: fixed;
            top: 0;
            width: 100%;
            height: 30px;
            background: var(--accent-secondary);
            color: #fff;
            overflow: hidden;
            z-index: 2000;
            line-height: 30px;
            font-size: 0.85rem;
            font-weight: bold;
        }

        .ticker {
            display: inline-block;
            white-space: nowrap;
            padding-left: 100%;
            animation: ticker 30s linear infinite;
        }

        @keyframes ticker {
            0% { transform: translate3d(0, 0, 0); }
            100% { transform: translate3d(-100%, 0, 0); }
        }

        header {
            background: rgba(18, 18, 18, 0.95);
            backdrop-filter: blur(10px);
            padding: 3rem 5% 1.5rem; /* Top padding for ticker */
            border-bottom: 1px solid var(--border-color);
            position: sticky;
            top: 0;
            z-index: 1000;
            display: flex;
            flex-direction: column;
            gap: 1rem;
        }

        .header-top {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .logo-area .brand-title {
            font-size: 1.8rem;
            background: linear-gradient(90deg, var(--text-primary), var(--accent-primary));
            background-clip: text;
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            letter-spacing: -1px;
        }

        .system-status {
            font-size: 0.75rem;
            font-family: monospace;
            color: var(--accent-primary);
            border: 1px solid var(--accent-primary);
            padding: 2px 8px;
            border-radius: 4px;
            animation: pulse 2s infinite;
        }

        /* SEARCH & FILTER */
        .controls-area {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            align-items: center;
            justify-content: space-between;
        }

        .search-box {
            position: relative;
            flex-grow: 1;
            max-width: 400px;
        }

        .search-box input {
            width: 100%;
            background: var(--bg-deep);
            border: 1px solid var(--border-color);
            padding: 10px 15px;
            border-radius: 8px;
            color: var(--text-primary);
            font-size: 0.9rem;
            transition: var(--transition-speed);
        }

        .search-box input:focus {
            border-color: var(--accent-primary);
            box-shadow: 0 0 10px rgba(0, 229, 255, 0.2);
        }

        .filters {
            display: flex;
            gap: 10px;
        }

        .filter-btn {
            background: var(--bg-elevated);
            border: 1px solid var(--border-color);
            color: var(--text-secondary);
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 0.85rem;
            cursor: pointer;
            transition: all 0.2s;
            font-weight: 500;
        }

        .filter-btn:hover, .filter-btn.active {
            background: var(--accent-secondary);
            color: #fff;
            border-color: var(--accent-secondary);
            transform: translateY(-2px);
        }

        /* MAIN GRID */
        main {
            padding: 2rem 5%;
            max-width: 1400px;
            margin: 0 auto;
            min-height: 80vh;
        }

        .news-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
            gap: 25px;
        }

        /* NEWS CARD */
        .card {
            background: var(--bg-surface);
            border: 1px solid var(--border-color);
            border-radius: 12px;
            overflow: hidden;
            display: flex;
            flex-direction: column;
            position: relative;
            transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
        }

        .card:hover {
            transform: translateY(-8px) scale(1.01);
            box-shadow: var(--shadow-heavy);
            border-color: var(--accent-primary);
            z-index: 10;
        }

        .card-header {
            padding: 1.5rem 1.5rem 0.5rem;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .source-badge {
            font-size: 0.7rem;
            text-transform: uppercase;
            font-weight: 800;
            padding: 4px 8px;
            border-radius: 4px;
            letter-spacing: 0.5px;
        }

        .badge-tagesschau { background: rgba(3, 218, 198, 0.15); color: #03dac6; }
        .badge-politik { background: rgba(187, 134, 252, 0.15); color: #bb86fc; }
        .badge-meinungen { background: rgba(207, 102, 121, 0.15); color: #cf6679; }
        .badge-Deutschlandfunk { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
        .badge-Bundesregierung { background: rgba(76, 175, 80, 0.15); color: #4caf50; }
        .badge-spiegel { background: rgba(33, 150, 243, 0.15); color: #2196f3; }
        .badge-faz { background: rgba(244, 67, 54, 0.15); color: #f44336; }
        .badge-zeit { background: rgba(156, 39, 176, 0.15); color: #9c27b0; }
        /* Badges für die neu hinzugefügten, geprüften Quellen */
        .badge-zdf { background: rgba(255, 111, 0, 0.15); color: #ff6f00; }
        .badge-dw { background: rgba(0, 168, 255, 0.15); color: #00a8ff; }
        .badge-sueddeutsche { background: rgba(233, 30, 99, 0.15); color: #e91e63; }
        .badge-tagesspiegel { background: rgba(255, 87, 34, 0.15); color: #ff5722; }
        .badge-heise { background: rgba(0, 150, 136, 0.15); color: #009688; }
        .badge-fragdenstaat { background: rgba(255, 214, 0, 0.15); color: #ffd600; }
        .badge-wirtschaft { background: rgba(129, 199, 132, 0.15); color: #81c784; }
        .badge-ausland { background: rgba(100, 181, 246, 0.15); color: #64b5f6; }
        .badge-taz { background: rgba(239, 83, 80, 0.15); color: #ef5350; }
        .badge-nzz { background: rgba(161, 136, 127, 0.18); color: #bcaaa4; }
        .badge-Meinungsmache { background: rgba(120, 144, 156, 0.18); color: #90a4ae; }
        /* Fallback-Badge für unbekannte/neue Kategorien (Kompatibilität) */
        .source-badge:not([class*="badge-"]) { background: rgba(160,160,160,0.15); color: var(--text-secondary); }

        .time-stamp {
            font-size: 0.75rem;
            color: var(--text-secondary);
        }

        .card-body {
            padding: 1rem 1.5rem;
            flex-grow: 1;
        }

        .card-title {
            font-size: 1.25rem;
            font-weight: 700;
            margin-bottom: 0.75rem;
            line-height: 1.3;
        }

        .card-excerpt {
            font-size: 0.95rem;
            color: var(--text-secondary);
            display: -webkit-box;
            line-clamp: 4;
            -webkit-line-clamp: 4;
            -webkit-box-orient: vertical;
            overflow: hidden;
        }

        .card-footer {
            padding: 1rem 1.5rem;
            background: rgba(255,255,255,0.02);
            border-top: 1px solid var(--border-color);
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .read-btn {
            text-decoration: none;
            color: var(--text-primary);
            font-size: 0.9rem;
            font-weight: 600;
            display: flex;
            align-items: center;
            gap: 5px;
            transition: color 0.2s;
        }

        .read-btn:hover { color: var(--accent-primary); }

        /* MODAL OVERLAY */
        .modal-overlay {
            position: fixed;
            top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.85);
            backdrop-filter: blur(5px);
            z-index: 3000;
            display: flex;
            justify-content: center;
            align-items: center;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s;
        }

        .modal-overlay.open { opacity: 1; pointer-events: all; }

        .modal-content {
            background: var(--bg-surface);
            width: 90%;
            max-width: 800px;
            max-height: 90vh;
            overflow-y: auto;
            border-radius: 12px;
            border: 1px solid var(--accent-primary);
            padding: 2rem;
            position: relative;
            transform: scale(0.9);
            transition: transform 0.3s;
        }

        .modal-overlay.open .modal-content { transform: scale(1); }

        .close-modal {
            position: absolute;
            top: 1rem;
            right: 1rem;
            font-size: 2rem;
            cursor: pointer;
            color: var(--text-secondary);
        }

        /* LOADING STATE */
        .loader-container {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 4rem;
            text-align: center;
        }

        .spinner {
            width: 50px;
            height: 50px;
            border: 3px solid rgba(255,255,255,0.1);
            border-radius: 50%;
            border-top-color: var(--accent-primary);
            animation: spin 1s ease-in-out infinite;
            margin-bottom: 1rem;
        }

        @keyframes spin { to { transform: rotate(360deg); } }
        @keyframes pulse { 0% { opacity: 0.5; } 50% { opacity: 1; } 100% { opacity: 0.5; } }

        /* FOOTER */
        footer {
            background: var(--bg-elevated);
            padding: 3rem 5%;
            text-align: center;
            border-top: 1px solid var(--border-color);
            margin-top: 4rem;
        }

        .security-seal {
            margin-top: 1rem;
            display: inline-block;
            border: 1px solid #333;
            padding: 10px;
            font-size: 0.7rem;
            color: #555;
            font-family: monospace;
        }

        /* ERROR TOAST */
        .error-toast {
            position: fixed;
            bottom: 20px;
            right: 20px;
            background: #cf6679;
            color: #000;
            padding: 1rem;
            border-radius: 8px;
            z-index: 5000;
            display: none;
            box-shadow: 0 5px 15px rgba(0,0,0,0.5);
        }

        @media (max-width: 768px) {
            header { padding-top: 3.5rem; }
            .controls-area { flex-direction: column; align-items: stretch; }
            .filters { overflow-x: auto; padding-bottom: 10px; }
            .modal-content { width: 95%; padding: 1.5rem; }
            .card { margin: 0 10px; }
            .news-grid { grid-template-columns: 1fr; gap: 15px; }
            .card-title { font-size: 1.1rem; }
            .extra-controls { flex-direction: column; gap: 5px; }
            .filter-btn { padding: 6px 12px; font-size: 0.8rem; }
            .search-box input { font-size: 16px; } /* Prevent zoom on iOS */
            .ticker { font-size: 0.7rem; }
            .logo-area .brand-title { font-size: 1.4rem; }
            .system-status { font-size: 0.6rem; }
        }

        @media (max-width: 480px) {
            main { padding: 1rem 2%; }
            header { padding: 2.5rem 2% 1rem; }
            .card-header { padding: 1rem 1rem 0.25rem; }
            .card-body { padding: 0.75rem 1rem; }
            .card-footer { padding: 0.75rem 1rem; }
            .modal-content { width: 98%; padding: 1rem; }
            .single-news { padding: 1rem; }
            .single-news h1 { font-size: 1.5rem; }
        }

        /* NEUE ERGÄNZUNGEN: MEHR FEATURES */
        .extra-controls {
            display: flex;
            gap: 10px;
            align-items: center;
            margin-top: 10px;
        }

        .export-btn, .refresh-btn {
            background: var(--bg-elevated);
            border: 1px solid var(--border-color);
            color: var(--text-secondary);
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 0.85rem;
            cursor: pointer;
            transition: all 0.2s;
            font-weight: 500;
        }

        .export-btn:hover, .refresh-btn:hover {
            background: var(--accent-primary);
            color: #000;
            border-color: var(--accent-primary);
            transform: translateY(-2px);
        }

        .progress-bar {
            width: 100%;
            height: 4px;
            background: var(--bg-deep);
            border-radius: 2px;
            overflow: hidden;
            margin-top: 10px;
        }

        .progress-fill {
            height: 100%;
            background: var(--accent-primary);
            width: 0%;
            transition: width 0.3s;
        }

        /* MOBILE OPTIMIZATIONS */
        .card {
            /* Touch-friendly */
            min-height: 200px;
            cursor: pointer;
        }

        .card:active {
            transform: scale(0.98);
        }

        .filter-btn, .export-btn, .refresh-btn {
            min-height: 44px; /* iOS touch target */
            min-width: 44px;
        }

        .search-box input {
            min-height: 44px;
        }

        /* BUGFIX: Text in Karten bleibt markierbar/kopierbar (SEO & A11y).
           Vorher verhinderte ".card *" jegliche Textauswahl. */
        .card *, .read-btn, .search-box input, .filter-btn, .export-btn, .refresh-btn {
            -webkit-user-select: text;
            -moz-user-select: text;
            -ms-user-select: text;
            user-select: text;
        }

        /* Sanftes Einblenden neuer Karten (Lazy-Reveal) */
        .card {
            opacity: 1;
            animation: cardFadeIn 0.5s ease both;
        }
        @keyframes cardFadeIn {
            from { opacity: 0; transform: translateY(12px); }
            to { opacity: 1; transform: translateY(0); }
        }

        /* Favoriten-Stern & Teilen-Buttons in der Kartenfußzeile */
        .card-actions {
            display: flex;
            gap: 12px;
            align-items: center;
        }
        .icon-btn {
            background: none;
            border: none;
            color: var(--text-secondary);
            cursor: pointer;
            font-size: 1.1rem;
            line-height: 1;
            padding: 4px;
            border-radius: 6px;
            transition: color 0.2s, transform 0.2s;
        }
        .icon-btn:hover { color: var(--accent-primary); transform: scale(1.15); }
        .icon-btn.favorited { color: var(--accent-alert); }
        
        /* ENHANCED MOBILE OPTIMIZATIONS FOR ALL SCREEN SIZES */
        @media screen and (max-width: 2560px) {
            /* 4K and ultra-wide screens */
            .news-grid { grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); }
            main { max-width: 1920px; }
        }
        
        @media screen and (max-width: 1920px) {
            /* Full HD and above */
            .news-grid { grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); }
        }
        
        @media screen and (max-width: 1440px) {
            /* Large desktop */
            .news-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
            header { padding: 2.5rem 4%; }
        }
        
        @media screen and (max-width: 1024px) {
            /* Tablet landscape and small desktop */
            .news-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 15px; }
            header { padding: 2rem 3%; }
            .controls-area { flex-direction: column; gap: 10px; }
            .search-box { max-width: 100%; }
            .card-title { font-size: 1.1rem; }
            .card-excerpt { font-size: 0.9rem; }
            .filter-btn { padding: 6px 12px; font-size: 0.8rem; }
        }
        
        @media screen and (max-width: 768px) {
            /* Tablet and large mobile */
            header { padding: 2rem 3% 1rem; margin-top: 30px; }
            .controls-area { flex-direction: column; align-items: stretch; gap: 8px; }
            .filters { flex-wrap: wrap; justify-content: center; }
            .modal-content { width: 95%; padding: 1.5rem; max-height: 85vh; }
            .card { margin: 5px; border-radius: 10px; }
            .news-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; padding: 1rem 2%; }
            .card-title { font-size: 1rem; }
            .card-header { padding: 1rem 1rem 0.5rem; }
            .card-body { padding: 0.75rem 1rem; }
            .card-footer { padding: 0.75rem 1rem; }
            .extra-controls { flex-direction: row; gap: 8px; flex-wrap: wrap; }
            .filter-btn { padding: 6px 10px; font-size: 0.75rem; min-width: auto; }
            .export-btn, .refresh-btn { padding: 6px 10px; font-size: 0.75rem; }
            .search-box input { font-size: 16px; padding: 12px 12px; height: auto; }
            .ticker { font-size: 0.7rem; }
            .logo-area .brand-title { font-size: 1.3rem; }
            .system-status { font-size: 0.65rem; }
            main { padding: 1rem 2%; min-height: 70vh; }
        }
        
        @media screen and (max-width: 600px) {
            /* Mobile phones */
            header { padding: 1.5rem 2% 0.75rem; }
            .header-top { flex-direction: column; gap: 0.5rem; align-items: flex-start; }
            .logo-area .brand-title { font-size: 1.2rem; }
            .system-status { font-size: 0.6rem; }
            .controls-area { padding: 0.75rem 0; flex-direction: column; gap: 6px; }
            .search-box { width: 100%; max-width: none; margin-bottom: 0.5rem; }
            .search-box input { font-size: 16px; height: 44px; padding: 10px 12px; }
            .filters { justify-content: flex-start; overflow-x: auto; gap: 6px; padding-bottom: 8px; }
            .filter-btn { padding: 6px 10px; font-size: 0.7rem; white-space: nowrap; }
            .extra-controls { gap: 6px; }
            .export-btn, .refresh-btn { padding: 6px 8px; font-size: 0.7rem; }
            
            /* CARD OPTIMIZATION FOR SMALL SCREENS */
            .news-grid { grid-template-columns: 1fr; gap: 10px; padding: 0.75rem 1%; }
            .card { margin: 0; border-radius: 8px; touch-action: manipulation; }
            .card:hover { transform: translateY(-4px) scale(1.005); }
            .card:active { transform: scale(0.98); }
            .card-header { padding: 0.75rem 0.75rem 0.25rem; }
            .card-body { padding: 0.5rem 0.75rem; }
            .card-footer { padding: 0.5rem 0.75rem; gap: 8px; }
            .card-title { font-size: 0.95rem; font-weight: 600; line-height: 1.2; margin-bottom: 0.5rem; }
            .card-excerpt { font-size: 0.85rem; line-clamp: 2; -webkit-line-clamp: 2; }
            
            /* MODAL ON MOBILE */
            .modal-overlay { background: rgba(0,0,0,0.9); }
            .modal-content { width: 98%; max-height: 95vh; padding: 1rem; border-radius: 8px; }
            .close-modal { top: 0.75rem; right: 0.75rem; font-size: 1.5rem; }
            
            /* BUTTONS & INPUTS */
            .read-btn { font-size: 0.8rem; padding: 6px 8px; }
            .ticker { font-size: 0.65rem; }
            main { padding: 0.5rem 1%; min-height: 60vh; }
            
            /* ACCESSIBILITY ENHANCEMENTS FOR MOBILE */
            * { touch-action: manipulation; }
            button, a, input { min-height: 44px; min-width: 44px; }
            .filter-btn { min-height: 40px; min-width: 40px; }
        }
        
        @media screen and (max-width: 480px) {
            /* Small mobile phones */
            .ticker-wrap { height: 25px; line-height: 25px; }
            .ticker { font-size: 0.6rem; }
            header { padding: 1.25rem 1.5% 0.5rem; margin-top: 25px; }
            .logo-area .brand-title { font-size: 1rem; letter-spacing: -0.5px; }
            .system-status { font-size: 0.55rem; padding: 1px 6px; }
            .controls-area { padding: 0.5rem 0; gap: 4px; }
            .search-box input { font-size: 16px; height: 42px; padding: 8px 10px; }
            .filters { gap: 4px; }
            .filter-btn { padding: 5px 8px; font-size: 0.65rem; }
            .extra-controls { gap: 4px; }
            .export-btn, .refresh-btn { padding: 5px 6px; font-size: 0.65rem; }
            
            /* RESPONSIVE CARD LAYOUT */
            .news-grid { grid-template-columns: 1fr; gap: 8px; padding: 0.5rem 1%; }
            .card { margin: 0; border-radius: 6px; }
            .card-header { padding: 0.5rem 0.5rem 0.15rem; }
            .card-body { padding: 0.4rem 0.5rem; }
            .card-footer { padding: 0.4rem 0.5rem; gap: 6px; }
            .card-title { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.35rem; }
            .source-badge { font-size: 0.6rem; padding: 3px 6px; }
            .time-stamp { font-size: 0.65rem; }
            .card-excerpt { font-size: 0.8rem; line-clamp: 2; -webkit-line-clamp: 2; }
            
            /* MODAL OPTIMIZATION */
            .modal-content { width: 96%; padding: 0.8rem; }
            .close-modal { top: 0.5rem; right: 0.5rem; font-size: 1.25rem; }
            
            main { padding: 0.35rem 0.5%; min-height: 50vh; }
            footer { padding: 1.5rem 2%; }
        }
        
        @media screen and (max-width: 360px) {
            /* Extra small phones (e.g., iPhone SE) */
            .ticker-wrap { height: 22px; }
            header { padding: 1rem 1% 0.4rem; margin-top: 22px; }
            .logo-area .brand-title { font-size: 0.95rem; }
            .system-status { font-size: 0.5rem; padding: 1px 4px; }
            .search-box input { font-size: 16px; height: 40px; padding: 7px 8px; }
            .filter-btn { padding: 4px 6px; font-size: 0.6rem; }
            .card-title { font-size: 0.85rem; }
            .card-excerpt { font-size: 0.75rem; }
            .card-header { padding: 0.4rem 0.4rem 0.1rem; }
            .card-body { padding: 0.3rem 0.4rem; }
            .card-footer { padding: 0.3rem 0.4rem; }
        }
        
        /* ACCESSIBILITY & USABILITY ENHANCEMENTS */
        @media (prefers-reduced-motion: reduce) {
            * {
                animation-duration: 0.01ms !important;
                animation-iteration-count: 1 !important;
                transition-duration: 0.01ms !important;
            }
        }
        
        @media (prefers-color-scheme: dark) {
            body { background-color: var(--bg-deep); color: var(--text-primary); }
        }
        
        @media (prefers-color-scheme: light) {
            body { background-color: #f5f5f5; color: #1a1a1a; }
            .card { background: #fff; }
            .modal-content { background: #fff; }
        }
        
        /* TOUCH DEVICE OPTIMIZATION */
        @media (hover: none) and (pointer: coarse) {
            .card { cursor: pointer; }
            .card:hover { transform: translateY(-2px); }
            .filter-btn, .export-btn, .refresh-btn { cursor: pointer; }
        }
        
        /* HIGH DPI SCREENS (Retina) */
        @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
            body { -webkit-font-smoothing: subpixel-antialiased; }
        }
        
        /* LANDSCAPE ORIENTATION ADJUSTMENTS -->
        @media screen and (orientation: landscape) and (max-height: 500px) {
            .ticker-wrap { height: 20px; }
            header { padding: 0.75rem 2% 0.5rem; }
            .card-title { font-size: 0.9rem; }
            .card-excerpt { display: none; }
            main { min-height: auto; padding: 0.75rem 2%; }
        }
        
        /* PRINT STYLES FOR BETTER ARCHIVING */
        @media print {
            body { background: white; color: black; }
            .ticker-wrap, header, .controls-area, footer, .extra-controls, .back-to-top { display: none; }
            .news-grid { display: block; }
            .card { break-inside: avoid; page-break-inside: avoid; }
            a { text-decoration: underline; }
        }

        /* =====================================================================
           NEUE KOMPONENTEN-STILE (Bugfix-Ausbau: Favoriten, Sortierung,
           Lesezeit, Bilder, Nach-oben-Button, Tastenkürzel, Statuszeile)
           ===================================================================== */

        /* Nur für Screenreader sichtbar (visuell versteckt, aber vorlesbar) */
        .visually-hidden {
            position: absolute !important;
            width: 1px; height: 1px;
            padding: 0; margin: -1px;
            overflow: hidden;
            clip: rect(0, 0, 0, 0);
            white-space: nowrap;
            border: 0;
        }

        /* Statuszeile (Trefferanzahl + letzte Aktualisierung) */
        .status-row {
            display: flex;
            gap: 18px;
            flex-wrap: wrap;
            width: 100%;
            margin-top: 4px;
        }
        .meta-info {
            font-size: 0.75rem;
            color: var(--text-secondary);
            font-family: monospace;
        }

        /* Zähler-Badge an den Filter-Buttons */
        .count-badge {
            display: inline-block;
            margin-left: 6px;
            padding: 1px 7px;
            font-size: 0.65rem;
            font-weight: 700;
            border-radius: 10px;
            background: rgba(0, 229, 255, 0.15);
            color: var(--accent-primary);
            vertical-align: middle;
        }
        .filter-btn.active .count-badge {
            background: rgba(255, 255, 255, 0.25);
            color: #fff;
        }

        /* Vorschaubild in der Karte */
        .card-image {
            width: 100%;
            /* Fallback für Browser ohne aspect-ratio (älteres Safari/Firefox):
               feste Höhe, damit das Bild nicht auf 0px kollabiert. */
            height: 190px;
            aspect-ratio: 16 / 9;
            overflow: hidden;
            background: var(--bg-elevated);
        }
        /* Moderne Browser: feste Höhe wieder aufheben, aspect-ratio übernimmt */
        @supports (aspect-ratio: 16 / 9) {
            .card-image { height: auto; }
        }
        .card-image img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            display: block;
            transition: transform 0.4s ease;
        }
        .card:hover .card-image img { transform: scale(1.05); }

        /* Lesezeit-Anzeige */
        .reading-time {
            display: inline-block;
            margin-top: 8px;
            font-size: 0.72rem;
            color: var(--text-secondary);
            font-family: monospace;
        }

        /* Sortier-Dropdown wie ein Refresh-Button gestalten */
        select.refresh-btn {
            appearance: none;
            -webkit-appearance: none;
            background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
                              linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
            background-position: calc(100% - 15px) center, calc(100% - 10px) center;
            background-size: 5px 5px, 5px 5px;
            background-repeat: no-repeat;
            padding-right: 32px;
        }

        /* Nach-oben-Button */
        .back-to-top {
            position: fixed;
            bottom: 24px;
            right: 24px;
            width: 48px;
            height: 48px;
            border-radius: 50%;
            border: none;
            background: var(--accent-secondary);
            color: #fff;
            font-size: 1.4rem;
            cursor: pointer;
            box-shadow: var(--shadow-heavy);
            opacity: 0;
            pointer-events: none;
            transform: translateY(20px);
            transition: opacity 0.3s, transform 0.3s, background 0.2s;
            z-index: 2500;
        }
        .back-to-top.visible {
            opacity: 1;
            pointer-events: all;
            transform: translateY(0);
        }
        .back-to-top:hover { background: var(--accent-primary); color: #000; }

        /* Tastenkürzel-Anzeige */
        kbd {
            background: var(--bg-elevated);
            border: 1px solid var(--border-color);
            border-bottom-width: 2px;
            border-radius: 4px;
            padding: 2px 7px;
            font-family: monospace;
            font-size: 0.8rem;
            color: var(--text-primary);
        }

        /* Footer-Navigation */
        .footer-nav {
            display: flex;
            gap: 20px;
            justify-content: center;
            flex-wrap: wrap;
            margin: 16px 0;
        }
        .footer-nav a {
            color: var(--text-secondary);
            text-decoration: none;
            font-size: 0.85rem;
            transition: color 0.2s;
        }
        .footer-nav a:hover { color: var(--accent-primary); }

        /* Helles Design (per Umschalter überschreibbar via body.theme-light) */
        body.theme-light {
            background-color: #f4f5f7;
            color: #1a1a1a;
        }
        body.theme-light header { background: rgba(255,255,255,0.95); }
        body.theme-light .card,
        body.theme-light .modal-content { background: #ffffff; border-color: #e0e0e0; }
        body.theme-light .card-excerpt,
        body.theme-light .time-stamp,
        body.theme-light .meta-info,
        body.theme-light .reading-time { color: #555; }
        body.theme-light .search-box input { background: #fff; color: #1a1a1a; }
        body.theme-light .logo-area .brand-title {
            background: linear-gradient(90deg, #111, var(--accent-secondary));
            background-clip: text;
            -webkit-background-clip: text;
        }
        body.theme-light footer { background: #eceff1; }

        /* Toast-Varianten für Erfolg/Info */
        .error-toast.success { background: #4caf50; color: #fff; }
        .error-toast.info { background: var(--accent-secondary); color: #fff; }

        /* Chronologische Datums-Trennüberschrift (spannt die ganze Grid-Breite) */
        .date-group {
            grid-column: 1 / -1;
            margin: 10px 0 2px;
            padding: 8px 14px;
            font-size: 0.95rem;
            font-weight: 800;
            letter-spacing: 0.3px;
            color: var(--accent-primary);
            text-transform: uppercase;
            border-left: 4px solid var(--accent-primary);
            background: linear-gradient(90deg, rgba(0,229,255,0.08), transparent);
            border-radius: 0 8px 8px 0;
            position: sticky;
            top: 0;
            z-index: 5;
        }
        body.theme-light .date-group { color: var(--accent-secondary); border-left-color: var(--accent-secondary); }

        /* Skeleton-Ladekarten (verbessert die WAHRGENOMMENE Ladezeit) */
        .skeleton-card {
            background: var(--bg-surface);
            border: 1px solid var(--border-color);
            border-radius: 12px;
            overflow: hidden;
            min-height: 260px;
            padding: 1.5rem;
        }
        .skeleton-line {
            height: 14px;
            border-radius: 6px;
            margin-bottom: 12px;
            background: linear-gradient(90deg, #1c1c1c 25%, #2a2a2a 37%, #1c1c1c 63%);
            background-size: 400% 100%;
            animation: skeletonShimmer 1.4s ease infinite;
        }
        .skeleton-line.short { width: 40%; }
        .skeleton-line.title { height: 22px; width: 85%; }
        .skeleton-line.full { width: 100%; }
        @keyframes skeletonShimmer {
            0% { background-position: 100% 50%; }
            100% { background-position: 0 50%; }
        }
        body.theme-light .skeleton-line {
            background: linear-gradient(90deg, #e8e8e8 25%, #f2f2f2 37%, #e8e8e8 63%);
            background-size: 400% 100%;
        }

        /* Sicherstellen, dass Bilder/Medien nie über den Rand hinauslaufen (Mobil) */
        img, video, iframe, table { max-width: 100%; height: auto; }
        .modal-content img { max-width: 100%; }

        /* Verhindert horizontales Scrollen auf schmalen Geräten */
        html, body { max-width: 100%; overflow-x: hidden; }

        /* Lange Wörter/URLs auf Mobilgeräten sauber umbrechen */
        .card-title, .card-excerpt, #modal-body, .single-news {
            overflow-wrap: break-word;
            word-wrap: break-word;
            -webkit-hyphens: auto;
            hyphens: auto;
        }

        /* SEO-Intro-Sektion (immer sichtbar, faktisch, indexierbar) */
        .intro-section {
            max-width: 1000px;
            margin: 0 auto 1.5rem;
            padding: 1.25rem 1.5rem;
            background: linear-gradient(135deg, rgba(124,77,255,0.08), rgba(0,229,255,0.05));
            border: 1px solid var(--border-color);
            border-radius: 12px;
        }
        .intro-h1 {
            font-size: 1.4rem;
            line-height: 1.3;
            margin-bottom: 0.6rem;
            background: linear-gradient(90deg, var(--text-primary), var(--accent-primary));
            background-clip: text;
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .intro-section p {
            font-size: 0.92rem;
            color: var(--text-secondary);
        }
        .intro-section strong { color: var(--text-primary); font-weight: 600; }
        body.theme-light .intro-section p { color: #444; }
        body.theme-light .intro-section strong { color: #111; }
        @media (max-width: 600px) {
            .intro-section { padding: 1rem; margin-bottom: 1rem; }
            .intro-h1 { font-size: 1.1rem; }
            .intro-section p { font-size: 0.85rem; }
        }

        /* NOSCRIPT-Fallback-Box */
        .noscript-box {
            max-width: 800px;
            margin: 1rem auto;
            padding: 1.5rem;
            background: var(--bg-surface);
            border: 1px solid var(--accent-alert);
            border-radius: 12px;
            color: var(--text-primary);
        }
        .noscript-box h2 { margin-bottom: 0.75rem; color: var(--accent-alert); }
        .noscript-box ul { margin: 0.75rem 0 0 1.25rem; line-height: 1.9; }
        .noscript-box a { color: var(--accent-primary); }

        /* „Mehr anzeigen“-Bereich (Paginierung / Infinite Scroll) */
        .load-more-wrap {
            display: flex;
            justify-content: center;
            padding: 1.5rem 0 2.5rem;
        }
        .load-more-btn {
            background: var(--bg-elevated);
            border: 1px solid var(--accent-primary);
            color: var(--accent-primary);
            padding: 12px 28px;
            border-radius: 24px;
            font-size: 0.95rem;
            font-weight: 700;
            cursor: pointer;
            transition: all 0.2s;
            min-height: 44px;
        }
        .load-more-btn:hover {
            background: var(--accent-primary);
            color: #001014;
            transform: translateY(-2px);
        }

        /* Such-Treffer-Hervorhebung (Highlight der gesuchten Begriffe) */
        mark.search-hit {
            background: var(--accent-primary);
            color: #001014;
            padding: 0 2px;
            border-radius: 3px;
            font-weight: 700;
        }
        body.theme-light mark.search-hit { background: #ffe27a; color: #111; }

        /* =====================================================================
           ============  DESIGN 2.0 – KOMPLETTES REDESIGN / NEWSROOM  ==========
           Späterer CSS-Block => überschreibt gezielt die Grundstile oben und
           hebt die Optik auf ein professionelles, redaktionelles Niveau an.
           Ziele: hochwertige Typografie, klare Hierarchie, angenehmes Lesen
           (v. a. mobil), moderne Tiefen/Verläufe, Barrierefreiheit.
           ===================================================================== */

        :root {
            /* Verfeinerte, wärmere „Newsroom“-Palette mit mehr Tiefe */
            --bg-deep: #0a0d14;
            --bg-surface: #121722;
            --bg-elevated: #1a2130;
            --bg-glass: rgba(18, 23, 34, 0.72);
            --text-primary: #eef2f8;
            --text-secondary: #9aa6ba;
            --text-muted: #6b7688;
            --accent-primary: #38bdf8;   /* Sky */
            --accent-secondary: #a78bfa; /* Violet */
            --accent-alert: #fb7185;     /* Rose */
            --accent-warm: #fbbf24;      /* Amber */
            --accent-emerald: #34d399;   /* Emerald */
            --border-color: #263041;
            --border-strong: #34405a;
            --shadow-subtle: 0 2px 10px rgba(0,0,0,0.35);
            --shadow-heavy: 0 18px 50px -12px rgba(0,0,0,0.65);
            --shadow-glow: 0 0 0 1px rgba(56,189,248,0.15), 0 10px 40px -10px rgba(56,189,248,0.25);
            --font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            --font-display: 'Lora', Georgia, 'Times New Roman', serif;
            --radius-sm: 10px;
            --radius-md: 16px;
            --radius-lg: 22px;
            --gradient-brand: linear-gradient(120deg, #38bdf8 0%, #a78bfa 55%, #fb7185 100%);
            --gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0));
        }

        body {
            font-family: var(--font-main);
            background:
                radial-gradient(1200px 600px at 12% -10%, rgba(56,189,248,0.10), transparent 60%),
                radial-gradient(1000px 700px at 100% 0%, rgba(167,139,250,0.10), transparent 55%),
                var(--bg-deep);
            background-attachment: fixed;
            letter-spacing: 0.1px;
        }

        /* ---------- TICKER ---------- */
        .ticker-wrap {
            height: 34px;
            line-height: 34px;
            background: linear-gradient(90deg, #7c3aed, #db2777);
            font-weight: 700;
            letter-spacing: 0.3px;
            box-shadow: 0 2px 12px rgba(219,39,119,0.35);
            border-bottom: 1px solid rgba(255,255,255,0.08);
        }

        /* ---------- HEADER (Glas-Optik) ---------- */
        header {
            background: var(--bg-glass);
            backdrop-filter: saturate(160%) blur(14px);
            -webkit-backdrop-filter: saturate(160%) blur(14px);
            border-bottom: 1px solid var(--border-color);
            padding: 2.6rem 6% 1.25rem;
            gap: 1.15rem;
        }
        .logo-area { display: flex; align-items: center; gap: 12px; }
        .logo-area .brand-title {
            font-family: var(--font-display);
            font-weight: 700;
            font-size: 2rem;
            letter-spacing: -0.5px;
            background: var(--gradient-brand);
            background-clip: text;
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .logo-area .brand-title::before {
            content: "📡";
            -webkit-text-fill-color: initial;
            margin-right: 8px;
            font-size: 1.5rem;
        }
        .system-status {
            font-family: var(--font-main);
            font-weight: 600;
            border-radius: 999px;
            letter-spacing: 0.4px;
            background: rgba(52,211,153,0.10);
        }

        /* ---------- SUCHE & FILTER ---------- */
        .search-box { max-width: 460px; }
        .search-box::before {
            content: "🔍";
            position: absolute;
            left: 14px;
            top: 50%;
            transform: translateY(-50%);
            font-size: 0.9rem;
            opacity: 0.7;
            pointer-events: none;
        }
        .search-box input {
            border-radius: 999px;
            padding: 12px 18px 12px 40px;
            background: var(--bg-elevated);
            border: 1px solid var(--border-color);
            font-size: 0.95rem;
        }
        .search-box input:focus {
            border-color: var(--accent-primary);
            box-shadow: 0 0 0 4px rgba(56,189,248,0.15);
        }

        .filters { gap: 8px; flex-wrap: wrap; }
        .filter-btn {
            border-radius: 999px;
            border: 1px solid var(--border-color);
            background: var(--bg-elevated);
            font-weight: 600;
            padding: 8px 15px;
            transition: transform 0.15s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
        }
        .filter-btn:hover {
            transform: translateY(-2px);
            border-color: var(--accent-primary);
            background: rgba(56,189,248,0.12);
            color: var(--text-primary);
        }
        .filter-btn.active {
            background: var(--gradient-brand);
            border-color: transparent;
            color: #0a0d14;
            box-shadow: 0 6px 18px -6px rgba(56,189,248,0.5);
        }

        .export-btn, .refresh-btn, select.refresh-btn {
            border-radius: 999px;
            background: var(--bg-elevated);
            border: 1px solid var(--border-color);
            font-weight: 600;
        }
        .export-btn:hover, .refresh-btn:hover {
            background: rgba(167,139,250,0.15);
            border-color: var(--accent-secondary);
            color: var(--text-primary);
        }

        /* ---------- MAIN & GRID ---------- */
        main { padding: 2.25rem 6%; }
        .news-grid {
            grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
            gap: 28px;
        }

        /* ---------- KARTEN (Redesign) ---------- */
        .card {
            background:
                var(--gradient-surface),
                var(--bg-surface);
            border: 1px solid var(--border-color);
            border-radius: var(--radius-md);
            box-shadow: var(--shadow-subtle);
            transition: transform 0.35s cubic-bezier(0.2,0.7,0.2,1), box-shadow 0.35s, border-color 0.35s;
        }
        /* Farbiger Akzentstreifen oben pro Quelle */
        .card::before {
            content: "";
            position: absolute;
            top: 0; left: 0; right: 0;
            height: 3px;
            background: var(--gradient-brand);
            opacity: 0.9;
            z-index: 2;
        }
        .card:hover {
            transform: translateY(-6px);
            box-shadow: var(--shadow-heavy), var(--shadow-glow);
            border-color: var(--border-strong);
        }
        .card-title {
            font-family: var(--font-display);
            font-weight: 600;
            font-size: 1.28rem;
            line-height: 1.32;
            letter-spacing: -0.2px;
        }
        .card-excerpt {
            color: var(--text-secondary);
            font-size: 0.96rem;
            line-height: 1.6;
        }
        .source-badge {
            border-radius: 999px;
            font-weight: 800;
            letter-spacing: 0.6px;
            font-size: 0.66rem;
        }
        .time-stamp { font-variant-numeric: tabular-nums; }
        .card-footer {
            background: rgba(255,255,255,0.015);
            border-top: 1px solid var(--border-color);
        }
        .read-btn { font-weight: 600; }
        .reading-time {
            background: rgba(255,255,255,0.04);
            padding: 3px 9px;
            border-radius: 999px;
            border: 1px solid var(--border-color);
        }

        /* Gelesen-Zustand: dezent abgedunkelt + Häkchen */
        .card.is-read { opacity: 0.62; }
        .card.is-read:hover { opacity: 1; }
        .card.is-read .card-title::after {
            content: " ✓";
            color: var(--accent-emerald);
            font-family: var(--font-main);
        }

        /* ---------- HERO / AUFMACHER ---------- */
        .hero {
            position: relative;
            grid-column: 1 / -1;
            display: grid;
            grid-template-columns: 1.2fr 1fr;
            min-height: 340px;
            border-radius: var(--radius-lg);
            overflow: hidden;
            border: 1px solid var(--border-strong);
            background: var(--bg-surface);
            box-shadow: var(--shadow-heavy);
            margin-bottom: 6px;
            cursor: pointer;
        }
        .hero:hover { border-color: var(--accent-primary); }
        .hero-media {
            position: relative;
            background: var(--bg-elevated);
            overflow: hidden;
        }
        .hero-media img {
            width: 100%; height: 100%;
            object-fit: cover;
            transition: transform 0.6s ease;
        }
        .hero:hover .hero-media img { transform: scale(1.06); }
        .hero-media::after {
            content: "";
            position: absolute; inset: 0;
            background: linear-gradient(90deg, transparent 40%, rgba(18,23,34,0.85) 100%);
        }
        .hero-body {
            padding: 2rem 2.2rem;
            display: flex;
            flex-direction: column;
            justify-content: center;
            gap: 0.9rem;
        }
        .hero-kicker {
            display: inline-flex;
            align-items: center;
            gap: 8px;
            align-self: flex-start;
            font-size: 0.72rem;
            font-weight: 800;
            letter-spacing: 1px;
            text-transform: uppercase;
            color: #0a0d14;
            background: var(--gradient-brand);
            padding: 5px 12px;
            border-radius: 999px;
        }
        .hero-title {
            font-family: var(--font-display);
            font-weight: 700;
            font-size: clamp(1.5rem, 3.4vw, 2.35rem);
            line-height: 1.18;
            letter-spacing: -0.5px;
        }
        .hero-excerpt {
            color: var(--text-secondary);
            font-size: 1.02rem;
            line-height: 1.6;
            display: -webkit-box;
            -webkit-line-clamp: 3;
            line-clamp: 3;
            -webkit-box-orient: vertical;
            overflow: hidden;
        }
        .hero-meta {
            display: flex;
            gap: 14px;
            align-items: center;
            font-size: 0.8rem;
            color: var(--text-muted);
            flex-wrap: wrap;
        }
        @media (max-width: 760px) {
            .hero { grid-template-columns: 1fr; min-height: 0; }
            .hero-media { height: 190px; }
            .hero-media::after { background: linear-gradient(0deg, rgba(18,23,34,0.85), transparent 60%); }
            .hero-body { padding: 1.3rem 1.25rem 1.5rem; }
        }

        /* ---------- DATUMS-GRUPPEN (verfeinert) ---------- */
        .date-group {
            font-family: var(--font-main);
            font-size: 0.82rem;
            letter-spacing: 1.2px;
            color: var(--text-secondary);
            background: linear-gradient(90deg, rgba(56,189,248,0.10), transparent);
            border-left: 3px solid var(--accent-primary);
            backdrop-filter: blur(6px);
            -webkit-backdrop-filter: blur(6px);
        }

        /* ---------- STATISTIK-/QUELLEN-PANEL ---------- */
        .stats-panel {
            grid-column: 1 / -1;
            display: flex;
            flex-wrap: wrap;
            gap: 12px;
            margin-bottom: 4px;
        }
        .stat-chip {
            display: flex;
            flex-direction: column;
            gap: 2px;
            padding: 10px 16px;
            border-radius: var(--radius-sm);
            background: var(--bg-surface);
            border: 1px solid var(--border-color);
            min-width: 110px;
        }
        .stat-chip .num {
            font-size: 1.35rem;
            font-weight: 800;
            font-family: var(--font-display);
            color: var(--accent-primary);
        }
        .stat-chip .lbl {
            font-size: 0.72rem;
            color: var(--text-secondary);
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        /* ---------- LESE-FORTSCHRITTSBALKEN (oben) ---------- */
        .read-progress {
            position: fixed;
            top: 0; left: 0;
            height: 3px;
            width: 0%;
            background: var(--gradient-brand);
            z-index: 2600;
            transition: width 0.1s linear;
        }

        /* ---------- SCHRIFTGRÖSSEN-STUFEN (Lesekomfort) ---------- */
        body.fs-large { font-size: 1.08rem; }
        body.fs-large .card-title { font-size: 1.4rem; }
        body.fs-large .card-excerpt { font-size: 1.05rem; }
        body.fs-xlarge { font-size: 1.18rem; }
        body.fs-xlarge .card-title { font-size: 1.55rem; }
        body.fs-xlarge .card-excerpt { font-size: 1.14rem; }

        /* ---------- FLOATING ACTION BUTTONS (rechts unten) ---------- */
        .fab-stack {
            position: fixed;
            right: 20px;
            bottom: 20px;
            display: flex;
            flex-direction: column;
            gap: 12px;
            z-index: 2500;
        }
        .fab-stack .back-to-top { position: static; }

        /* ---------- FEINSCHLIFF LICHT-THEMA ---------- */
        body.theme-light {
            --bg-deep: #f3f5f9;
            --bg-surface: #ffffff;
            --bg-elevated: #eef1f6;
            --bg-glass: rgba(255,255,255,0.8);
            --text-primary: #16202e;
            --text-secondary: #566072;
            --text-muted: #8a94a6;
            --border-color: #e2e7ef;
            --border-strong: #cdd5e2;
        }
        body.theme-light {
            background:
                radial-gradient(1200px 600px at 12% -10%, rgba(56,189,248,0.12), transparent 60%),
                radial-gradient(1000px 700px at 100% 0%, rgba(167,139,250,0.12), transparent 55%),
                var(--bg-deep);
        }

        /* ---------- MOBILE LESEKOMFORT ---------- */
        @media (max-width: 768px) {
            main { padding: 1.1rem 4%; }
            header { padding: 2.4rem 4% 1rem; }
            .news-grid { grid-template-columns: 1fr; gap: 16px; }
            .card-title { font-size: 1.22rem; line-height: 1.34; }
            .card-excerpt { font-size: 0.98rem; line-height: 1.62; -webkit-line-clamp: 3; line-clamp: 3; }
            .card-body { padding: 1rem 1.15rem; }
            .card-header { padding: 1.1rem 1.15rem 0.4rem; }
            .card-footer { padding: 0.85rem 1.15rem; }
            /* Filter horizontal scrollbar mit sanftem Snap */
            .filters {
                flex-wrap: nowrap;
                overflow-x: auto;
                scroll-snap-type: x proximity;
                -webkit-overflow-scrolling: touch;
                padding-bottom: 6px;
                scrollbar-width: none;
            }
            .filters::-webkit-scrollbar { display: none; }
            .filter-btn { scroll-snap-align: start; flex: 0 0 auto; }
            .logo-area .brand-title { font-size: 1.55rem; }
            .intro-h1 { font-size: 1.25rem; }
        }
        @media (max-width: 768px) and (min-width: 0px) {
            /* Bei Aufmacher auf Mobil: großzügiges, ruhiges Layout */
            .hero-title { font-size: 1.5rem; }
        }

        /* Sanftes Erscheinen beim Scrollen (respektiert reduced motion) */
        @media (prefers-reduced-motion: no-preference) {
            .card { will-change: transform; }
        }

        /* =====================================================================
           ==============  LESEMODUS – RUHIG & ANGENEHM ZU LESEN  ==============
           Optimiert für entspanntes Lesen (v. a. Handy): begrenzte Zeilenlänge,
           großzügige Zeilenhöhe, Serifen-Fließtext, sanfte Kontraste.
           ===================================================================== */
        .reader {
            max-width: 44rem;         /* ~65–75 Zeichen pro Zeile = optimale Lesbarkeit */
            margin: 0 auto;
            padding: 1rem 1.25rem 3rem;
        }
        .reader-back {
            background: var(--bg-elevated);
            color: var(--text-primary);
            border: 1px solid var(--border-color);
            padding: 9px 16px;
            border-radius: 999px;
            cursor: pointer;
            font-weight: 600;
            font-size: 0.9rem;
            margin-bottom: 1.25rem;
            transition: background 0.2s, border-color 0.2s;
        }
        .reader-back:hover { background: var(--accent-secondary); color: #fff; border-color: transparent; }
        .reader-crumbs {
            display: flex; gap: 8px; align-items: center;
            font-size: 0.8rem; color: var(--text-muted);
            margin-bottom: 0.9rem; flex-wrap: wrap;
        }
        .reader-crumbs a { color: var(--text-secondary); text-decoration: none; }
        .reader-crumbs a:hover { color: var(--accent-primary); }
        .reader-title {
            font-family: var(--font-display);
            font-weight: 700;
            font-size: clamp(1.7rem, 5vw, 2.6rem);
            line-height: 1.22;
            letter-spacing: -0.4px;
            margin: 0.7rem 0 0.6rem;
        }
        .reader-meta {
            display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
            color: var(--text-muted); font-size: 0.9rem;
            padding-bottom: 1.2rem; margin-bottom: 1.4rem;
            border-bottom: 1px solid var(--border-color);
        }
        .reader-figure { margin: 0 0 1.6rem; }
        .reader-figure img {
            width: 100%; height: auto; border-radius: var(--radius-md);
            display: block; box-shadow: var(--shadow-subtle);
        }
        .reader-figure figcaption {
            font-size: 0.78rem; color: var(--text-muted);
            margin-top: 0.5rem; text-align: center;
        }
        /* Der eigentliche Fließtext – hier zählt Ruhe & Lesbarkeit */
        .reader-body {
            font-family: var(--font-display);
            font-size: 1.18rem;
            line-height: 1.85;
            color: var(--text-primary);
            letter-spacing: 0.1px;
        }
        .reader-body p { margin: 0 0 1.25rem; }
        .reader-body a { color: var(--accent-primary); text-decoration: underline; text-underline-offset: 3px; }
        .reader-body h2, .reader-body h3 {
            font-family: var(--font-display);
            margin: 1.8rem 0 0.8rem; line-height: 1.3;
        }
        .reader-body img { max-width: 100%; height: auto; border-radius: var(--radius-sm); margin: 1rem 0; }
        .reader-body ul, .reader-body ol { margin: 0 0 1.25rem 1.4rem; }
        .reader-body li { margin-bottom: 0.5rem; }
        .reader-body blockquote {
            border-left: 4px solid var(--accent-primary);
            margin: 1.4rem 0; padding: 0.4rem 0 0.4rem 1.1rem;
            color: var(--text-secondary); font-style: italic;
        }
        .reader-actions {
            display: flex; gap: 12px; flex-wrap: wrap; align-items: center;
            margin: 2.2rem 0 1.2rem;
            padding-top: 1.5rem; border-top: 1px solid var(--border-color);
        }
        .reader-note {
            font-size: 0.85rem; color: var(--text-muted);
            background: var(--bg-surface); border: 1px solid var(--border-color);
            border-radius: var(--radius-sm); padding: 0.9rem 1.1rem;
        }
        .reader-note strong { color: var(--text-secondary); }

        /* Angenehme Textmarkierung (Selektion) */
        ::selection { background: rgba(56,189,248,0.28); color: var(--text-primary); }

        /* Mobil: noch großzügiger & entspannter lesen */
        @media (max-width: 768px) {
            .reader { padding: 0.5rem 1.15rem 2.5rem; }
            .reader-title { font-size: clamp(1.5rem, 7vw, 2rem); line-height: 1.25; }
            .reader-body { font-size: 1.14rem; line-height: 1.9; }
            .reader-body p { margin-bottom: 1.15rem; }
            .reader-actions .filter-btn { flex: 1 1 auto; text-align: center; }
        }
        /* Bei größerer Schriftstufe wächst auch der Lesetext angenehm mit */
        body.fs-large .reader-body { font-size: 1.28rem; line-height: 1.9; }
        body.fs-xlarge .reader-body { font-size: 1.4rem; line-height: 1.95; }

        /* Vor/Zurück-Navigation im Lesemodus (Tap + Swipe + Pfeiltasten) */
        .reader-nav {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 12px;
            margin-top: 1.6rem;
        }
        .reader-nav-btn {
            display: flex;
            flex-direction: column;
            gap: 4px;
            text-align: left;
            padding: 12px 14px;
            min-height: 56px;
            background: var(--bg-surface);
            border: 1px solid var(--border-color);
            border-radius: var(--radius-sm);
            color: var(--text-primary);
            cursor: pointer;
            transition: border-color 0.2s, transform 0.15s, background 0.2s;
        }
        .reader-nav-btn.next { text-align: right; }
        .reader-nav-btn:hover:not([disabled]) {
            border-color: var(--accent-primary);
            transform: translateY(-2px);
            background: var(--bg-elevated);
        }
        .reader-nav-btn[disabled] { opacity: 0.35; cursor: default; }
        .reader-nav-btn .rn-dir { font-size: 0.78rem; font-weight: 700; color: var(--accent-primary); }
        .reader-nav-btn .rn-title { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.3; }
        /* Wisch-Hinweis nur auf Touch-Geräten einblenden */
        @media (hover: none) and (pointer: coarse) {
            .reader-nav::after {
                content: "Tipp: nach links/rechts wischen zum Blättern";
                grid-column: 1 / -1;
                text-align: center;
                font-size: 0.75rem;
                color: var(--text-muted);
                margin-top: 2px;
            }
        }

        /* =====================================================================
           ================  BARRIEREFREIHEIT (A11Y-MODI)  ====================
           ===================================================================== */

        /* Deutlich sichtbarer Fokusrahmen für die per Tastatur fokussierbare Karte */
        .card:focus-visible {
            outline: 3px solid var(--accent-primary);
            outline-offset: 3px;
            box-shadow: var(--shadow-glow);
        }

        /* HOHER KONTRAST: stärkere Ränder, kräftigere Textfarben, klare Trennung */
        body.high-contrast {
            --text-primary: #ffffff;
            --text-secondary: #e6ecf5;
            --text-muted: #c4ccd8;
            --border-color: #7f8ca3;
            --border-strong: #aab6c9;
        }
        body.high-contrast.theme-light {
            --text-primary: #000000;
            --text-secondary: #1a2230;
            --text-muted: #333c4a;
            --border-color: #55606f;
            --border-strong: #2a3240;
        }
        body.high-contrast .card,
        body.high-contrast .stat-chip,
        body.high-contrast .filter-btn,
        body.high-contrast .reader-nav-btn { border-width: 2px; }
        body.high-contrast .card-excerpt,
        body.high-contrast .reader-body { color: var(--text-primary); }
        body.high-contrast a { text-decoration: underline; }

        /* RUHIGER MODUS: alle Animationen/Übergänge praktisch aus */
        body.reduce-motion *,
        body.reduce-motion *::before,
        body.reduce-motion *::after {
            animation-duration: 0.001ms !important;
            animation-iteration-count: 1 !important;
            transition-duration: 0.001ms !important;
            scroll-behavior: auto !important;
        }
        body.reduce-motion .ticker { animation: none !important; }

        /* GUT LESBARE SCHRIFT: serifenlose, ruhige Schrift + mehr Buchstaben-/
           Wort-/Zeilenabstand. Hilft u. a. bei Legasthenie. Kein externer Font
           nötig (CSP-sicher, schnell). */
        body.dyslexia {
            --font-display: var(--font-main);
        }
        body.dyslexia,
        body.dyslexia .reader-body,
        body.dyslexia .card-title,
        body.dyslexia .reader-title,
        body.dyslexia .hero-title {
            font-family: 'Segoe UI', Verdana, Tahoma, -apple-system, sans-serif;
            letter-spacing: 0.04em;
            word-spacing: 0.12em;
        }
        body.dyslexia .reader-body { line-height: 2.05; }
        body.dyslexia .card-excerpt { line-height: 1.8; }

        /* Touch-Ziele überall mind. 44×44px (WCAG 2.5.5) – gerätekompatibel */
        @media (pointer: coarse) {
            .filter-btn, .export-btn, .refresh-btn, .icon-btn,
            .read-btn, .load-more-btn, .reader-back { min-height: 44px; }
            .icon-btn { min-width: 44px; }
        }

        /* Crawlbarer Artikel-Index im Footer (SEO/Indexierung) */
        .article-index {
            max-width: 1000px;
            margin: 1.5rem auto 0;
            text-align: left;
        }
        .article-index summary {
            cursor: pointer;
            font-weight: 700;
            color: var(--text-secondary);
            padding: 10px 14px;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-sm);
            list-style: none;
        }
        .article-index summary::-webkit-details-marker { display: none; }
        .article-index summary::before { content: "🔗 "; }
        .article-index[open] summary, .article-index details[open] summary {
            border-bottom-left-radius: 0; border-bottom-right-radius: 0;
        }
        .article-index-ul {
            list-style: none;
            margin: 0; padding: 8px;
            max-height: 340px;
            overflow-y: auto;
            border: 1px solid var(--border-color);
            border-top: none;
            border-radius: 0 0 var(--radius-sm) var(--radius-sm);
        }
        .article-index-ul li { margin: 0; }
        .article-index-ul a {
            display: flex;
            gap: 10px;
            align-items: baseline;
            padding: 8px 10px;
            border-radius: 8px;
            text-decoration: none;
            color: var(--text-secondary);
            font-size: 0.85rem;
            border-bottom: 1px solid var(--border-color);
        }
        .article-index-ul a:hover { background: var(--bg-elevated); color: var(--text-primary); }
        .article-index-ul .ai-cat {
            flex: 0 0 auto;
            font-size: 0.66rem; font-weight: 800; text-transform: uppercase;
            color: var(--accent-primary); min-width: 92px;
        }
        .article-index-ul .ai-title { flex: 1 1 auto; }
        .article-index-ul time { flex: 0 0 auto; color: var(--text-muted); font-variant-numeric: tabular-nums; }
        .ai-note { font-size: 0.75rem; color: var(--text-muted); margin-top: 8px; text-align: center; }
        @media (max-width: 600px) {
            .article-index-ul .ai-cat { min-width: 74px; }
            .article-index-ul time { display: none; }
        }

        /* „Neue Meldungen"-Hinweis (schwebend, oben mittig) */
        .new-items-banner {
            position: fixed;
            top: 44px;
            left: 50%;
            transform: translateX(-50%) translateY(-16px);
            z-index: 2700;
            background: var(--gradient-brand);
            color: #06131c;
            font-weight: 800;
            font-size: 0.9rem;
            border: none;
            padding: 10px 20px;
            border-radius: 999px;
            box-shadow: var(--shadow-heavy);
            cursor: pointer;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s, transform 0.3s;
            min-height: 40px;
        }
        .new-items-banner.visible {
            opacity: 1;
            pointer-events: auto;
            transform: translateX(-50%) translateY(0);
        }
        .new-items-banner:hover { filter: brightness(1.08); }
        @media (max-width: 600px) {
            .new-items-banner { font-size: 0.82rem; padding: 9px 16px; top: 40px; }
        }

        /* =====================================================================
           =========  MOBILE-BUGFIXES & EINSTELLUNGS-PANEL (DESIGN 3.0)  =======
           ===================================================================== */

        /* --- BUGFIX: Ticker überdeckte den klebenden Header ---------------
           .ticker-wrap ist position:fixed (z-index 2000), <header> ist
           position:sticky mit top:0 (z-index 1000). Beim Scrollen klebte der
           Header UNTER dem Ticker – die obere Header-Zeile war verdeckt.
           Jetzt klebt der Header exakt unterhalb der Ticker-Höhe. */
        :root { --ticker-h: 34px; }
        header { top: var(--ticker-h); }
        @media (max-width: 480px) { :root { --ticker-h: 25px; } }
        @media (max-width: 360px) { :root { --ticker-h: 22px; } }

        /* --- Notch-/Rundungs-Support (iPhone & Co.) ---------------------- */
        body { padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }
        footer { padding-bottom: calc(3rem + env(safe-area-inset-bottom)); }
        .fab-stack, .back-to-top { bottom: calc(20px + env(safe-area-inset-bottom)); }

        /* --- Kopfzeile rechts: Status + Zahnrad -------------------------- */
        .header-top-right { display: flex; align-items: center; gap: 10px; }
        .settings-toggle {
            width: 42px; height: 42px;
            border-radius: 50%;
            border: 1px solid var(--border-color);
            background: var(--bg-elevated);
            color: var(--text-primary);
            font-size: 1.15rem;
            cursor: pointer;
            transition: transform 0.25s, background 0.2s, border-color 0.2s;
            flex: 0 0 auto;
        }
        .settings-toggle:hover { background: var(--accent-secondary); color: #fff; border-color: transparent; }
        .settings-toggle[aria-expanded="true"] { transform: rotate(90deg); background: var(--accent-secondary); color: #fff; }

        /* --- Einstellungs-Panel ------------------------------------------ */
        .settings-backdrop {
            position: fixed; inset: 0;
            background: rgba(0,0,0,0.55);
            backdrop-filter: blur(3px);
            -webkit-backdrop-filter: blur(3px);
            z-index: 2900;
            opacity: 0;
            transition: opacity 0.25s;
        }
        .settings-backdrop.open { opacity: 1; }

        .settings-drawer {
            background: var(--bg-surface);
            border: 1px solid var(--border-color);
            border-radius: var(--radius-md);
            padding: 0 0 12px;
            margin-top: 6px;
            opacity: 0;
            transform: translateY(-8px);
            transition: opacity 0.25s, transform 0.25s;
            width: 100%;
        }
        .settings-drawer.open { opacity: 1; transform: translateY(0); }
        .settings-head {
            display: flex; align-items: center; justify-content: space-between;
            padding: 12px 16px;
            border-bottom: 1px solid var(--border-color);
        }
        .settings-title { font-weight: 800; font-size: 0.92rem; color: var(--text-primary); }
        .settings-close {
            background: none; border: none; color: var(--text-secondary);
            font-size: 1.7rem; line-height: 1; cursor: pointer;
            min-width: 44px; min-height: 44px; border-radius: 8px;
        }
        .settings-close:hover { color: var(--accent-alert); }

        /* Gruppierte Werkzeuge statt Button-Wand */
        .settings-drawer .extra-controls {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
            gap: 4px 20px;
            margin: 0;
            padding: 12px 16px 0;
            align-items: start;
        }
        .settings-group { padding: 8px 0; min-width: 0; }
        .settings-group-label {
            display: block;
            font-size: 0.68rem;
            font-weight: 800;
            letter-spacing: 0.9px;
            text-transform: uppercase;
            color: var(--text-muted);
            margin-bottom: 8px;
        }
        .settings-group-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
        .settings-group-body .refresh-btn,
        .settings-group-body .export-btn { font-size: 0.82rem; }
        .settings-drawer .auto-refresh-indicator {
            font-size: 0.72rem; color: var(--text-muted); font-family: monospace;
        }
        /* Aktive Schalter im Panel deutlich markieren */
        .settings-group-body [aria-pressed="true"] {
            background: var(--gradient-brand);
            color: #06131c;
            border-color: transparent;
            font-weight: 700;
        }

        /* Auf dem Handy als Bottom-Sheet (daumenfreundlich erreichbar) */
        @media (max-width: 768px) {
            .settings-drawer {
                position: fixed;
                left: 0; right: 0; bottom: 0;
                top: auto;
                margin: 0;
                z-index: 3000;
                border-radius: 20px 20px 0 0;
                border-bottom: none;
                max-height: 82vh;
                overflow-y: auto;
                -webkit-overflow-scrolling: touch;
                transform: translateY(100%);
                box-shadow: 0 -12px 40px rgba(0,0,0,0.5);
                padding-bottom: calc(16px + env(safe-area-inset-bottom));
            }
            .settings-drawer.open { transform: translateY(0); }
            .settings-head {
                position: sticky; top: 0;
                background: var(--bg-surface);
                z-index: 2;
            }
            /* Griff-Andeutung wie bei nativen Bottom-Sheets */
            .settings-head::before {
                content: "";
                position: absolute;
                top: 6px; left: 50%;
                transform: translateX(-50%);
                width: 42px; height: 4px;
                border-radius: 999px;
                background: var(--border-strong);
            }
            .settings-drawer .extra-controls { grid-template-columns: 1fr; gap: 0; }
            .settings-group { border-bottom: 1px solid var(--border-color); padding: 12px 0; }
            .settings-group:last-child { border-bottom: none; }
            .settings-group-body .refresh-btn,
            .settings-group-body .export-btn { flex: 1 1 auto; min-width: 44%; }
        }

        /* --- KOMPAKTE ANSICHT (dichte Liste zum schnellen Überfliegen) ---- */
        body.compact-mode .news-grid { grid-template-columns: 1fr; gap: 0; }
        body.compact-mode .card-image { display: none; }
        body.compact-mode .card {
            border-radius: 0;
            border-left: none; border-right: none; border-top: none;
            box-shadow: none;
            min-height: 0;
            margin: 0;
        }
        body.compact-mode .card::before { display: none; }
        body.compact-mode .card:hover { transform: none; box-shadow: none; }
        body.compact-mode .card-header { padding: 0.7rem 0.9rem 0.15rem; }
        body.compact-mode .card-body { padding: 0.15rem 0.9rem 0.5rem; }
        body.compact-mode .card-footer { padding: 0.4rem 0.9rem 0.7rem; }
        body.compact-mode .card-title { font-size: 1.02rem; line-height: 1.3; margin-bottom: 0.3rem; }
        body.compact-mode .card-excerpt {
            -webkit-line-clamp: 2; line-clamp: 2; font-size: 0.88rem;
        }
        body.compact-mode .reading-time { display: none; }
        body.compact-mode .hero { display: none; }  /* Kompakt = reine Liste */

        /* --- Statistik-Panel auf dem Handy horizontal scrollbar ---------- */
        @media (max-width: 768px) {
            .stats-panel {
                flex-wrap: nowrap;
                overflow-x: auto;
                -webkit-overflow-scrolling: touch;
                scrollbar-width: none;
                padding-bottom: 4px;
            }
            .stats-panel::-webkit-scrollbar { display: none; }
            .stat-chip { flex: 0 0 auto; min-width: 96px; padding: 8px 13px; }
            .stat-chip .num { font-size: 1.15rem; }
        }

        /* --- Weitere Handy-Feinheiten ------------------------------------ */
        @media (max-width: 768px) {
            /* Suchfeld + Zahnrad sollen nie überlaufen */
            .controls-area { width: 100%; min-width: 0; }
            .search-box { width: 100%; max-width: none; }
            /* Ladebalken direkt unter der Suche gut sichtbar */
            .progress-bar { margin-top: 6px; }
            /* Modal weiter oben, damit es nicht unter der Tastatur verschwindet */
            .modal-content { max-height: 88vh; }
        }
        /* Sehr schmale Geräte: Markentitel darf umbrechen statt zu überlaufen */
        @media (max-width: 380px) {
            .header-top { gap: 8px; }
            .logo-area .brand-title { font-size: 1.25rem; }
            .system-status { display: none; } /* Platz für das Zahnrad schaffen */
        }

        /* --- PULL-TO-REFRESH-ANZEIGE ------------------------------------- */
        .ptr-indicator {
            position: fixed;
            top: 0;
            left: 50%;
            transform: translateX(-50%) translateY(-100%);
            z-index: 2800;
            display: flex;
            align-items: center;
            gap: 9px;
            padding: 9px 18px;
            border-radius: 999px;
            background: var(--bg-elevated);
            border: 1px solid var(--accent-primary);
            color: var(--text-primary);
            font-size: 0.82rem;
            font-weight: 600;
            box-shadow: var(--shadow-heavy);
            opacity: 0;
            pointer-events: none;
            white-space: nowrap;
        }
        .ptr-spinner { font-size: 1rem; display: inline-block; color: var(--accent-primary); }
        .ptr-spinner.spinning { animation: spin 0.9s linear infinite; }
        body.reduce-motion .ptr-spinner.spinning { animation: none; }

        /* --- QUELLEN-STATUS (faktische Ladeergebnisse je Feed) ----------- */
        .source-status { margin-top: 4px; }
        .source-status summary {
            cursor: pointer;
            list-style: none;
            font-size: 0.72rem;
            font-family: monospace;
            color: var(--text-muted);
            padding: 4px 0;
        }
        .source-status summary::-webkit-details-marker { display: none; }
        .source-status summary:hover { color: var(--accent-primary); }
        .source-status-list {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
            margin-top: 8px;
            padding: 10px;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-sm);
            background: var(--bg-surface);
        }
        .src-chip {
            display: inline-flex;
            align-items: center;
            gap: 5px;
            font-size: 0.7rem;
            font-weight: 600;
            padding: 4px 10px;
            border-radius: 999px;
            border: 1px solid var(--border-color);
            color: var(--text-secondary);
            white-space: nowrap;
        }
        .src-chip.ok { border-color: rgba(52,211,153,0.5); color: var(--accent-emerald); }
        .src-chip.fail { border-color: rgba(251,113,133,0.5); color: var(--accent-alert); }
        .src-chip .src-count { font-family: monospace; opacity: 0.85; }

        /* --- TREND-THEMEN-LEISTE (Wortzählung aus echten Schlagzeilen) ---- */
        .trending-bar {
            grid-column: 1 / -1;
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 8px;
            padding: 12px 14px;
            margin-bottom: 4px;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-md);
            background: linear-gradient(120deg, rgba(251,113,133,0.07), rgba(167,139,250,0.05));
        }
        .trending-label {
            font-size: 0.72rem;
            font-weight: 800;
            letter-spacing: 0.8px;
            text-transform: uppercase;
            color: var(--text-secondary);
            margin-right: 4px;
            cursor: help;
        }
        .trend-chip {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 6px 13px;
            border-radius: 999px;
            border: 1px solid var(--border-color);
            background: var(--bg-elevated);
            color: var(--text-primary);
            font-size: 0.83rem;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.15s, background 0.2s, border-color 0.2s;
            min-height: 34px;
        }
        .trend-chip:hover {
            transform: translateY(-2px);
            border-color: var(--accent-alert);
            background: rgba(251,113,133,0.14);
        }
        .trend-chip .trend-count {
            font-size: 0.68rem;
            font-family: monospace;
            font-weight: 700;
            color: var(--accent-alert);
            background: rgba(251,113,133,0.16);
            padding: 1px 6px;
            border-radius: 999px;
        }
        /* Handy: Themen horizontal wischbar statt umbrechend */
        @media (max-width: 768px) {
            .trending-bar {
                flex-wrap: nowrap;
                overflow-x: auto;
                -webkit-overflow-scrolling: touch;
                scrollbar-width: none;
                padding: 10px 12px;
            }
            .trending-bar::-webkit-scrollbar { display: none; }
            .trending-label { flex: 0 0 auto; }
            .trend-chip { flex: 0 0 auto; min-height: 40px; }
        }

        /* --- ÄHNLICHE MELDUNGEN (im Lesemodus) --------------------------- */
        .related-section {
            margin: 2.4rem 0 1rem;
            padding-top: 1.6rem;
            border-top: 1px solid var(--border-color);
        }
        .related-heading {
            font-family: var(--font-display);
            font-size: 1.28rem;
            margin-bottom: 0.35rem;
        }
        .related-note {
            font-size: 0.78rem;
            color: var(--text-muted);
            margin-bottom: 1rem;
            line-height: 1.5;
        }
        .related-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
        .related-item {
            display: flex;
            flex-direction: column;
            gap: 6px;
            width: 100%;
            text-align: left;
            padding: 12px 14px;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-sm);
            background: var(--bg-surface);
            color: var(--text-primary);
            cursor: pointer;
            transition: border-color 0.2s, transform 0.15s, background 0.2s;
            min-height: 56px;
        }
        .related-item:hover {
            border-color: var(--accent-primary);
            background: var(--bg-elevated);
            transform: translateY(-2px);
        }
        .related-item .related-cat { align-self: flex-start; }
        .related-item .related-title {
            font-family: var(--font-display);
            font-weight: 600;
            font-size: 1rem;
            line-height: 1.35;
        }
        .related-item .related-meta {
            font-size: 0.73rem;
            color: var(--text-muted);
            font-family: monospace;
        }
        @media (max-width: 768px) {
            .related-item { padding: 14px; min-height: 64px; }
            .related-item .related-title { font-size: 1.02rem; }
        }

        /* --- VORLESEN-BUTTON: aktiver Zustand deutlich sichtbar ---------- */
        #speak-btn.active {
            background: var(--accent-emerald);
            border-color: var(--accent-emerald);
            color: #04241a;
            font-weight: 700;
        }
        #speak-btn.active::before {
            content: "● ";
            animation: pulse 1.4s ease-in-out infinite;
        }
        body.reduce-motion #speak-btn.active::before { animation: none; }

        /* =====================================================================
           ====  EILMELDUNGS- & NEU-KENNZEICHNUNG (läuft automatisch ab)  ======
           ===================================================================== */
        .flash-badge {
            display: inline-flex;
            align-items: center;
            gap: 4px;
            font-size: 0.63rem;
            font-weight: 900;
            letter-spacing: 0.9px;
            padding: 3px 9px;
            border-radius: 999px;
            text-transform: uppercase;
            white-space: nowrap;
            cursor: help;
        }
        /* EILMELDUNG: kräftiges Rot, dezent pulsierend */
        .flash-badge.breaking {
            background: var(--accent-alert);
            color: #2b0410;
            box-shadow: 0 0 0 0 rgba(251,113,133,0.6);
            animation: breathePulse 2s ease-in-out infinite;
        }
        @keyframes breathePulse {
            0%   { box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
            70%  { box-shadow: 0 0 0 9px rgba(251,113,133,0); }
            100% { box-shadow: 0 0 0 0 rgba(251,113,133,0); }
        }
        /* NEU: ruhiges Grün, ohne Bewegung – soll nicht ablenken */
        .flash-badge.fresh {
            background: rgba(52,211,153,0.16);
            color: var(--accent-emerald);
            border: 1px solid rgba(52,211,153,0.45);
        }
        /* Ruhiger Modus & reduzierte Bewegung: kein Pulsieren */
        body.reduce-motion .flash-badge.breaking { animation: none; }
        @media (prefers-reduced-motion: reduce) {
            .flash-badge.breaking { animation: none; }
        }

        /* Karte mit aktiver Eilmeldung erkennbar hervorheben */
        .card:has(.flash-badge.breaking) { border-color: var(--accent-alert); }
        .card:has(.flash-badge.breaking)::before { background: var(--accent-alert); height: 4px; }

        /* Laufband schaltet bei Eilmeldungen auf Rot um */
        .ticker-wrap.has-breaking {
            background: linear-gradient(90deg, #b91c3c, #e11d48);
            box-shadow: 0 2px 14px rgba(225,29,72,0.45);
        }

        /* Im Aufmacher etwas größer, damit es dort nicht untergeht */
        .hero .flash-badge { font-size: 0.7rem; padding: 4px 12px; align-self: flex-start; }

        @media (max-width: 600px) {
            .flash-badge { font-size: 0.58rem; padding: 3px 7px; letter-spacing: 0.5px; }
        }

        /* --- VERSIONSSTAND im Footer ------------------------------------- */
        .site-version {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            align-items: center;
            gap: 8px;
            margin: 14px auto 6px;
            padding: 8px 16px;
            max-width: 640px;
            font-family: monospace;
            font-size: 0.74rem;
            color: var(--text-muted);
            border: 1px solid var(--border-color);
            border-radius: 999px;
            background: var(--bg-surface);
        }
        .site-version .sv-label { font-weight: 700; color: var(--text-secondary); }
        .site-version time { color: var(--accent-primary); font-weight: 700; }
        .site-version .sv-sep { opacity: 0.5; }
        @media (max-width: 600px) {
            .site-version { font-size: 0.68rem; gap: 6px; padding: 8px 12px; }
            .site-version .sv-sep { display: none; }
        }

        /* =====================================================================
           ==  BUGFIX: Einstellungs-Panel wurde am Desktop nicht angezeigt  ===
           =====================================================================
           URSACHE: Das Panel liegt im <header> (z-index 1000). Der abdunkelnde
           Hintergrund (.settings-backdrop) hat z-index 2900 und lag damit ÜBER
           dem Panel – am Desktop sah man nur die dunkle Fläche, das Panel war
           verdeckt und nicht bedienbar. Auf dem Handy fiel es nicht auf, weil
           das Panel dort position:fixed mit z-index 3000 hat.

           LÖSUNG: Am Desktop wird gar kein Hintergrund benötigt – das Panel
           sitzt dort ohnehin sichtbar im Kopfbereich. Zusätzlich bekommt es
           einen eigenen Stapelkontext oberhalb des restlichen Kopfbereichs. */
        @media (min-width: 769px) {
            .settings-backdrop { display: none !important; }
            .settings-drawer {
                position: relative;
                z-index: 1300;
                flex-basis: 100%;
                box-shadow: var(--shadow-heavy);
            }
        }
        /* Der Kopfbereich darf das Panel nicht abschneiden */
        header { overflow: visible; }

        /* --- QUELLEN-SCHALTER im Einstellungs-Panel ---------------------- */
        .settings-group-wide { grid-column: 1 / -1; }
        .settings-hint {
            font-size: 0.74rem;
            color: var(--text-muted);
            margin: -2px 0 8px;
            line-height: 1.45;
        }
        .settings-group-label .stc {
            font-weight: 600;
            color: var(--accent-primary);
            letter-spacing: 0;
            text-transform: none;
            margin-left: 6px;
        }
        .source-toggles { gap: 7px; }
        .source-toggle {
            display: inline-flex;
            align-items: center;
            gap: 7px;
            padding: 7px 13px;
            border-radius: 999px;
            border: 1px solid var(--border-color);
            background: var(--bg-elevated);
            font-size: 0.8rem;
            font-weight: 600;
            cursor: pointer;
            user-select: none;
            transition: border-color 0.2s, background 0.2s, opacity 0.2s;
            min-height: 40px;
        }
        .source-toggle input { accent-color: var(--accent-primary); width: 16px; height: 16px; cursor: pointer; }
        .source-toggle.on { border-color: rgba(56,189,248,0.5); color: var(--text-primary); }
        .source-toggle.off { opacity: 0.5; }
        .source-toggle.off span { text-decoration: line-through; }
        .source-toggle:hover { border-color: var(--accent-primary); }

        /* --- CRAWLBARE QUELLEN-LINKS im Footer --------------------------- */
        .category-links {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 7px;
            max-width: 1000px;
            margin: 18px auto 8px;
        }
        .category-links a {
            display: inline-flex;
            align-items: center;
            gap: 5px;
            padding: 6px 13px;
            border-radius: 999px;
            border: 1px solid var(--border-color);
            background: var(--bg-surface);
            color: var(--text-secondary);
            text-decoration: none;
            font-size: 0.78rem;
            font-weight: 600;
            transition: border-color 0.2s, color 0.2s, transform 0.15s;
            min-height: 34px;
        }
        .category-links a:hover {
            border-color: var(--accent-primary);
            color: var(--text-primary);
            transform: translateY(-2px);
        }
        .category-links .cat-link-all {
            background: var(--gradient-brand);
            color: #06131c;
            border-color: transparent;
            font-weight: 800;
        }
        .category-links .cl-n {
            font-family: monospace;
            font-size: 0.68rem;
            opacity: 0.75;
        }

        /* --- ÜBERSICHTSSEITE /quellen ------------------------------------ */
        .source-index { max-width: 800px; margin: 0 auto; padding: 1rem 1.25rem 3rem; }
        .source-index-list { list-style: none; margin: 1.2rem 0 0; padding: 0; display: grid; gap: 8px; }
        .src-index-item {
            display: flex;
            align-items: center;
            gap: 12px;
            padding: 13px 16px;
            border: 1px solid var(--border-color);
            border-radius: var(--radius-sm);
            background: var(--bg-surface);
            text-decoration: none;
            color: var(--text-primary);
            transition: border-color 0.2s, transform 0.15s, background 0.2s;
            min-height: 52px;
        }
        .src-index-item:hover {
            border-color: var(--accent-primary);
            background: var(--bg-elevated);
            transform: translateY(-2px);
        }
        .src-index-item .si-name { flex: 1 1 auto; font-weight: 700; font-size: 1rem; }
        .src-index-item .si-count {
            flex: 0 0 auto; font-family: monospace; font-size: 0.78rem;
            color: var(--accent-primary);
        }
        .src-index-item .si-time {
            flex: 0 0 auto; font-family: monospace; font-size: 0.74rem;
            color: var(--text-muted); min-width: 92px; text-align: right;
        }
        /* Tages-Trennung im Leseverlauf */
        .hist-day {
            list-style: none;
            margin: 14px 0 4px;
            padding: 6px 12px;
            font-size: 0.72rem;
            font-weight: 800;
            letter-spacing: 1px;
            text-transform: uppercase;
            color: var(--accent-primary);
            border-left: 3px solid var(--accent-primary);
            background: linear-gradient(90deg, rgba(56,189,248,0.09), transparent);
            border-radius: 0 8px 8px 0;
        }
        .hist-day:first-child { margin-top: 0; }

        @media (max-width: 600px) {
            .src-index-item { flex-wrap: wrap; gap: 6px 10px; }
            .src-index-item .si-name { flex: 1 1 100%; }
            .src-index-item .si-time { text-align: left; min-width: 0; }
            .category-links { gap: 6px; }
            .category-links a { font-size: 0.74rem; padding: 6px 11px; min-height: 38px; }
        }

        /* ==================================================================
           HERKUNFT: REGIONEN, SPRACHEN UND INTERNATIONALE QUELLEN
           ================================================================== */

        /* Zweite Filterzeile: Herkunftsregion der Meldung */
        .region-bar {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 8px;
            margin: 10px 0 4px;
            padding-top: 10px;
            border-top: 1px solid rgba(255, 255, 255, 0.08);
        }
        .region-bar-label {
            font-size: 0.72rem;
            letter-spacing: 0.06em;
            text-transform: uppercase;
            opacity: 0.62;
            margin-right: 2px;
        }
        .region-btn, .lang-btn {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 6px 12px;
            font-size: 0.78rem;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.14);
            background: rgba(255, 255, 255, 0.04);
            color: inherit;
            cursor: pointer;
            transition: background 0.15s, border-color 0.15s, opacity 0.15s;
        }
        .region-btn:hover, .lang-btn:hover,
        .region-btn:focus-visible, .lang-btn:focus-visible {
            background: rgba(255, 255, 255, 0.1);
            border-color: rgba(255, 255, 255, 0.3);
        }
        .region-btn.active, .lang-btn.active {
            background: var(--accent, #2b7cff);
            border-color: var(--accent, #2b7cff);
            color: #fff;
        }
        /* Region ohne geladene Meldungen: sichtbar, aber erkennbar leer */
        .region-btn.is-empty { opacity: 0.4; }
        .region-btn .count-badge {
            font-size: 0.68rem;
            padding: 1px 6px;
            border-radius: 999px;
            background: rgba(0, 0, 0, 0.25);
        }

        /* Kennzeichen auf der Karte: Herkunftsregion und Sprachkuerzel */
        .region-badge {
            display: inline-flex;
            align-items: center;
            gap: 5px;
            font-size: 0.66rem;
            padding: 2px 8px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.16);
            opacity: 0.85;
            white-space: nowrap;
        }
        .lang-tag {
            font-size: 0.58rem;
            font-weight: 700;
            letter-spacing: 0.04em;
            padding: 1px 4px;
            border-radius: 3px;
            background: rgba(255, 255, 255, 0.16);
        }

        /* Farbkennungen der internationalen Quellen.
           Bewusst gedeckt gehalten, damit die Kartenliste ruhig bleibt. */
        .badge-euronews   { background: #1a4b8c; }
        .badge-orf        { background: #b31217; }
        .badge-standard   { background: #7a5c2e; }
        .badge-srf        { background: #16557f; }
        .badge-eu         { background: #1c3f94; }
        .badge-lemonde    { background: #4a4a7d; }
        .badge-nyt        { background: #333333; }
        .badge-npr        { background: #4a6fa5; }
        .badge-cbc        { background: #9c2a2a; }
        .badge-abcau      { background: #2b6a8f; }
        .badge-japantimes { background: #6b3f6b; }
        .badge-bbc        { background: #8b1c26; }
        .badge-guardian   { background: #1b4a73; }
        .badge-aljazeera  { background: #8a6d1f; }
        .badge-un         { background: #2b6cb0; }
        .badge-nasa       { background: #2a3f7a; }
        .badge-cnn        { background: #7a1f1f; }
        .badge-toi        { background: #8a5a1f; }

        @media (max-width: 640px) {
            .region-bar { gap: 6px; }
            .region-btn, .lang-btn { padding: 5px 10px; font-size: 0.72rem; }
            .region-bar-label { width: 100%; margin-bottom: 2px; }
        }
        @media (prefers-reduced-motion: reduce) {
            .region-btn, .lang-btn { transition: none; }
        }

        /* ==================================================================
           MEHRFACHBERICHTERSTATTUNG
           Zeigt an, welche anderen Quellen ueber dieselbe Sache berichten.
           ================================================================== */
        .auch-bei {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 6px;
            margin-top: 9px;
            padding-top: 8px;
            border-top: 1px dashed rgba(255, 255, 255, 0.12);
            font-size: 0.7rem;
        }
        .auch-bei-label { opacity: 0.6; }
        .auch-bei a {
            display: inline-block;
            padding: 2px 7px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.16);
            text-decoration: none;
            color: inherit;
            opacity: 0.9;
            transition: background 0.15s, border-color 0.15s;
        }
        .auch-bei a:hover, .auch-bei a:focus-visible {
            background: rgba(255, 255, 255, 0.1);
            border-color: rgba(255, 255, 255, 0.34);
            outline: none;
        }

        /* ==================================================================
           MAERKTE: Devisen und Kryptowerte
           Bewusst nuechtern gehalten. Kurse sind Daten, keine Werbeflaeche.
           ================================================================== */
        .markets-section {
            margin: 26px 0 10px;
            border: 1px solid rgba(255, 255, 255, 0.1);
            border-radius: 12px;
            padding: 18px 18px 14px;
            background: rgba(255, 255, 255, 0.025);
        }
        .markets-head {
            display: flex;
            flex-wrap: wrap;
            align-items: baseline;
            gap: 10px 14px;
            margin-bottom: 4px;
        }
        .markets-head h2 { font-size: 1.05rem; margin: 0; }
        .markets-updated { font-size: 0.72rem; opacity: 0.65; }
        .markets-reload {
            margin-left: auto;
            font-size: 0.72rem;
            padding: 5px 11px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.18);
            background: rgba(255, 255, 255, 0.05);
            color: inherit;
            cursor: pointer;
        }
        .markets-reload:hover, .markets-reload:focus-visible {
            background: rgba(255, 255, 255, 0.12);
        }
        .markets-note {
            font-size: 0.72rem;
            line-height: 1.55;
            opacity: 0.72;
            margin: 6px 0 14px;
        }
        .markets-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
            gap: 10px;
            list-style: none;
            padding: 0;
            margin: 0 0 12px;
        }
        .market-item {
            border: 1px solid rgba(255, 255, 255, 0.1);
            border-radius: 10px;
            padding: 11px 13px;
            background: rgba(0, 0, 0, 0.16);
        }
        .market-label { font-size: 0.72rem; opacity: 0.7; display: block; }
        .market-value {
            font-size: 1.12rem;
            font-weight: 700;
            font-variant-numeric: tabular-nums;
            display: block;
            margin-top: 3px;
        }
        .market-sub { font-size: 0.68rem; opacity: 0.6; display: block; margin-top: 2px; }
        .market-change { font-size: 0.74rem; font-weight: 600; font-variant-numeric: tabular-nums; }
        .market-change.up   { color: #4ac47a; }
        .market-change.down { color: #e06c6c; }
        .market-change.flat { opacity: 0.7; }
        .markets-group-title {
            font-size: 0.74rem;
            letter-spacing: 0.06em;
            text-transform: uppercase;
            opacity: 0.62;
            margin: 4px 0 8px;
        }
        .markets-error {
            font-size: 0.8rem;
            border: 1px dashed rgba(255, 255, 255, 0.22);
            border-radius: 10px;
            padding: 14px;
            opacity: 0.85;
        }
        .markets-source { font-size: 0.68rem; opacity: 0.6; margin-top: 10px; }
        .markets-source a { color: inherit; }
        @media (max-width: 640px) {
            .markets-section { padding: 14px 13px 12px; }
            .markets-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
        }

        /* ==================================================================
           HANDY-OPTIMIERUNG
           Ausgangslage vor dieser Aenderung, gemessen bei 430 x 932 Punkten:
           die erste Meldung begann erst bei 1830 Pixeln. Man musste also fast
           zwei volle Bildschirme scrollen, bevor ueberhaupt eine Nachricht kam.
           Ursachen: der Maerkte-Block stand vor den Nachrichten, und die drei
           Filterleisten beanspruchten zusammen ueber 260 Pixel Hoehe.
           ================================================================== */

        /* Einklappbarer Bereich fuer Region und Sprache */
        .herkunft-box {
            margin: 8px 0 4px;
            border-top: 1px solid rgba(255, 255, 255, 0.08);
            padding-top: 8px;
        }
        .herkunft-summary {
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            list-style: none;
            font-size: 0.78rem;
            letter-spacing: 0.04em;
            text-transform: uppercase;
            opacity: 0.72;
            padding: 6px 2px;
            min-height: 40px;
        }
        .herkunft-summary::-webkit-details-marker { display: none; }
        .herkunft-summary::before {
            content: "▸";
            display: inline-block;
            transition: transform 0.18s;
            opacity: 0.8;
        }
        .herkunft-box[open] .herkunft-summary::before { transform: rotate(90deg); }
        .herkunft-hinweis {
            margin-left: auto;
            text-transform: none;
            letter-spacing: 0;
            font-size: 0.72rem;
            opacity: 0.8;
            padding: 2px 9px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.16);
        }
        .herkunft-box .region-bar { border-top: none; padding-top: 4px; margin-top: 0; }
        .herkunft-hinweis.aktiv {
            border-color: rgba(90, 160, 255, 0.6);
            background: rgba(90, 160, 255, 0.16);
            opacity: 1;
        }

        /* Einklappbarer Rechtshinweis bei den Maerkten */
        .markets-details { margin: 4px 0 12px; }
        .markets-details > summary {
            cursor: pointer;
            font-size: 0.74rem;
            opacity: 0.72;
            padding: 6px 0;
            min-height: 34px;
            display: flex;
            align-items: center;
        }
        .markets-details .markets-note { margin-top: 4px; }

        /* Waagerecht scrollbare Filterreihen mit sichtbarem Hinweis,
           dass es rechts weitergeht. */
        .filters, .region-bar {
            overflow-x: auto;
            flex-wrap: nowrap;
            scrollbar-width: none;
            -webkit-overflow-scrolling: touch;
            scroll-snap-type: x proximity;
        }
        .filters::-webkit-scrollbar, .region-bar::-webkit-scrollbar { display: none; }
        .filters > *, .region-bar > * { flex: 0 0 auto; scroll-snap-align: start; }

        @media (max-width: 680px) {
            /* Kopfbereich zusammenruecken */
            header h1, .site-title { font-size: 1.45rem !important; }

            /* Filter deutlich kompakter. Vorher beanspruchte eine Reihe
               allein rund 100 Pixel Hoehe. */
            .filter-btn {
                padding: 7px 13px !important;
                font-size: 0.78rem !important;
                min-height: 38px;
                border-radius: 999px;
            }
            .region-btn, .lang-btn {
                padding: 6px 11px;
                font-size: 0.74rem;
                min-height: 36px;
            }
            .region-bar { gap: 7px; margin: 6px 0 2px; padding-top: 6px; }
            /* Beschriftung bleibt in derselben Zeile, statt eine eigene zu belegen */
            .region-bar-label { width: auto; margin: 0 4px 0 0; font-size: 0.68rem; }

            .search-box input { min-height: 44px; }

            /* Maerkte: kompakter, da sie unter den Nachrichten stehen */
            .markets-section { margin: 20px 0 8px; padding: 13px 12px 11px; }
            .markets-grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
            .market-item { padding: 9px 11px; }
            .market-value { font-size: 1rem; }

            /* Karteninhalte etwas dichter setzen */
            .auch-bei { font-size: 0.66rem; gap: 5px; }
        }

        @media (max-width: 400px) {
            .filter-btn { padding: 6px 11px !important; font-size: 0.74rem !important; }
            .markets-grid { grid-template-columns: 1fr 1fr; }
        }

        /* Einleitung: Kurzfassung immer sichtbar, Einzelheiten aufklappbar.
           Der vollstaendige Text bleibt im Dokument und damit indexierbar,
           belegt auf dem Handy aber nicht mehr rund 800 Pixel Hoehe. */
        .intro-kurz { margin-bottom: 6px; }
        .intro-mehr > summary {
            cursor: pointer;
            font-size: 0.86rem;
            opacity: 0.8;
            padding: 8px 0;
            min-height: 40px;
            display: flex;
            align-items: center;
        }
        .intro-mehr > summary:hover { opacity: 1; }
        .intro-mehr p + p { margin-top: 10px; }

        /* Tippziele: die Quellenlinks bei den Maerkten waren nur 14 Pixel hoch
           und damit auf dem Handy kaum zu treffen. Die WCAG empfiehlt
           mindestens 24 Pixel, die Praxis eher 44. */
        .markets-source a {
            display: inline-block;
            padding: 6px 4px;
            min-height: 24px;
            text-decoration: underline;
            text-underline-offset: 2px;
        }

        /* Sanftes Scrollen zu Sprungmarken, ohne dass die klebende Kopfzeile
           den Anfang verdeckt. */
        html { scroll-padding-top: 84px; }

        @media (max-width: 680px) {
            /* Karten etwas dichter, damit mehr Meldungen auf den Schirm passen */
            .news-grid { gap: 12px; }
            .card { border-radius: 14px; }
            .card-title { font-size: 1.02rem; line-height: 1.35; }
            .card-excerpt { font-size: 0.86rem; }

            /* Der Aufmacher war auf dem Handy unnoetig hoch */
            .hero-media { max-height: 190px; }
            .hero-title { font-size: 1.3rem; }

            /* Statistikkacheln zweispaltig statt vierspaltig gequetscht */
            /* Vier Kennzahlen passen auf schmalen Schirmen als 2x2 sauber
               ins Bild. Vorher liefen sie seitlich aus dem Bild und die vierte
               Kachel war nur noch angeschnitten sichtbar. */
            /* Die fuenf Kennzahlen liefen vorher seitlich aus dem Bild, die
               letzte war nur angeschnitten sichtbar. Zweispaltig wurden sie
               dagegen 320 Pixel hoch und schoben die Nachrichten nach unten.
               Dreispaltig und kompakt passen sie in zwei flache Reihen. */
            .stats-panel {
                flex-wrap: wrap !important;
                overflow-x: visible !important;
                gap: 7px !important;
            }
            .stats-panel .stat-chip {
                flex: 1 1 calc(33.333% - 7px) !important;
                min-width: 0 !important;
                padding: 6px 8px !important;
                text-align: center;
            }
            .stats-panel .stat-chip .num { font-size: 0.98rem !important; }
            .stats-panel .stat-chip .label,
            .stats-panel .stat-chip small { font-size: 0.58rem !important; letter-spacing: 0.02em; }
        }
    </style>
</head>
<body>

    <!-- Barrierefreiheit: Sprunglink zum Hauptinhalt (Tastatur/Screenreader) -->
    <a href="#news-container" class="skip-link">Zum Hauptinhalt springen</a>

    <!-- Lese-Fortschrittsbalken (zeigt Scroll-Position der Seite) -->
    <div class="read-progress" id="read-progress" role="progressbar"
         aria-label="Lesefortschritt" aria-hidden="true"></div>

    <!-- BARRIEREFREIHEIT: unsichtbare Live-Region. Screenreader lesen hier
         eingetragene Statusmeldungen vor (z. B. „42 Meldungen geladen"). -->
    <div id="a11y-live" class="visually-hidden" aria-live="polite" role="status"></div>

    <!-- Pull-to-Refresh-Anzeige (nur Touch-Geräte): erscheint beim Herunterziehen
         am Seitenanfang und löst beim Loslassen eine Aktualisierung aus. -->
    <div class="ptr-indicator" id="ptr-indicator" aria-hidden="true">
        <span class="ptr-spinner" id="ptr-spinner">↓</span>
        <span class="ptr-text" id="ptr-text">Zum Aktualisieren ziehen</span>
    </div>

    <div class="ticker-wrap">
        <div class="ticker" id="news-ticker">
            +++ Lade aktuelle Eilmeldungen... +++ Nachrichten Kaipfstr +++ Unabhängig & Schnell +++
        </div>
    </div>

    <header>
        <div class="header-top">
            <div class="logo-area">
                <!-- SEO: Marken-Titel als <div> (kein zweites <h1>). Die einzige
                     <h1> der Seite ist die beschreibende Überschrift im Intro. -->
                <div class="brand-title" role="heading" aria-level="1">Nachrichten.Kaipfstr</div>
            </div>
            <div class="header-top-right">
                <div class="system-status" id="connection-status">
                    ● VERBUNDEN
                </div>
                <!-- MOBILE-FIX: Alle Werkzeuge stecken jetzt in einem Panel, damit
                     der Header auf dem Handy nicht von ~12 Buttons geflutet wird. -->
                <button class="settings-toggle" id="settings-toggle" onclick="toggleSettings()"
                        aria-expanded="false" aria-controls="settings-drawer"
                        aria-label="Einstellungen und Werkzeuge öffnen" title="Einstellungen">⚙</button>
            </div>
        </div>

        <div class="controls-area">
            <div class="search-box">
                <label for="searchInput" class="visually-hidden">Nachrichten durchsuchen</label>
                <input type="search" id="searchInput" placeholder="Nachrichten durchsuchen … (Strg+K)"
                       aria-label="Nachrichten durchsuchen" autocomplete="off" enterkeyhint="search"
                       oninput="handleSearch()" onsearch="handleSearch(true)"
                       onkeydown="if(event.key==='Enter'){handleSearch(true);this.blur();}">
            </div>
            <div class="filters" role="group" aria-label="Nach Quelle filtern">
                <button class="filter-btn active" data-cat="all" onclick="applyFilter('all', this)">Alle</button>
                <button class="filter-btn" data-cat="Tagesschau" onclick="applyFilter('Tagesschau', this)">Tagesschau</button>
                <button class="filter-btn" data-cat="Politik" onclick="applyFilter('Politik', this)">Politik</button>
                <button class="filter-btn" data-cat="Meinungen" onclick="applyFilter('Meinungen', this)">Meinungen</button>
                <button class="filter-btn" data-cat="Deutschlandfunk" onclick="applyFilter('Deutschlandfunk', this)">Deutschlandfunk</button>
                <button class="filter-btn" data-cat="Bundesregierung" onclick="applyFilter('Bundesregierung', this)">Bundesregierung</button>
                <button class="filter-btn" data-cat="Spiegel" onclick="applyFilter('Spiegel', this)">Spiegel</button>
                <button class="filter-btn" data-cat="FAZ" onclick="applyFilter('FAZ', this)">FAZ</button>
                <button class="filter-btn" data-cat="Zeit" onclick="applyFilter('Zeit', this)">Zeit</button>
                <button class="filter-btn" data-cat="ZDF" onclick="applyFilter('ZDF', this)">ZDF</button>
                <button class="filter-btn" data-cat="Deutsche Welle" onclick="applyFilter('Deutsche Welle', this)">Deutsche Welle</button>
                <button class="filter-btn" data-cat="Süddeutsche" onclick="applyFilter('Süddeutsche', this)">Süddeutsche</button>
                <button class="filter-btn" data-cat="Tagesspiegel" onclick="applyFilter('Tagesspiegel', this)">Tagesspiegel</button>
                <button class="filter-btn" data-cat="Heise" onclick="applyFilter('Heise', this)">Heise</button>
                <button class="filter-btn" data-cat="FragDenStaat" onclick="applyFilter('FragDenStaat', this)" title="Erfolgreiche Informationsfreiheitsanfragen">FragDenStaat</button>
                <button class="filter-btn" data-cat="Wirtschaft" onclick="applyFilter('Wirtschaft', this)">Wirtschaft</button>
                <button class="filter-btn" data-cat="Ausland" onclick="applyFilter('Ausland', this)">Ausland</button>
                <button class="filter-btn" data-cat="taz" onclick="applyFilter('taz', this)">taz</button>
                <button class="filter-btn" data-cat="NZZ" onclick="applyFilter('NZZ', this)">NZZ</button>
                <button class="filter-btn" data-cat="Meinungsmache" onclick="applyFilter('Meinungsmache', this)">Meinungsmache.de</button>
                <button class="filter-btn" data-cat="favorites" onclick="applyFilter('favorites', this)" title="Nur gemerkte Artikel">★ Favoriten</button>

                <!-- INTERNATIONALE QUELLEN. Alle Adressen wurden am 09.08.2026
                     einzeln live geprueft, siehe Kommentar bei CONFIG.feeds. -->
                <button class="filter-btn" data-cat="Euronews" onclick="applyFilter('Euronews', this)">Euronews</button>
                <button class="filter-btn" data-cat="Euronews EN" onclick="applyFilter('Euronews EN', this)">Euronews EN</button>
                <button class="filter-btn" data-cat="ORF" onclick="applyFilter('ORF', this)">ORF</button>
                <button class="filter-btn" data-cat="derStandard" onclick="applyFilter('derStandard', this)">derStandard</button>
                <button class="filter-btn" data-cat="SRF" onclick="applyFilter('SRF', this)">SRF</button>
                <button class="filter-btn" data-cat="EU-Kommission" onclick="applyFilter('EU-Kommission', this)">EU-Kommission</button>
                <button class="filter-btn" data-cat="Le Monde" onclick="applyFilter('Le Monde', this)">Le Monde</button>
                <button class="filter-btn" data-cat="NYT World" onclick="applyFilter('NYT World', this)">NYT World</button>
                <button class="filter-btn" data-cat="NPR" onclick="applyFilter('NPR', this)">NPR</button>
                <button class="filter-btn" data-cat="CBC" onclick="applyFilter('CBC', this)">CBC</button>
                <button class="filter-btn" data-cat="ABC Australia" onclick="applyFilter('ABC Australia', this)">ABC Australia</button>
                <button class="filter-btn" data-cat="BBC World" onclick="applyFilter('BBC World', this)">BBC World</button>
                <button class="filter-btn" data-cat="Al Jazeera" onclick="applyFilter('Al Jazeera', this)">Al Jazeera</button>
                <button class="filter-btn" data-cat="DW English" onclick="applyFilter('DW English', this)">DW English</button>
                <button class="filter-btn" data-cat="CNN World" onclick="applyFilter('CNN World', this)">CNN World</button>
                <button class="filter-btn" data-cat="NZZ International" onclick="applyFilter('NZZ International', this)">NZZ International</button>
                <button class="filter-btn" data-cat="Times of India" onclick="applyFilter('Times of India', this)">Times of India</button>
                <button class="filter-btn" data-cat="Japan Times" onclick="applyFilter('Japan Times', this)">Japan Times</button>
                <button class="filter-btn" data-cat="UN News" onclick="applyFilter('UN News', this)">UN News</button>
            </div>

            <!-- ==========================================================
                 HERKUNFT: Regionen und Sprachen
                 Die Zaehler werden ausschliesslich aus den tatsaechlich
                 geladenen Meldungen berechnet, nichts ist fest verdrahtet.
                 ========================================================== -->
            <details class="herkunft-box" id="herkunft-box">
                <summary class="herkunft-summary">
                    <span>Region und Sprache</span>
                    <span class="herkunft-hinweis" id="herkunft-hinweis">alle</span>
                </summary>
                <div class="region-bar" role="group" aria-label="Nach Herkunftsregion filtern">
                    <span class="region-bar-label" id="region-bar-label">Region</span>
                    <button class="region-btn active" data-region="all" aria-pressed="true"
                            onclick="applyRegion('all', this)">Alle <span class="count-badge">0</span></button>
                    <button class="region-btn" data-region="Deutschland" aria-pressed="false"
                            onclick="applyRegion('Deutschland', this)">Deutschland <span class="count-badge">0</span></button>
                    <button class="region-btn" data-region="Europa" aria-pressed="false"
                            onclick="applyRegion('Europa', this)">Europa <span class="count-badge">0</span></button>
                    <button class="region-btn" data-region="Nordamerika" aria-pressed="false"
                            onclick="applyRegion('Nordamerika', this)">Nordamerika <span class="count-badge">0</span></button>
                    <button class="region-btn" data-region="Asien-Pazifik" aria-pressed="false"
                            onclick="applyRegion('Asien-Pazifik', this)">Asien und Pazifik <span class="count-badge">0</span></button>
                    <button class="region-btn" data-region="Weltweit" aria-pressed="false"
                            onclick="applyRegion('Weltweit', this)">Weltweit <span class="count-badge">0</span></button>
                </div>
                <div class="region-bar" role="group" aria-label="Nach Sprache filtern">
                    <span class="region-bar-label">Sprache</span>
                    <button class="lang-btn active" data-lang="all" aria-pressed="true"
                            onclick="applyLang('all', this)">Alle</button>
                    <button class="lang-btn" data-lang="de" aria-pressed="false"
                            onclick="applyLang('de', this)">Deutsch</button>
                    <button class="lang-btn" data-lang="en" aria-pressed="false"
                            onclick="applyLang('en', this)">Englisch</button>
                    <button class="lang-btn" data-lang="fr" aria-pressed="false"
                            onclick="applyLang('fr', this)">Franz&ouml;sisch</button>
                </div>
            </details>
            <!-- Ladebalken bleibt IMMER sichtbar (auch bei geschlossenem Panel) -->
            <div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>

            <!-- EINSTELLUNGS-PANEL: auf dem Handy ein Bottom-Sheet, auf dem Desktop
                 ein aufklappbarer Bereich. Gruppiert & beschriftet statt Button-Wand. -->
            <div class="settings-backdrop" id="settings-backdrop" onclick="toggleSettings(false)" hidden></div>
            <div class="settings-drawer" id="settings-drawer" role="dialog" aria-modal="false"
                 aria-label="Einstellungen und Werkzeuge" hidden>
                <div class="settings-head">
                    <span class="settings-title">⚙ Einstellungen &amp; Werkzeuge</span>
                    <button class="settings-close" onclick="toggleSettings(false)" aria-label="Einstellungen schließen">&times;</button>
                </div>

                <div class="extra-controls">
                    <div class="settings-group">
                        <span class="settings-group-label" id="grp-sort">Sortierung &amp; Zeitraum</span>
                        <div class="settings-group-body">
                            <label for="sortSelect" class="visually-hidden">Sortierung</label>
                            <select id="sortSelect" class="refresh-btn" aria-label="Sortierung" onchange="applySort(this.value)">
                                <option value="newest">Neueste zuerst</option>
                                <option value="oldest">Älteste zuerst</option>
                                <option value="title">Titel (A–Z)</option>
                                <option value="region">Nach Region</option>
                                <option value="source">Quelle (A–Z)</option>
                            </select>
                            <label for="rangeSelect" class="visually-hidden">Zeitraum</label>
                            <select id="rangeSelect" class="refresh-btn" aria-label="Zeitraum" onchange="applyTimeRange(this.value)">
                                <option value="all">Ganzer Zeitraum</option>
                                <option value="1h">Letzte Stunde</option>
                                <option value="6h">Letzte 6 Stunden</option>
                                <option value="today">Heute</option>
                                <option value="24h">Letzte 24 Stunden</option>
                                <option value="7d">Letzte 7 Tage</option>
                            </select>
                        </div>
                    </div>

                    <div class="settings-group">
                        <span class="settings-group-label">Aktualisieren</span>
                        <div class="settings-group-body">
                            <button class="refresh-btn" onclick="loadLiveFeeds(true)" title="Holt alle Quellen neu, unabhängig von der Schonfrist">🔄 Jetzt laden</button>
                            <button class="refresh-btn" onclick="toggleAutoRefresh()">⏱ Auto-Refresh</button>
                            <button class="refresh-btn" onclick="markAllRead()" title="Alle als gelesen markieren" aria-label="Alle als gelesen markieren">✓ Alle gelesen</button>
                            <button class="refresh-btn" onclick="openHistory()" title="Zuletzt gelesene Artikel anzeigen" aria-label="Leseverlauf anzeigen">🕘 Verlauf</button>
                            <span class="auto-refresh-indicator" id="auto-refresh-indicator">Auto-Refresh: Aus</span>
                        </div>
                    </div>

                    <div class="settings-group">
                        <span class="settings-group-label">Darstellung &amp; Lesen</span>
                        <div class="settings-group-body">
                            <button class="refresh-btn" onclick="toggleTheme()" title="Hell/Dunkel umschalten" aria-label="Design umschalten">🌓 Design</button>
                            <button class="refresh-btn" onclick="cycleFontSize()" title="Schriftgröße ändern (Lesekomfort)" aria-label="Schriftgröße ändern" id="fontsize-btn">A± Schrift</button>
                            <button class="refresh-btn" onclick="toggleCompactMode()" title="Kompakte Listenansicht" aria-label="Kompakte Ansicht umschalten" aria-pressed="false" id="compact-btn">☰ Kompakt</button>
                        </div>
                    </div>

                    <div class="settings-group">
                        <span class="settings-group-label">Barrierefreiheit</span>
                        <div class="settings-group-body">
                            <button class="refresh-btn" onclick="toggleContrast()" title="Hohen Kontrast umschalten" aria-label="Hohen Kontrast umschalten" aria-pressed="false" id="contrast-btn">🔆 Kontrast</button>
                            <button class="refresh-btn" onclick="toggleReduceMotion()" title="Animationen reduzieren" aria-label="Animationen reduzieren" aria-pressed="false" id="motion-btn">🎞 Ruhe</button>
                            <button class="refresh-btn" onclick="toggleDyslexia()" title="Gut lesbare Schrift (mehr Abstand)" aria-label="Gut lesbare Schrift umschalten" aria-pressed="false" id="dyslexia-btn">🅰 Lesbar</button>
                        </div>
                    </div>

                    <div class="settings-group settings-group-wide">
                        <span class="settings-group-label">Quellen auswählen
                            <span id="source-toggle-count" class="stc"></span></span>
                        <p class="settings-hint">Abgewählte Quellen werden gar nicht erst geladen –
                           das beschleunigt den Aufruf und spart mobiles Datenvolumen.</p>
                        <div class="settings-group-body source-toggles" id="source-toggles">
                            <!-- wird dynamisch aus CONFIG.feeds befüllt -->
                        </div>
                        <div class="settings-group-body" style="margin-top:8px;">
                            <button class="refresh-btn" onclick="setAllSources(true)">Alle an</button>
                            <button class="refresh-btn" onclick="setAllSources(false)">Alle aus</button>
                        </div>
                    </div>

                    <div class="settings-group">
                        <span class="settings-group-label">Daten &amp; Hilfe</span>
                        <div class="settings-group-body">
                            <button class="export-btn" onclick="exportNews()" title="Aktuelle Ansicht als JSON exportieren">⬇ JSON</button>
                            <button class="export-btn" onclick="exportNewsCSV()" title="Aktuelle Ansicht als CSV exportieren">⬇ CSV</button>
                            <button class="export-btn" onclick="exportNewsRSS()" title="Aktuelle Ansicht als RSS-2.0-Datei exportieren">⬇ RSS</button>
                            <button class="export-btn" onclick="openFeedsOpml()" title="Alle 40 Quellen als OPML-Liste für den Feedreader">📡 OPML</button>
                            <button class="refresh-btn" onclick="toggleShortcutHelp()" title="Tastenkürzel anzeigen" aria-label="Tastenkürzel">⌨ Hilfe</button>
                        </div>
                    </div>
                </div>
            </div>

            <div class="status-row">
                <span id="results-count" class="meta-info" aria-live="polite"></span>
                <span id="last-updated" class="meta-info"></span>
            </div>

            <!-- QUELLEN-STATUS: zeigt faktisch, welche Feeds beim letzten Ladevorgang
                 erreichbar waren und wie viele Meldungen sie geliefert haben.
                 Transparenz statt Blackbox – hilft auch bei der Fehlersuche. -->
            <details class="source-status" id="source-status" hidden>
                <summary id="source-status-summary">Quellen-Status</summary>
                <div class="source-status-list" id="source-status-list"></div>
            </details>
        </div>
    </header>

    <main>
        <!-- SEO/INDEXIERUNG: Immer im DOM vorhandene, faktische Einleitung.
             Sie beschreibt die Seite für Suchmaschinen unabhängig davon, ob die
             dynamischen RSS-Inhalte bereits per JavaScript geladen wurden. -->


        <section id="intro" class="intro-section">
            <h1 class="intro-h1">Nachrichten Kaipfstr, aktueller Nachrichten-Aggregator für Deutschland und die Welt</h1>
            <p class="intro-kurz">
                <strong>Nachrichten Kaipfstr</strong> bündelt aktuelle Schlagzeilen aus
                40 öffentlich verfügbaren RSS-Feeds und stellt sie chronologisch,
                durchsuchbar und nach Herkunft gegliedert auf einer Seite dar.
            </p>
            <details class="intro-mehr">
                <summary>Welche Quellen ausgewertet werden</summary>
                <p>
                    Aus <strong>Deutschland</strong> kommen unter anderem
                    <strong>Tagesschau</strong>, <strong>Deutschlandfunk</strong>,
                    <strong>Bundesregierung</strong>, <strong>Spiegel</strong>,
                    <strong>FAZ</strong>, <strong>Zeit</strong>, <strong>ZDF</strong>,
                    <strong>Deutsche Welle</strong>, <strong>Süddeutsche Zeitung</strong>,
                    <strong>Tagesspiegel</strong>, <strong>heise online</strong>,
                    <strong>taz</strong> sowie <strong>FragDenStaat</strong> (erfolgreiche
                    Informationsfreiheitsanfragen nach IFG und UIG, betrieben von der
                    Open Knowledge Foundation Deutschland e.&nbsp;V.).
                </p>
                <p>
                    International kommen Quellen aus <strong>Europa</strong>
                    (Euronews, ORF, Der Standard, SRF, Europäische Kommission, Le Monde,
                    Neue Zürcher Zeitung), <strong>Nordamerika</strong>
                    (The New York Times, NPR, CBC, CNN International),
                    <strong>Asien und dem Pazifik</strong>
                    (ABC Australia, The Japan Times, The Times of India) sowie
                    <strong>weltweit</strong> ausgerichtete Angebote
                    (BBC News, Al Jazeera English, UN News) hinzu.
                    Meldungen in anderen Sprachen sind als solche gekennzeichnet und
                    lassen sich gezielt ein- oder ausblenden.
                </p>
                <p>
                    Zusätzlich lassen sich die Themenbereiche <strong>Wirtschaft</strong>
                    und <strong>Ausland</strong> gezielt filtern. Berichten mehrere Quellen
                    über dasselbe Ereignis, weist die jeweilige Meldung das aus.
                    Unter den Nachrichten stehen außerdem <strong>Marktdaten</strong>:
                    Devisen als Referenzkurse der Europäischen Zentralbank sowie
                    Kryptowerte. Alle Inhalte und Urheberrechte verbleiben bei den
                    jeweiligen Quellen; über den Quell-Link gelangen Sie jeweils zum
                    Originalartikel.
                </p>
            </details>
        </section>

        <div id="loading-indicator" class="loader-container" role="status" aria-live="polite">
            <div class="spinner"></div>
            <p>Aktuelle Nachrichten werden geladen …<br><span style="font-size:0.8rem; color:#888">RSS-Feeds werden über öffentliche Proxys aggregiert</span></p>
        </div>

        <!-- NOSCRIPT-FALLBACK: Ohne JavaScript sehen Crawler & Nutzer trotzdem
             sinnvolle, faktische Inhalte samt direkter Links zu den Original-RSS-Feeds.
             Das verbessert die Indexierbarkeit erheblich. -->
        <noscript>
            <div class="noscript-box">
                <h2>Hinweis: JavaScript ist deaktiviert</h2>
                <p>
                    Diese Seite lädt die aktuellen Nachrichten dynamisch per JavaScript.
                    Ohne aktiviertes JavaScript können die Live-Feeds nicht angezeigt werden.
                    Sie erreichen die Originalquellen jedoch direkt über die folgenden
                    offiziellen RSS-Feeds:
                </p>
                <ul>
                    <li><a href="https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml" rel="nofollow noopener">Tagesschau – Alle Meldungen (RSS)</a></li>
                    <li><a href="https://www.deutschlandfunk.de/politikportal-100.rss" rel="nofollow noopener">Deutschlandfunk – Politikportal (RSS)</a></li>
                    <li><a href="https://www.bundesregierung.de/service/rss/breg-de/1151242/feed.xml" rel="nofollow noopener">Bundesregierung – Aktuelles (RSS)</a></li>
                    <li><a href="https://www.spiegel.de/schlagzeilen/tops/index.rss" rel="nofollow noopener">Spiegel – Schlagzeilen (RSS)</a></li>
                    <li><a href="https://www.faz.net/rss/aktuell/" rel="nofollow noopener">FAZ – Aktuell (RSS)</a></li>
                    <li><a href="https://newsfeed.zeit.de/index" rel="nofollow noopener">Die Zeit – Startseite (RSS)</a></li>
                    <li><a href="https://www.zdf.de/rss/zdf/nachrichten" rel="nofollow noopener">ZDF – Nachrichten (RSS)</a></li>
                    <li><a href="https://rss.dw.com/xml/rss-de-all" rel="nofollow noopener">Deutsche Welle – Alle Themen (RSS)</a></li>
                    <li><a href="https://rss.sueddeutsche.de/rss/Topthemen" rel="nofollow noopener">Süddeutsche Zeitung – Topthemen (RSS)</a></li>
                    <li><a href="https://www.tagesspiegel.de/contentexport/feed/home" rel="nofollow noopener">Tagesspiegel – Startseite (RSS)</a></li>
                    <li><a href="https://www.heise.de/rss/heise-atom.xml" rel="nofollow noopener">heise online – News (Atom)</a></li>
                    <li><a href="https://fragdenstaat.de/anfragen/rss/?&amp;status=erfolgreich&amp;campaign=-" rel="nofollow noopener">FragDenStaat – erfolgreiche Informationsfreiheitsanfragen (RSS)</a></li>
                    <li><a href="https://www.tagesschau.de/wirtschaft/index~rss2.xml" rel="nofollow noopener">Tagesschau – Wirtschaft (RSS)</a></li>
                    <li><a href="https://www.tagesschau.de/ausland/index~rss2.xml" rel="nofollow noopener">Tagesschau – Ausland (RSS)</a></li>
                    <li><a href="https://taz.de/!p4608;rss/" rel="nofollow noopener">taz – Nachrichten (RSS)</a></li>
                    <li><a href="https://www.nzz.ch/recent.rss" rel="nofollow noopener">Neue Zürcher Zeitung – Aktuell (RSS)</a></li>
                    <li><a href="https://www.zdf.de/rss/zdf/politik" rel="nofollow noopener">ZDF – Politik (RSS)</a></li>
                    <li><a href="https://newsfeed.zeit.de/politik/index" rel="nofollow noopener">Die Zeit – Politik (RSS)</a></li>
                </ul>

                <p>
                    Zusätzlich werden internationale Quellen ausgewertet. Jede dieser
                    Adressen wurde am 09.08.2026 einzeln aufgerufen und lieferte
                    gültiges RSS oder Atom mit Einträgen:
                </p>
                <h3>Europa</h3>
                <ul>
                    <li><a href="https://de.euronews.com/rss" rel="nofollow noopener">Euronews, deutschsprachige Ausgabe (RSS)</a></li>
                    <li><a href="https://www.euronews.com/rss" rel="nofollow noopener">Euronews, englischsprachige Ausgabe (RSS)</a></li>
                    <li><a href="https://rss.orf.at/news.xml" rel="nofollow noopener">ORF, Nachrichten (RSS)</a></li>
                    <li><a href="https://www.derstandard.at/rss" rel="nofollow noopener">Der Standard, alle Meldungen (RSS)</a></li>
                    <li><a href="https://www.srf.ch/news/bnf/rss/1646" rel="nofollow noopener">SRF, News (RSS)</a></li>
                    <li><a href="https://ec.europa.eu/commission/presscorner/api/rss?language=de&amp;pagesize=20" rel="nofollow noopener">Europäische Kommission, Pressemitteilungen auf Deutsch (RSS)</a></li>
                    <li><a href="https://www.lemonde.fr/rss/une.xml" rel="nofollow noopener">Le Monde, Titelseite (RSS, französisch)</a></li>
                </ul>
                <h3>Nordamerika</h3>
                <ul>
                    <li><a href="https://rss.nytimes.com/services/xml/rss/nyt/World.xml" rel="nofollow noopener">The New York Times, World (RSS, englisch)</a></li>
                    <li><a href="https://feeds.npr.org/1004/rss.xml" rel="nofollow noopener">NPR, World (RSS, englisch)</a></li>
                    <li><a href="https://www.cbc.ca/webfeed/rss/rss-world" rel="nofollow noopener">CBC, World (RSS, englisch)</a></li>
                </ul>
                <h3>Asien und Pazifik</h3>
                <ul>
                    <li><a href="https://www.abc.net.au/news/feed/51120/rss.xml" rel="nofollow noopener">ABC Australia, World (RSS, englisch)</a></li>
                </ul>
                <h3>Weltweit</h3>
                <ul>
                    <li><a href="https://feeds.bbci.co.uk/news/world/rss.xml" rel="nofollow noopener">BBC News, World (RSS, englisch)</a></li>
                    <li><a href="https://www.aljazeera.com/xml/rss/all.xml" rel="nofollow noopener">Al Jazeera English (RSS, englisch)</a></li>
                    <li><a href="https://rss.dw.com/rdf/rss-en-all" rel="nofollow noopener">Deutsche Welle, englische Ausgabe (RSS)</a></li>
                    <li><a href="http://rss.cnn.com/rss/edition_world.rss" rel="nofollow noopener">CNN International, World (RSS, englisch)</a></li>
                    <li><a href="https://www.nzz.ch/international.rss" rel="nofollow noopener">Neue Zürcher Zeitung, International (RSS)</a></li>
                    <li><a href="https://timesofindia.indiatimes.com/rssfeeds/296589292.cms" rel="nofollow noopener">The Times of India, World (RSS, englisch)</a></li>
                    <li><a href="https://www.japantimes.co.jp/feed/" rel="nofollow noopener">The Japan Times (RSS, englisch)</a></li>
                    <li><a href="https://news.un.org/feed/subscribe/en/news/all/rss.xml" rel="nofollow noopener">UN News, englische Ausgabe (RSS)</a></li>
                </ul>
            </div>
        </noscript>

        <div id="news-container" class="news-grid">
            </div>

        <div id="no-results" style="display:none; text-align:center; padding:2rem; color:var(--text-secondary);">
            Keine Nachrichten gefunden.
        </div>

        <!-- ==============================================================
             MAERKTE
             Zwei belegbare, kostenlose und schluessellose Datenquellen:
               1. Devisen: EZB-Referenzkurse ueber api.frankfurter.app
               2. Kryptowerte: CoinGecko
             Es werden ausschliesslich Werte angezeigt, die tatsaechlich
             abgerufen wurden. Schlaegt ein Abruf fehl, erscheint ein
             Hinweis statt eines geschaetzten oder alten Wertes.
             ============================================================== -->
        <section class="markets-section" id="maerkte" aria-labelledby="markets-title" hidden>
            <div class="markets-head">
                <h2 id="markets-title">Märkte</h2>
                <span class="markets-updated" id="markets-updated"></span>
                <button type="button" class="markets-reload" onclick="loadMarkets(true)"
                        aria-label="Marktdaten neu laden">Aktualisieren</button>
            </div>
            <details class="markets-details">
                <summary>Woher die Kurse stammen und was sie nicht sind</summary>
                <p class="markets-note" id="markets-note"></p>
            </details>
            <div id="markets-body"></div>
        </section>
    </main>

    <div id="article-modal" class="modal-overlay" role="dialog" aria-modal="true"
         aria-labelledby="modal-title" aria-hidden="true">
        <div class="modal-content">
            <span class="close-modal" onclick="closeModal()" role="button" tabindex="0"
                  aria-label="Dialog schließen" title="Schließen (Esc)">&times;</span>
            <div id="modal-body">
                </div>
        </div>
    </div>

    <!-- Tastenkürzel-Hilfe (Barrierefreiheit & Usability) -->
    <div id="shortcut-help" class="modal-overlay" role="dialog" aria-modal="true"
         aria-labelledby="shortcut-title" aria-hidden="true">
        <div class="modal-content" style="max-width:520px;">
            <span class="close-modal" onclick="toggleShortcutHelp(false)" role="button" tabindex="0" aria-label="Schließen">&times;</span>
            <h2 id="shortcut-title" style="margin-bottom:1rem;">Tastenkürzel</h2>
            <ul style="list-style:none; line-height:2.2;">
                <li><kbd>Strg</kbd> + <kbd>K</kbd> &nbsp;— Suche fokussieren</li>
                <li><kbd>R</kbd> &nbsp;— Nachrichten neu laden</li>
                <li><kbd>Esc</kbd> &nbsp;— Dialog/Einzelansicht schließen</li>
                <li><kbd>T</kbd> &nbsp;— Hell/Dunkel-Design umschalten</li>
                <li><kbd>C</kbd> &nbsp;— Kompakte Listenansicht umschalten</li>
                <li><kbd>S</kbd> &nbsp;— Einstellungen öffnen/schließen</li>
                <li><kbd>←</kbd> <kbd>→</kbd> &nbsp;— Im Artikel blättern</li>
                <li><kbd>?</kbd> &nbsp;— Diese Hilfe öffnen/schließen</li>
                <li><kbd>Pos1</kbd> &nbsp;— Nach oben springen</li>
            </ul>
        </div>
    </div>

    <div id="error-display" class="error-toast" role="alert" aria-live="assertive"></div>

    <!-- Nach-oben-Button (erscheint beim Scrollen) -->
    <button id="back-to-top" class="back-to-top" onclick="scrollToTop()"
            aria-label="Nach oben scrollen" title="Nach oben">↑</button>

    <!-- „Neue Meldungen"-Hinweis: erscheint, wenn eine Hintergrund-Aktualisierung
         echte neue Artikel gefunden hat, ohne den aktuellen Lesefluss zu stören. -->
    <button id="new-items-banner" class="new-items-banner" onclick="showNewItems()"
            aria-live="polite" hidden>↑ <span id="new-items-count">0</span> neue Meldungen</button>

    <footer>
        <p>&copy; 2026 Nachrichten Kaipfstr — Nachrichten-Aggregator.</p>
        <p style="font-size: 0.8rem; color: #888; margin-top: 10px; max-width: 720px; margin-left:auto; margin-right:auto;">
            Diese Seite bündelt öffentlich verfügbare RSS-Feeds etablierter Quellen
            (u. a. Tagesschau, Deutschlandfunk, Bundesregierung, Spiegel, FAZ, Zeit)
            und stellt sie übersichtlich dar. Alle Inhalte und Urheberrechte verbleiben
            bei den jeweiligen Quellen; über den Quell-Link gelangen Sie zum Originalartikel.
            Nachrichten werden clientseitig zwischengespeichert (localStorage), damit
            Wiederholbesuche schnell laden — es werden keine personenbezogenen Daten an
            uns übertragen.
        </p>
        <nav class="footer-nav" aria-label="Weitere Informationen">
            <a href="#" onclick="goHome();return false;">Startseite</a>
            <a href="/sitemap.xml">Sitemap</a>
            <a href="/feeds.opml" title="Alle 40 Quellen als OPML für den eigenen Feedreader">Quellen (OPML)</a>
            <a href="/robots.txt">robots.txt</a>
            <a href="/humans.txt">humans.txt</a>
            <a href="#" onclick="toggleShortcutHelp(true);return false;">Tastenkürzel</a>
        </nav>

        <!-- SEO / INDEXIERUNG: Crawlbare Links zu ALLEN Quellen-Unterseiten.
             Ohne diese echten <a>-Links könnte ein Suchmaschinen-Crawler die
             Unterseiten nicht entdecken – Filter-Buttons allein reichen dafür
             nicht, weil Crawler kein JavaScript-Klickverhalten nachbilden.
             Wird beim Laden aus CONFIG.feeds befüllt (renderCategoryLinks). -->
        <nav class="category-links" id="category-links" aria-label="Alle Quellen">
            <a href="/quellen" class="cat-link-all">Alle Quellen im Überblick</a>
        </nav>

        <!-- SEO / INDEXIERUNG: Crawlbarer Index ALLER geladenen Meldungen.
             Jeder Eintrag ist ein echter <a>-Link auf die PERMANENTE Quell-URL,
             damit Suchmaschinen jede einzelne Nachricht entdecken & indexieren
             können. Wird per JavaScript aus Feed + Archiv befüllt. -->
        <section class="article-index" aria-labelledby="article-index-title">
            <details>
                <summary id="article-index-title">Alle Meldungen (Index für Suchmaschinen)</summary>
                <nav id="article-index-list" aria-label="Index aller Meldungen">
                    <!-- wird dynamisch befüllt (renderArticleIndex) -->
                </nav>
            </details>
        </section>

        <!-- VERSIONSSTAND: zeigt an, wann diese Datei zuletzt bearbeitet wurde.
             So lässt sich prüfen, ob wirklich die neueste Fassung ausgeliefert
             wird (z. B. nach einem Deployment oder bei Verdacht auf alten Cache).
             Der Wert wird bei jeder Bearbeitung im Skript gepflegt. -->
        <div class="site-version" id="site-version">
            <span class="sv-label">Stand der Website:</span>
            <time id="sv-time" datetime=""></time>
            <span class="sv-sep" aria-hidden="true">·</span>
            <span id="sv-age" class="sv-age"></span>
            <span class="sv-sep" aria-hidden="true">·</span>
            <span id="sv-cache" class="sv-cache" title="Zeitpunkt, zu dem dieser Seitenaufruf geladen wurde"></span>
        </div>

        <div class="security-seal">
            Statische Seite · Kein Tracking · Keine Cookies · Quelloffener Client
        </div>
    </footer>

    <script>
        // --- CONFIGURATION & STATE ---
        const CONFIG = {
            feeds: [
                { url: 'https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml', cat: 'Tagesschau', class: 'badge-tagesschau', direkt: true },
                { url: 'https://meinungsmache.de/rss/politik', cat: 'Politik', class: 'badge-politik' },
                { url: 'https://meinungsmache.de/rss/meinungen', cat: 'Meinungen', class: 'badge-meinungen' },
                { url: 'https://www.deutschlandfunk.de/politikportal-100.rss', cat: 'Deutschlandfunk', class: 'badge-Deutschlandfunk' },
                { url: 'https://www.bundesregierung.de/service/rss/breg-de/1151242/feed.xml', cat: 'Bundesregierung', class: 'badge-Bundesregierung' },
                { url: 'https://www.spiegel.de/schlagzeilen/tops/index.rss', cat: 'Spiegel', class: 'badge-spiegel' },
                { url: 'https://www.faz.net/rss/aktuell/', cat: 'FAZ', class: 'badge-faz' },
                // BUGFIX: Der frühere Zeit-Feed (www.zeit.de/gesellschaft/index.rss)
                // lieferte über die Proxys keine Items mehr (Weiterleitung/Blockade).
                // Ersetzt durch den funktionierenden offiziellen Feed newsfeed.zeit.de.
                { url: 'https://newsfeed.zeit.de/index', cat: 'Zeit', class: 'badge-zeit' },
                { url: 'https://meinungsmache.de/rss/all', cat: 'Meinungsmache', class: 'badge-Meinungsmache' },
                // NEUE, GEPRÜFTE QUELLEN (liefern gültiges RSS/Atom, Stand 2026-07-06):
                { url: 'https://www.zdf.de/rss/zdf/nachrichten', cat: 'ZDF', class: 'badge-zdf' },
                { url: 'https://rss.dw.com/xml/rss-de-all', cat: 'Deutsche Welle', class: 'badge-dw', direkt: true },
                { url: 'https://rss.sueddeutsche.de/rss/Topthemen', cat: 'Süddeutsche', class: 'badge-sueddeutsche' },
                { url: 'https://www.tagesspiegel.de/contentexport/feed/home', cat: 'Tagesspiegel', class: 'badge-tagesspiegel' },
                { url: 'https://www.heise.de/rss/heise-atom.xml', cat: 'Heise', class: 'badge-heise', direkt: true },
                // FragDenStaat – erfolgreiche Informationsfreiheitsanfragen (IFG/UIG).
                // Geprüft am 2026-07-23: liefert gültiges RSS 2.0, sowohl direkt als
                // auch über den rss2json-Proxy (status "ok"). Betrieben von der
                // Open Knowledge Foundation Deutschland e. V.
                { url: 'https://fragdenstaat.de/anfragen/rss/?&status=erfolgreich&campaign=-', cat: 'FragDenStaat', class: 'badge-fragdenstaat' },
                // WEITERE GEPRÜFTE QUELLEN (Live-Test am 2026-07-23: gültiges RSS mit Items).
                // Zwei davon sind THEMATISCH statt nach Sender sortiert – dadurch lassen
                // sich Wirtschaft und Ausland gezielt filtern.
                { url: 'https://www.tagesschau.de/wirtschaft/index~rss2.xml', cat: 'Wirtschaft', class: 'badge-wirtschaft', direkt: true },
                { url: 'https://www.tagesschau.de/ausland/index~rss2.xml', cat: 'Ausland', class: 'badge-ausland', direkt: true },
                { url: 'https://taz.de/!p4608;rss/', cat: 'taz', class: 'badge-taz' },
                { url: 'https://www.nzz.ch/recent.rss', cat: 'NZZ', class: 'badge-nzz' },
                // Ergänzen die bereits vorhandenen Kanäle um mehr Meldungen
                // (Duplikate werden über den Quell-Link zuverlässig entfernt).
                { url: 'https://www.zdf.de/rss/zdf/politik', cat: 'ZDF', class: 'badge-zdf' },
                { url: 'https://newsfeed.zeit.de/politik/index', cat: 'Zeit', class: 'badge-zeit' },

                // ============================================================
                //  INTERNATIONALE QUELLEN
                // ============================================================
                //  Jede dieser Adressen wurde am 09.08.2026 einzeln live
                //  abgerufen und musste drei Bedingungen erfuellen, sonst wurde
                //  sie NICHT aufgenommen:
                //    1. HTTP-Status 200
                //    2. gueltig parsebares RSS, Atom oder RDF
                //    3. mindestens ein echter Eintrag im Feed
                //
                //  Aussortiert wurden dabei (Beleg fuer die Pruefung):
                //    - Reuters  : Hostname loest nicht mehr auf, Feed eingestellt
                //    - AP News  : HTTP 404
                //    - France24 (deutsch): HTTP 404
                //    - UN News (deutsch) : liefert kein XML
                //    - El Pais           : XML fehlerhaft
                //    - Kyiv Independent  : HTTP 404
                //
                //  "region" und "lang" steuern die Regions- und Sprachfilter.
                //  Feeds ohne diese Angaben gelten als Deutschland/deutsch.
                // ============================================================

                // ---- Europa ----
                { url: 'https://de.euronews.com/rss', cat: 'Euronews', class: 'badge-euronews', region: 'Europa', lang: 'de', publisher: 'Euronews' },
                { url: 'https://www.euronews.com/rss', cat: 'Euronews EN', class: 'badge-euronews', region: 'Europa', lang: 'en', publisher: 'Euronews' },
                { url: 'https://rss.orf.at/news.xml', cat: 'ORF', class: 'badge-orf', region: 'Europa', lang: 'de', publisher: 'ORF, Oesterreichischer Rundfunk', direkt: true },
                { url: 'https://www.derstandard.at/rss', cat: 'derStandard', class: 'badge-standard', region: 'Europa', lang: 'de', publisher: 'Der Standard, Wien' },
                { url: 'https://www.srf.ch/news/bnf/rss/1646', cat: 'SRF', class: 'badge-srf', region: 'Europa', lang: 'de', publisher: 'Schweizer Radio und Fernsehen', direkt: true },
                { url: 'https://ec.europa.eu/commission/presscorner/api/rss?language=de&pagesize=20', cat: 'EU-Kommission', class: 'badge-eu', region: 'Europa', lang: 'de', publisher: 'Europaeische Kommission', direkt: true },
                { url: 'https://www.lemonde.fr/rss/une.xml', cat: 'Le Monde', class: 'badge-lemonde', region: 'Europa', lang: 'fr', publisher: 'Le Monde, Paris' },

                // ---- Nordamerika ----
                { url: 'https://rss.nytimes.com/services/xml/rss/nyt/World.xml', cat: 'NYT World', class: 'badge-nyt', region: 'Nordamerika', lang: 'en', publisher: 'The New York Times', direkt: true },
                { url: 'https://feeds.npr.org/1004/rss.xml', cat: 'NPR', class: 'badge-npr', region: 'Nordamerika', lang: 'en', publisher: 'NPR, National Public Radio' },
                { url: 'https://www.cbc.ca/webfeed/rss/rss-world', cat: 'CBC', class: 'badge-cbc', region: 'Nordamerika', lang: 'en', publisher: 'CBC, Canadian Broadcasting Corporation' },

                // ---- Asien und Pazifik ----
                { url: 'https://www.abc.net.au/news/feed/51120/rss.xml', cat: 'ABC Australia', class: 'badge-abcau', region: 'Asien-Pazifik', lang: 'en', publisher: 'ABC, Australian Broadcasting Corporation', preferProxy: 'AllOrigins-Raw' },

                // ---- Weltweit ----
                { url: 'https://feeds.bbci.co.uk/news/world/rss.xml', cat: 'BBC World', class: 'badge-bbc', region: 'Weltweit', lang: 'en', publisher: 'BBC News', preferProxy: 'AllOrigins' },
                { url: 'https://www.aljazeera.com/xml/rss/all.xml', cat: 'Al Jazeera', class: 'badge-aljazeera', region: 'Weltweit', lang: 'en', publisher: 'Al Jazeera Media Network', preferProxy: 'AllOrigins-Raw' },
                { url: 'https://rss.dw.com/rdf/rss-en-all', cat: 'DW English', class: 'badge-dw', region: 'Weltweit', lang: 'en', publisher: 'Deutsche Welle', preferProxy: 'AllOrigins', direkt: true },
                { url: 'http://rss.cnn.com/rss/edition_world.rss', cat: 'CNN World', class: 'badge-cnn', region: 'Weltweit', lang: 'en', publisher: 'CNN International', preferProxy: 'AllOrigins-Raw' },
                { url: 'https://www.nzz.ch/international.rss', cat: 'NZZ International', class: 'badge-nzz', region: 'Europa', lang: 'de', publisher: 'Neue Zuercher Zeitung', preferProxy: 'AllOrigins-Raw' },
                { url: 'https://timesofindia.indiatimes.com/rssfeeds/296589292.cms', cat: 'Times of India', class: 'badge-toi', region: 'Asien-Pazifik', lang: 'en', publisher: 'The Times of India', preferProxy: 'AllOrigins' },
                { url: 'https://www.japantimes.co.jp/feed/', cat: 'Japan Times', class: 'badge-japantimes', region: 'Asien-Pazifik', lang: 'en', publisher: 'The Japan Times, Tokio', preferProxy: 'AllOrigins-Raw' },
                { url: 'https://news.un.org/feed/subscribe/en/news/all/rss.xml', cat: 'UN News', class: 'badge-un', region: 'Weltweit', lang: 'en', publisher: 'United Nations News', preferProxy: 'AllOrigins-Raw' }

                // NICHT AUFGENOMMEN, Stand 09.08.2026:
                //   - The Guardian, World  https://www.theguardian.com/world/rss
                //   - NASA Breaking News   https://www.nasa.gov/rss/dyn/breaking_news.rss
                // Beide liefern beim direkten Aufruf gueltiges RSS, waren aber in
                // mehreren Durchlaeufen ueber keinen der sechs CORS-Proxys zu
                // erreichen. Auf der Seite blieben sie dauerhaft leer und wuerden
                // nur eine tote Filterschaltflaeche erzeugen. Sobald sich die
                // Proxylage aendert, koennen sie jederzeit ergaenzt werden.
                //
                // WICHTIG ZUR METHODE: Ein einzelner fehlgeschlagener Proxy-Test
                // beweist gar nichts. RSS2JSON drosselt nach wenigen Abrufen
                // ausdruecklich ("please use an API key"), und AllOrigins liefert
                // zeitweise HTTP 502 oder 522. Japan Times und UN News galten
                // deshalb zunaechst faelschlich als unerreichbar und wurden nach
                // einer Nachpruefung mit Pausen wieder aufgenommen. Jeder Ausschluss
                // gehoert daher mehrfach und zeitlich verteilt geprueft.
            ],

            // Regionen in fester Reihenfolge fuer Filter und Gruppierung.
            // "Deutschland" ist die Vorgabe fuer alle Feeds ohne Regionsangabe.
            regions: ['Deutschland', 'Europa', 'Nordamerika', 'Asien-Pazifik', 'Weltweit'],

            // Sprachkuerzel zu Klartext, fuer Badges und das lang-Attribut.
            languages: { de: 'Deutsch', en: 'Englisch', fr: 'Franzoesisch' },

            // Primary Proxy: RSS2JSON (Best for formatting)
            // Backup Proxy: AllOrigins (Raw XML via Proxy)
            // Tertiary Proxy: CORS-Anywhere (Fallback)
            proxies: [
                (url) => `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`,
                (url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`,
                (url) => `https://cors-anywhere.herokuapp.com/${url}`,
                (url) => `https://thingproxy.freeboard.io/fetch/${url}`,
                (url) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`,
                (url) => `https://corsproxy.org/?${encodeURIComponent(url)}`
            ],

            // ----------------------------------------------------------------
            // PERFORMANCE-EINSTELLUNGEN (Bugfix Ladezeiten)
            // ----------------------------------------------------------------
            // Zeitlimit pro einzelner Proxy-Anfrage. Ohne Timeout blockierte ein
            // hängender Proxy die komplette Seite (Browser-Default kann 30–120s sein).
            timeoutMs: 8000,
            // Maximale Meldungen pro Feed, die verarbeitet/gerendert werden
            // (schützt vor überlangen Feeds und hält das DOM schlank).
            maxItemsPerFeed: 40,
            // Wie viele Feeds gleichzeitig laden dürfen (Drosselung für Mobilgeräte
            // & langsame Netze; verhindert Verbindungs-Staus). Wird bei mobilen
            // Geräten dynamisch gesenkt (siehe optimizeForMobileEnhanced()).
            maxConcurrentFeeds: 6,

            // ----------------------------------------------------------------
            // PROXY-STRATEGIEN: beschreibt pro Proxy, wie die Antwort zu parsen ist.
            // 'rss2json'  -> JSON mit .items (bereits strukturiert)
            // 'allorigins'-> JSON mit .contents (roher XML-String -> parseXML)
            // 'raw'       -> reiner XML-Text -> parseXML
            // Reihenfolge = Priorität (schnellste/zuverlässigste zuerst).
            // ----------------------------------------------------------------
            proxyStrategies: [
                { build: (url) => `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`, type: 'rss2json', name: 'RSS2JSON' },
                { build: (url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`, type: 'allorigins', name: 'AllOrigins' },
                { build: (url) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`, type: 'raw', name: 'CodeTabs' },
                { build: (url) => `https://corsproxy.io/?${encodeURIComponent(url)}`, type: 'raw', name: 'CorsProxy.io' },
                { build: (url) => `https://thingproxy.freeboard.io/fetch/${url}`, type: 'raw', name: 'ThingProxy' },
                { build: (url) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`, type: 'raw', name: 'AllOrigins-Raw' }
            ]
        };

        let appState = {
            allNews: [],
            filteredNews: [],
            currentFilter: 'all',
            searchQuery: '',
            autoRefresh: false,
            refreshInterval: null,
            favorites: [],          // Liste gemerkter Artikel-IDs (localStorage)
            readIds: [],            // Liste bereits gelesener Artikel-IDs (localStorage)
            sortMode: 'newest',     // Aktuelle Sortierung
            lastUpdated: null,      // Zeitpunkt des letzten erfolgreichen Ladens
            renderLimit: 30,        // Wie viele Karten aktuell gerendert werden (Paginierung)
            feedStatus: [],         // Faktische Ladeergebnisse je Quelle (Transparenz)
            timeRange: 'all',       // Zeitraum-Filter (all | 1h | 6h | today | 24h | 7d)
            disabledSources: [],    // Vom Nutzer abgeschaltete Quellen (localStorage)
            mehrfachberichte: null, // Map: Artikel-ID -> gleiche Meldung bei anderen Quellen
            regionFilter: 'all',    // Herkunftsregion (all | Deutschland | Europa | ...)
            langFilter: 'all'       // Sprache der Meldung (all | de | en | fr)
        };

        // Seitengröße für die Paginierung / „Mehr anzeigen“ / Infinite Scroll.
        const PAGE_SIZE = 30;

        // --- 1. SICHERHEITS- & INTEGRITÄTS-SYSTEM (XSS-SCHUTZ & MONITORING) ---
        //
        // WICHTIGER BUGFIX (Faktenbasiert): Die frühere Version enthielt einen
        // `debugger;`-Aufruf in einem 1-Sekunden-Intervall sowie eine Fenster-
        // größen-Heuristik, die bei geöffneten DevTools (auch angedockt) oder auf
        // bestimmten mobilen Browsern die KOMPLETTE Seite mit "SECURITY BREACH"
        // überschrieb. Das war kein echter Schutz (Client-Code ist immer einsehbar),
        // sondern hat legitime Nutzer, Screenreader und Google-Crawler blockiert.
        // Ersetzt durch echten, nützlichen XSS-Schutz und harmloses Monitoring.
        //
        // ----------------------------------------------------------------------
        // HTML-SANITIZER: Entfernt gefährliche Elemente/Attribute aus Fremd-RSS.
        // RSS-Feeds sind Fremdinhalte -> ohne Bereinigung droht Stored-XSS, wenn
        // `fullContent`/`title`/`description` per innerHTML eingefügt werden.
        // ----------------------------------------------------------------------
        const SecurityUtils = {
            // Erlaubte Tags für Artikel-Vorschau im Modal (Whitelist-Ansatz)
            allowedTags: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol',
                          'li', 'blockquote', 'h2', 'h3', 'h4', 'h5', 'h6', 'span',
                          'figure', 'figcaption', 'img', 'small', 'sub', 'sup', 'hr'],
            allowedAttrs: {
                'a': ['href', 'title', 'target', 'rel'],
                'img': ['src', 'alt', 'title', 'loading', 'width', 'height']
            },

            // Reiner Text: entfernt jegliches HTML komplett (für Titel/Excerpt)
            stripHtml: function(input) {
                if (input === null || input === undefined) return '';
                const doc = new DOMParser().parseFromString(String(input), 'text/html');
                return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
            },

            // Escaped die 5 kritischen HTML-Zeichen (für Attribut-/Textkontexte)
            escapeHtml: function(input) {
                if (input === null || input === undefined) return '';
                return String(input)
                    .replace(/&/g, '&amp;')
                    .replace(/</g, '&lt;')
                    .replace(/>/g, '&gt;')
                    .replace(/"/g, '&quot;')
                    .replace(/'/g, '&#39;');
            },

            // Bereinigt einen HTML-Fragment-String anhand der Whitelist oben.
            sanitizeHtml: function(dirty) {
                if (dirty === null || dirty === undefined) return '';
                const template = document.createElement('template');
                template.innerHTML = String(dirty);
                const walk = (node) => {
                    const children = Array.from(node.childNodes);
                    for (const child of children) {
                        if (child.nodeType === Node.ELEMENT_NODE) {
                            const tag = child.tagName.toLowerCase();
                            if (this.allowedTags.indexOf(tag) === -1) {
                                // Nicht erlaubtes Element: Inhalt behalten, Hülle entfernen
                                while (child.firstChild) {
                                    node.insertBefore(child.firstChild, child);
                                }
                                node.removeChild(child);
                                continue;
                            }
                            // Attribute säubern
                            const allowed = this.allowedAttrs[tag] || [];
                            for (const attr of Array.from(child.attributes)) {
                                const name = attr.name.toLowerCase();
                                const value = attr.value || '';
                                const isEventHandler = name.indexOf('on') === 0;
                                const isDangerousUrl = /^(javascript|data|vbscript):/i.test(value.trim());
                                if (allowed.indexOf(name) === -1 || isEventHandler ||
                                    (['href', 'src'].indexOf(name) !== -1 && isDangerousUrl)) {
                                    child.removeAttribute(attr.name);
                                }
                            }
                            // Externe Links absichern
                            if (tag === 'a' && child.getAttribute('href')) {
                                child.setAttribute('target', '_blank');
                                child.setAttribute('rel', 'nofollow noopener noreferrer');
                            }
                            if (tag === 'img') {
                                child.setAttribute('loading', 'lazy');
                            }
                            walk(child);
                        } else if (child.nodeType === Node.COMMENT_NODE) {
                            node.removeChild(child);
                        }
                    }
                };
                walk(template.content);
                return template.innerHTML;
            }
        };

        // Nicht-destruktives Verbindungs-Monitoring (ersetzt den DevTools-Trap).
        // Aktualisiert lediglich die Status-Anzeige - blockiert NIEMANDEN.
        (function connectionMonitor() {
            const statusEl = document.getElementById('connection-status');
            const setStatus = () => {
                if (!statusEl) return;
                if (navigator.onLine) {
                    statusEl.textContent = '● VERBUNDEN';
                    statusEl.style.color = 'var(--accent-primary)';
                    statusEl.style.borderColor = 'var(--accent-primary)';
                } else {
                    statusEl.textContent = '● OFFLINE (Cache)';
                    statusEl.style.color = 'var(--accent-alert)';
                    statusEl.style.borderColor = 'var(--accent-alert)';
                }
            };
            // Beim Wechsel ONLINE: Status aktualisieren UND automatisch nachladen.
            // Praktisch beim Handy-Lesen (Funkloch, U-Bahn, WLAN-Wechsel): sobald
            // die Verbindung zurück ist, kommen die aktuellen Meldungen von selbst.
            window.addEventListener('online', function() {
                setStatus();
                if (typeof announce === 'function') announce('Verbindung wiederhergestellt');
                if (typeof showToast === 'function') showToast('Wieder online – lade aktuelle Meldungen …', 'success');
                // Kurz warten, bis die Verbindung wirklich stabil ist
                setTimeout(function() {
                    if (navigator.onLine && typeof loadLiveFeeds === 'function') {
                        Promise.resolve(loadLiveFeeds()).catch(function() {});
                    }
                }, 1200);
            });
            window.addEventListener('offline', function() {
                setStatus();
                if (typeof announce === 'function') announce('Keine Internetverbindung – gespeicherte Meldungen bleiben lesbar');
                if (typeof showToast === 'function') showToast('Offline – gespeicherte Meldungen bleiben lesbar', 'info');
            });
            setStatus();
        })();

        // --- 2. DATA FETCHING ENGINE (ROBUST, MIT TIMEOUTS & FALLBACKS) ---

        // fetch() mit hartem Zeitlimit über AbortController.
        // BUGFIX LADEZEIT: Ohne diesen Timeout konnte eine einzige hängende
        // Proxy-Verbindung die gesamte Seite minutenlang blockieren, weil
        // Promise.all auf den langsamsten Feed wartet. Jetzt bricht jede Anfrage
        // nach CONFIG.timeoutMs ab und der nächste Proxy wird sofort versucht.
        function fetchWithTimeout(url, options, timeoutMs) {
            const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
            const timeout = timeoutMs || CONFIG.timeoutMs || 8000;
            const opts = Object.assign({}, options || {});
            if (controller) opts.signal = controller.signal;

            return new Promise((resolve, reject) => {
                let settled = false;
                const timer = setTimeout(() => {
                    settled = true;
                    if (controller) { try { controller.abort(); } catch (e) {} }
                    reject(new Error('Timeout nach ' + timeout + 'ms: ' + url));
                }, timeout);

                fetch(url, opts).then((response) => {
                    if (settled) return;
                    clearTimeout(timer);
                    resolve(response);
                }).catch((err) => {
                    if (settled) return;
                    clearTimeout(timer);
                    reject(err);
                });
            });
        }

        // Parst eine Proxy-Antwort je nach Strategie-Typ in ein Item-Array.
        async function parseProxyResponse(response, strategy, feedConfig) {
            if (!response || !response.ok) {
                throw new Error('HTTP ' + (response ? response.status : 'kein Response'));
            }
            if (strategy.type === 'rss2json') {
                const data = await response.json();
                if (data.status === 'ok' && Array.isArray(data.items)) {
                    return data.items.map(item => normalizeItem(item, feedConfig, 'json'));
                }
                throw new Error('RSS2JSON lieferte Fehlerstatus');
            }
            if (strategy.type === 'allorigins') {
                const data = await response.json();
                if (data && typeof data.contents === 'string') {
                    return parseXML(data.contents, feedConfig);
                }
                throw new Error('AllOrigins ohne contents');
            }
            // 'raw': reiner XML-Text
            const text = await response.text();
            if (!text || text.length < 20) throw new Error('Leere Antwort');
            return parseXML(text, feedConfig);
        }

        // Versucht nacheinander alle Proxy-Strategien (mit Timeout) für EINEN Feed.
        // Gibt beim ersten Erfolg zurück; liefert [] wenn alle scheitern.
        async function fetchWithFallback(feedConfig) {
            // ==============================================================
            //  DIREKTABRUF OHNE PROXY
            // --------------------------------------------------------------
            //  Am 09.08.2026 wurden alle 40 Quellen daraufhin geprueft, ob sie
            //  selbst einen Access-Control-Allow-Origin-Header senden. Zehn tun
            //  das, darunter Tagesschau, Deutsche Welle, ORF, SRF, NYT und die
            //  Europaeische Kommission. Diese Feeds lassen sich direkt aus dem
            //  Browser laden.
            //
            //  Das ist aus zwei Gruenden wichtig: es ist schneller, und es macht
            //  die wichtigsten Quellen unabhaengig von den kostenlosen
            //  CORS-Proxys. Auf einem Handy waren zuletzt nur 1 von 40 Quellen
            //  erreichbar, weil samtliche Proxys versagten. Mit Direktabruf
            //  liefern diese zehn Quellen auch dann noch.
            //
            //  Schlaegt der Direktabruf fehl, geht es ganz normal ueber die
            //  Proxykette weiter. Es geht also nichts verloren.
            // ==============================================================
            if (feedConfig.direkt) {
                try {
                    const response = await fetchWithTimeout(feedConfig.url, null, CONFIG.timeoutMs);
                    if (response.ok) {
                        const text = await response.text();
                        const items = parseXML(text, feedConfig);
                        if (items.length) return items;
                    }
                } catch (e) {
                    console.warn(`${feedConfig.cat}: Direktabruf fehlgeschlagen, weiter ueber Proxy.`);
                }
            }

            // Lokale Entwicklung: Direktzugriff zuerst (spart Proxy-Umweg).
            const host = window.location.hostname;
            if (host === 'localhost' || host === '127.0.0.1' || host === '') {
                try {
                    const response = await fetchWithTimeout(feedConfig.url, null, CONFIG.timeoutMs);
                    if (response.ok) {
                        const text = await response.text();
                        const items = parseXML(text, feedConfig);
                        if (items.length) return items;
                    }
                } catch (e) {
                    // still weiter zu den Proxys
                }
            }

            // BEVORZUGTE STRATEGIE: Einige Quellen sind nachweislich nur ueber
            // einen bestimmten Proxy erreichbar. Ohne diese Vorsortierung wuerde
            // die Kette erst fuenf Fehlversuche mit je bis zu 8 Sekunden Zeitlimit
            // durchlaufen, bevor der funktionierende Proxy an die Reihe kommt.
            // Die uebrigen Strategien bleiben als Rueckfallebene erhalten, es wird
            // also nichts ausgeschlossen, nur die Reihenfolge angepasst.
            let strategies = CONFIG.proxyStrategies || [];
            if (feedConfig.preferProxy) {
                const bevorzugt = strategies.filter(s => s.name === feedConfig.preferProxy);
                const rest = strategies.filter(s => s.name !== feedConfig.preferProxy);
                if (bevorzugt.length) strategies = bevorzugt.concat(rest);
            } else if (strategies.length > 1) {
                // LASTVERTEILUNG. Ohne diese Drehung begannen ALLE Feeds ohne
                // eigene Vorgabe beim selben Anbieter. Bei aktuell 40 Quellen
                // waren das 31 Anfragen an RSS2JSON pro Seitenaufruf. Der Dienst
                // drosselt das nachweislich und antwortet dann mit
                // "You are converting new feeds in a very short period,
                //  please use an API key".
                // Jeder Feed startet daher bei einem anderen Proxy. Die Last
                // verteilt sich damit auf rund sechs bis sieben Anfragen je
                // Anbieter. Die vollstaendige Kette bleibt fuer jeden Feed als
                // Rueckfallebene erhalten, es wird nichts ausgeschlossen.
                const idx = CONFIG.feeds.indexOf(feedConfig);
                const start = (idx >= 0 ? idx : 0) % strategies.length;
                strategies = strategies.slice(start).concat(strategies.slice(0, start));
            }

            for (let i = 0; i < strategies.length; i++) {
                const strategy = strategies[i];
                try {
                    const targetUrl = strategy.build(feedConfig.url);
                    const response = await fetchWithTimeout(targetUrl, null, CONFIG.timeoutMs);
                    const items = await parseProxyResponse(response, strategy, feedConfig);
                    if (items && items.length) {
                        if (i > 0) {
                            console.info(`${feedConfig.cat}: geladen über Fallback „${strategy.name}“.`);
                        }
                        return items;
                    }
                    throw new Error('Keine Items');
                } catch (err) {
                    console.warn(`${feedConfig.cat}: Proxy „${strategy.name}“ fehlgeschlagen (${err.message}).`);
                    // nächste Strategie versuchen
                }
            }

            console.error(`Alle Proxys für ${feedConfig.cat} fehlgeschlagen.`);
            return [];
        }

        // Führt eine Liste von async-Aufgaben mit begrenzter Parallelität aus.
        // onEach(result, index) wird pro fertiger Aufgabe aufgerufen -> ermöglicht
        // progressives Rendern, sobald ein Feed geladen ist.
        async function runWithConcurrency(tasks, limit, onEach) {
            const results = new Array(tasks.length);
            let cursor = 0;
            const workerCount = Math.max(1, Math.min(limit || 4, tasks.length));

            async function worker() {
                while (cursor < tasks.length) {
                    const current = cursor++;
                    try {
                        results[current] = await tasks[current]();
                    } catch (e) {
                        results[current] = [];
                    }
                    if (typeof onEach === 'function') {
                        try { onEach(results[current], current); } catch (e) {}
                    }
                }
            }

            const workers = [];
            for (let w = 0; w < workerCount; w++) workers.push(worker());
            await Promise.all(workers);
            return results;
        }

        function normalizeItem(raw, config, type) {
            // Standardizes data from different proxies into one format
            let title = '', link = '#', date = new Date(), description = '', content = '', image = '';

            if (type === 'json') {
                // BUGFIX: pubDate kann fehlen -> vorher warf raw.pubDate.replace(...)
                // eine TypeError-Exception und die komplette Meldung ging verloren.
                title = SecurityUtils.stripHtml(raw.title) || 'Ohne Titel';
                link = safeUrl(raw.link);
                const pubRaw = raw.pubDate || raw.published || raw.updated || '';
                const parsed = pubRaw ? new Date(String(pubRaw).replace(/-/g, '/')) : new Date();
                date = isNaN(parsed.getTime()) ? new Date() : parsed;
                description = raw.description || raw.content || '';
                // Reiner Text für die Vorschau (kein HTML)
                content = SecurityUtils.stripHtml(description);
                // Vorschaubild extrahieren (thumbnail/enclosure), falls vorhanden
                image = safeUrl(raw.thumbnail || raw.enclosure?.link || extractFirstImage(description) || '');
            }

            const excerpt = content.length > 220
                ? content.substring(0, 220).trim() + ' …'
                : (content || 'Keine Vorschau verfügbar.');

            return {
                id: (config.cat + '-' + link + '-' + date.getTime())
                        .replace(/[^a-zA-Z0-9]/g, '').substring(0, 40) +
                        '-' + Math.random().toString(36).substr(2, 5),
                title: title,
                link: link,
                date: date,
                description: excerpt,
                // WICHTIG: HTML fürs Modal wird SANITISIERT (Anti-XSS), nicht roh.
                fullContent: SecurityUtils.sanitizeHtml(description),
                image: image,
                category: config.cat,
                badgeClass: config.class,
                // Herkunft der Meldung. Ohne Angabe im Feed gilt Deutschland/deutsch,
                // damit die bestehenden Quellen unveraendert weiterlaufen.
                region: config.region || 'Deutschland',
                lang: config.lang || 'de',
                publisher: config.publisher || config.cat,
                timestamp: date.getTime(),
                readingTime: estimateReadingTime(content)
            };
        }

        // Validiert & normalisiert URLs; blockt javascript:/data: Schemata (Anti-XSS)
        function safeUrl(url) {
            if (!url) return '#';
            try {
                const u = String(url).trim();
                if (/^(javascript|data|vbscript):/i.test(u)) return '#';
                // Relative oder absolute http(s)-URLs zulassen
                if (/^https?:\/\//i.test(u) || u.startsWith('/')) return u;
                return '#';
            } catch (e) {
                return '#';
            }
        }

        // Grobe Lesezeit-Schätzung (ca. 200 Wörter/Minute) - faktenbasierter Richtwert
        function estimateReadingTime(text) {
            if (!text) return 1;
            const words = text.trim().split(/\s+/).filter(Boolean).length;
            return Math.max(1, Math.round(words / 200));
        }

        // Extrahiert die erste Bild-URL aus einem HTML-Description-Fragment
        function extractFirstImage(html) {
            if (!html) return '';
            const match = String(html).match(/<img[^>]+src=["']([^"']+)["']/i);
            return match ? match[1] : '';
        }

        function parseXML(xmlStr, config) {
            // Manuelles XML-Parsing (Fallback ohne rss2json).
            // BUGFIX/ROBUSTHEIT: Unterstützt jetzt SOWOHL klassisches RSS 2.0
            // (<item>) ALS AUCH Atom (<entry>). Zuvor lieferten Atom-Feeds wie
            // heise 0 Items, weil nur nach <item> gesucht wurde.
            try {
                const parser = new DOMParser();
                const xmlDoc = parser.parseFromString(xmlStr, "text/xml");

                // Parser-Fehler erkennen (ungültiges XML) -> leeres Ergebnis
                if (xmlDoc.querySelector("parsererror")) {
                    console.warn(`${config.cat}: XML enthält Parser-Fehler.`);
                    return [];
                }

                let nodes = Array.from(xmlDoc.querySelectorAll("item"));
                const isAtom = nodes.length === 0;
                if (isAtom) {
                    nodes = Array.from(xmlDoc.querySelectorAll("entry"));
                }

                return nodes.map(node => {
                    const title = SecurityUtils.stripHtml(node.querySelector("title")?.textContent) || "Kein Titel";

                    // Link: RSS nutzt <link>Text</link>, Atom nutzt <link href="…"/>
                    let link = "#";
                    if (isAtom) {
                        const links = Array.from(node.querySelectorAll("link"));
                        // bevorzugt rel="alternate", sonst der erste Link mit href
                        const alt = links.find(l => (l.getAttribute("rel") || "alternate") === "alternate" && l.getAttribute("href"))
                                 || links.find(l => l.getAttribute("href"));
                        link = safeUrl(alt ? alt.getAttribute("href") : "#");
                    } else {
                        link = safeUrl(node.querySelector("link")?.textContent || "#");
                    }

                    // Datum: RSS pubDate/date, Atom published/updated
                    const pubDateText = node.querySelector("pubDate")?.textContent
                        || node.querySelector("published")?.textContent
                        || node.querySelector("updated")?.textContent
                        || node.querySelector("date")?.textContent;
                    // Ungültige Datumsangaben fallen sicher auf "jetzt" zurück
                    const parsed = pubDateText ? new Date(pubDateText) : new Date();
                    const date = isNaN(parsed.getTime()) ? new Date() : parsed;

                    // Inhalt: description / content:encoded (RSS) bzw. content / summary (Atom)
                    let descHTML = node.querySelector("description")?.textContent || "";
                    const contentEncoded = node.getElementsByTagNameNS("*", "encoded");
                    if (contentEncoded.length > 0) {
                        descHTML = contentEncoded[0].textContent;
                    }
                    if (!descHTML && isAtom) {
                        descHTML = node.querySelector("content")?.textContent
                            || node.querySelector("summary")?.textContent
                            || "";
                    }

                    // Vorschaubild aus media:content / media:thumbnail / enclosure / <img>
                    let image = extractFirstImage(descHTML);
                    if (!image) {
                        const media = node.getElementsByTagNameNS("*", "content")[0]
                                   || node.getElementsByTagNameNS("*", "thumbnail")[0];
                        if (media && media.getAttribute && media.getAttribute("url")) {
                            image = media.getAttribute("url");
                        }
                        const enclosure = node.querySelector("enclosure");
                        if (!image && enclosure && /^image\//i.test(enclosure.getAttribute("type") || "")) {
                            image = enclosure.getAttribute("url") || "";
                        }
                    }

                    // Strip HTML for preview (reiner Text)
                    const cleanText = SecurityUtils.stripHtml(descHTML);
                    const excerpt = cleanText.length > 220
                        ? cleanText.substring(0, 220).trim() + " …"
                        : (cleanText || "Keine Vorschau verfügbar.");

                    return {
                        id: (config.cat + '-' + link + '-' + date.getTime())
                                .replace(/[^a-zA-Z0-9]/g, '').substring(0, 40) +
                                '-' + Math.random().toString(36).substr(2, 5),
                        title: title,
                        link: link,
                        date: date,
                        description: excerpt,
                        // Anti-XSS: HTML fürs Modal wird sanitisiert
                        fullContent: SecurityUtils.sanitizeHtml(descHTML),
                        image: safeUrl(image),
                        category: config.cat,
                        badgeClass: config.class,
                        // Herkunft der Meldung. Ohne Angabe im Feed gilt Deutschland/deutsch,
                        // damit die bestehenden Quellen unveraendert weiterlaufen.
                        region: config.region || 'Deutschland',
                        lang: config.lang || 'de',
                        publisher: config.publisher || config.cat,
                        timestamp: date.getTime(),
                        readingTime: estimateReadingTime(cleanText)
                    };
                });
            } catch (e) {
                console.error("XML Parse Error", e);
                return [];
            }
        }

        // --- 3. APPLICATION LOGIC ---

        async function initApp() {
            // Check Local Storage Cache first (Speed!)
            const cached = localStorage.getItem('news_cache');
            const cacheTime = localStorage.getItem('news_cache_time');
            const now = new Date().getTime();

            // Use cache if less than 15 mins old
            if (cached && cacheTime && (now - cacheTime < 15 * 60 * 1000)) {
                console.log("Loading from cache");
                appState.allNews = JSON.parse(cached);
                // Re-hydrate dates
                appState.allNews.forEach(x => x.date = new Date(x.timestamp));
                finalizeLoading();
            } else {
                console.log("Fetching live data");
                loadLiveFeeds();
            }
            
            // Additional: Always try to refresh data if cache is older than 5 minutes or has few items
            if (cached && cacheTime && (now - cacheTime > 5 * 60 * 1000 || appState.allNews.length < 20)) {
                console.log("Cache is old or insufficient, loading fresh data");
                setTimeout(() => loadLiveFeeds(), 1000); // Delay to allow initial render
            }
        }

        async function loadLiveFeeds() {
            console.log('Starting to load live feeds...');
            const progressFill = document.getElementById('progress-fill');
            progressFill.style.width = '0%';
            const promises = CONFIG.feeds.map((feed, index) => {
                return fetchWithFallback(feed).then(result => {
                    console.log(`Loaded ${result.length} items from ${feed.cat}`);
                    progressFill.style.width = `${((index + 1) / CONFIG.feeds.length) * 100}%`;
                    return result;
                }).catch(err => {
                    console.error(`Failed to load ${feed.cat}:`, err);
                    return [];
                });
            });
            const results = await Promise.all(promises);
            appState.allNews = results.flat().sort((a, b) => b.timestamp - a.timestamp);
            console.log(`Total news loaded: ${appState.allNews.length}`);
            
            // Fallback: Mock-Daten, falls keine Nachrichten geladen wurden
            if (appState.allNews.length === 0) {
                console.warn('No news loaded, using mock data for demo.');
                appState.allNews = [
                    {
                        id: 'mock1',
                        title: 'Demo-Nachricht: Technologie-News',
                        link: '#',
                        date: new Date(),
                        description: 'Dies ist eine Demo-Nachricht, da echte Feeds nicht geladen werden konnten.',
                        fullContent: 'Vollständiger Inhalt der Demo-Nachricht.',
                        category: 'Demo',
                        badgeClass: 'badge-tagesschau',
                        timestamp: Date.now()
                    }
                ];
            }
            
            localStorage.setItem('news_cache', encryptCache(appState.allNews));
            localStorage.setItem('news_cache_time', new Date().getTime());
            finalizeLoading();
        }

        function finalizeLoading() {
            document.getElementById('loading-indicator').style.display = 'none';
            appState.filteredNews = appState.allNews;
            renderNews();
            updateTicker();
            generateSEO();
            maximizeSEOIndexing(); // Add maximum SEO
        }

        // --- 4. RENDERING & INTERACTION ---

        // Zeigt Skeleton-Platzhalterkarten, während echte Daten noch laden.
        // Verbessert die WAHRGENOMMENE Ladezeit erheblich (Nutzer sieht sofort Struktur).
        function showSkeletons(count) {
            const container = document.getElementById('news-container');
            if (!container) return;
            const n = count || 6;
            let html = '';
            for (let i = 0; i < n; i++) {
                html += `
                    <div class="skeleton-card" aria-hidden="true">
                        <div class="skeleton-line short"></div>
                        <div class="skeleton-line title"></div>
                        <div class="skeleton-line full"></div>
                        <div class="skeleton-line full"></div>
                        <div class="skeleton-line short"></div>
                    </div>`;
            }
            container.innerHTML = html;
            const noRes = document.getElementById('no-results');
            if (noRes) noRes.style.display = 'none';
        }

        // Hebt die aktuellen Suchbegriffe in bereits HTML-escapetem Text hervor.
        // WICHTIG (Anti-XSS): Erwartet BEREITS escapten Text und fügt nur <mark>
        // hinzu; die Suchbegriffe selbst werden für den Regex maskiert.
        function highlightTerms(escapedText) {
            const q = (appState.searchQuery || '').trim();
            if (!q || !escapedText) return escapedText;
            const terms = q.split(/\s+/).filter(t => t.length >= 2);
            if (!terms.length) return escapedText;
            let out = escapedText;
            terms.forEach(term => {
                const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                try {
                    const re = new RegExp('(' + safe + ')', 'gi');
                    // Nicht innerhalb bestehender Tags ersetzen (hier gibt es keine
                    // echten Tags, nur Text) -> einfache Ersetzung ist sicher.
                    out = out.replace(re, '<mark class="search-hit">$1</mark>');
                } catch (e) { /* ungültiges Muster ignorieren */ }
            });
            return out;
        }

        // Liefert ein menschenlesbares, chronologisches Gruppenlabel für ein Datum:
        // „Heute“, „Gestern“ oder z. B. „Montag, 5. Juli 2026“. Vergleich erfolgt
        // rein über das Kalenderdatum (nicht die Uhrzeit).
        function getDateGroupLabel(date) {
            const d = (date instanceof Date) ? date : new Date(date);
            if (isNaN(d.getTime())) return 'Ohne Datum';
            const today = new Date();
            const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
            const diffDays = Math.round((startOfDay(today) - startOfDay(d)) / 86400000);
            if (diffDays === 0) return 'Heute';
            if (diffDays === 1) return 'Gestern';
            if (diffDays === -1) return 'Morgen';
            return d.toLocaleDateString('de-DE', {
                weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
            });
        }

        // =====================================================================
        //  EILMELDUNGEN & FRISCHE MELDUNGEN – ZEITLICH BEGRENZTE HERVORHEBUNG
        // =====================================================================
        //  WICHTIG (Faktentreue): Es wird NICHTS erfunden. Die Feeds enthalten
        //  kein strukturiertes "Eilmeldung"-Feld (geprüft: Tagesschau nutzt das
        //  Muster "Thema: Schlagzeile", ZDFs <category> ist in Wahrheit ein
        //  Untertitel). Deshalb gibt es genau ZWEI nachprüfbare Grundlagen:
        //
        //  1) EILMELDUNG – nur wenn die QUELLE SELBST es so bezeichnet, also der
        //     Titel erkennbar als Eilmeldung ausgewiesen ist ("Eilmeldung",
        //     "Eilt:", "+++", "Breaking News"). Es wird nie eine Meldung zur
        //     Eilmeldung erklärt, die die Redaktion nicht so gekennzeichnet hat.
        //
        //  2) NEU – rein zeitlich: Veröffentlichung liegt weniger als 30 Minuten
        //     zurück. Das ist ein reiner Messwert aus dem pubDate.
        //
        //  BEIDE laufen automatisch ab (deshalb "nicht für immer"):
        //     - "NEU"        nach 30 Minuten
        //     - "EILMELDUNG" nach 3 Stunden – danach ist es schlicht keine
        //                    Eilmeldung mehr, auch wenn der Titel es noch sagt.
        //  Ein Zeitgeber entfernt abgelaufene Kennzeichnungen im laufenden
        //  Betrieb, ohne dass die Seite neu geladen werden muss.
        // =====================================================================
        const FRESH_MAX_AGE_MS = 30 * 60 * 1000;        // 30 Minuten
        const BREAKING_MAX_AGE_MS = 3 * 60 * 60 * 1000; // 3 Stunden

        // Muster, mit denen deutsche Redaktionen Eilmeldungen tatsächlich
        // auszeichnen. Bewusst eng gefasst, um Fehlalarme zu vermeiden:
        // "Eilantrag" oder "eilig" dürfen NICHT auslösen -> \b und Doppelpunkt.
        const BREAKING_PATTERNS = [
            /\beilmeldung\b/i,
            /\beilt\s*:/i,
            /^\s*\+\+\+/,
            /\+\+\+\s*eil/i,
            /\bbreaking\s+news\b/i,
            /^\s*breaking\s*:/i
        ];

        // Ist die Meldung von der QUELLE als Eilmeldung gekennzeichnet?
        function isSourceLabelledBreaking(item) {
            if (!item || !item.title) return false;
            const t = String(item.title);
            return BREAKING_PATTERNS.some(re => re.test(t));
        }

        // Liefert den aktuellen Hervorhebungs-Zustand einer Meldung.
        // Wird bei jedem Rendern UND vom Zeitgeber neu berechnet -> läuft ab.
        function getHighlightState(item) {
            if (!item || !item.timestamp) return { breaking: false, fresh: false };
            const age = Date.now() - item.timestamp;
            if (age < 0) return { breaking: false, fresh: false }; // Datum in der Zukunft
            return {
                breaking: isSourceLabelledBreaking(item) && age < BREAKING_MAX_AGE_MS,
                fresh: age < FRESH_MAX_AGE_MS
            };
        }

        // Erzeugt das Abzeichen-HTML (leer, wenn nichts zutrifft).
        function buildHighlightBadge(item) {
            const st = getHighlightState(item);
            if (st.breaking) {
                const mins = Math.max(1, Math.round((Date.now() - item.timestamp) / 60000));
                return `<span class="flash-badge breaking" data-flash="breaking"
                    title="Von der Quelle selbst als Eilmeldung gekennzeichnet · vor ${mins} Min. veröffentlicht">
                    ⚡ EILMELDUNG</span>`;
            }
            if (st.fresh) {
                const mins = Math.max(1, Math.round((Date.now() - item.timestamp) / 60000));
                return `<span class="flash-badge fresh" data-flash="fresh"
                    title="Vor ${mins} Minuten veröffentlicht">NEU</span>`;
            }
            return '';
        }

        // ZEITGEBER: entfernt abgelaufene Kennzeichnungen im laufenden Betrieb.
        // Läuft jede Minute, arbeitet direkt am DOM (kein Neuaufbau der Liste,
        // damit die Scrollposition und der Lesefluss erhalten bleiben).
        let _flashTimer = null;
        function startHighlightExpiryTimer() {
            if (_flashTimer) clearInterval(_flashTimer);
            _flashTimer = setInterval(function() {
                let removed = 0;
                document.querySelectorAll('[data-flash]').forEach(function(el) {
                    const card = el.closest('[data-id]');
                    if (!card) return;
                    const item = appState.allNews.find(n => n.id === card.dataset.id);
                    if (!item) { el.remove(); removed++; return; }
                    const st = getHighlightState(item);
                    const kind = el.getAttribute('data-flash');
                    // Abgelaufen? Dann Kennzeichnung entfernen.
                    if ((kind === 'breaking' && !st.breaking) || (kind === 'fresh' && !st.fresh)) {
                        el.remove();
                        removed++;
                    }
                });
                if (removed > 0) {
                    console.log(removed + ' abgelaufene Kennzeichnung(en) entfernt.');
                }
            }, 60 * 1000);
        }

        // Relative, gut scannbare Zeitangabe („vor 5 Min.“, „vor 2 Std.“, sonst Datum).
        // Bleibt faktenbasiert: bei älteren Meldungen wird das exakte Datum gezeigt.
        function timeAgo(date) {
            const d = (date instanceof Date) ? date : new Date(date);
            if (isNaN(d.getTime())) return '';
            const sec = Math.floor((Date.now() - d.getTime()) / 1000);
            if (sec < 0) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
            if (sec < 60) return 'gerade eben';
            const min = Math.floor(sec / 60);
            if (min < 60) return 'vor ' + min + ' Min.';
            const std = Math.floor(min / 60);
            if (std < 24) return 'vor ' + std + ' Std.';
            const tage = Math.floor(std / 24);
            if (tage === 1) return 'gestern';
            if (tage < 7) return 'vor ' + tage + ' Tagen';
            return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
        }

        // ---------------------------------------------------------------------
        //  GELESEN-MARKIERUNG (persistiert im localStorage)
        // ---------------------------------------------------------------------
        function loadReadState() {
            try {
                const raw = localStorage.getItem('news_read');
                appState.readIds = raw ? JSON.parse(raw) : [];
                if (!Array.isArray(appState.readIds)) appState.readIds = [];
                // Begrenzen, damit die Liste nicht unbegrenzt wächst
                if (appState.readIds.length > 2000) {
                    appState.readIds = appState.readIds.slice(-2000);
                }
            } catch (e) { appState.readIds = []; }
        }
        function saveReadState() {
            try { localStorage.setItem('news_read', JSON.stringify(appState.readIds)); } catch (e) {}
        }
        function isRead(id) { return appState.readIds.indexOf(id) !== -1; }

        // ---------------------------------------------------------------------
        //  LESEVERLAUF – „Zuletzt gelesen"
        //  Merkt sich, WANN welcher Artikel geöffnet wurde. Anders als die reine
        //  Gelesen-Markierung erlaubt das, einen Artikel wiederzufinden („was
        //  habe ich heute Morgen gelesen?"). Gespeichert werden nur Titel,
        //  Quelle, permanenter Link und Zeitpunkt – alles rein lokal im Browser,
        //  nichts wird übertragen.
        // ---------------------------------------------------------------------
        const HISTORY_MAX = 100;
        function loadHistory() {
            try {
                const raw = localStorage.getItem('news_history');
                const arr = raw ? JSON.parse(raw) : [];
                return Array.isArray(arr) ? arr : [];
            } catch (e) { return []; }
        }
        function saveHistory(list) {
            try { localStorage.setItem('news_history', JSON.stringify(list.slice(0, HISTORY_MAX))); }
            catch (e) {}
        }
        function addToHistory(item) {
            if (!item || !item.id) return;
            const list = loadHistory();
            const link = articlePermalink(item);
            // Bereits vorhandenen Eintrag entfernen, damit er nach oben rutscht
            const filtered = list.filter(e => e.link !== link);
            filtered.unshift({
                id: item.id,
                title: item.title,
                category: item.category,
                link: link,
                readAt: Date.now()
            });
            saveHistory(filtered);
        }
        function clearHistory() {
            try { localStorage.removeItem('news_history'); } catch (e) {}
            showToast('Leseverlauf gelöscht', 'success');
            if (document.querySelector('.history-page')) renderHistoryPage();
        }

        // Öffnet den Leseverlauf (auch per Adresse /verlauf erreichbar).
        function openHistory() {
            toggleSettings(false);
            if (location.protocol.startsWith('http')) {
                history.pushState({ view: 'history' }, '', '/verlauf');
            }
            renderHistoryPage();
        }

        // Zeigt den Leseverlauf als eigene Ansicht.
        function renderHistoryPage() {
            const container = document.getElementById('news-container');
            if (!container) return;
            const esc = SecurityUtils.escapeHtml;
            const intro = document.getElementById('intro');
            if (intro) intro.style.display = 'none';
            const staleMore = document.getElementById('load-more-wrap');
            if (staleMore) staleMore.remove();
            const noRes = document.getElementById('no-results');
            if (noRes) noRes.style.display = 'none';

            const list = loadHistory();
            let body;
            if (!list.length) {
                body = `<p class="settings-hint">Noch keine gelesenen Artikel. Sobald Sie eine
                        Meldung öffnen, erscheint sie hier – damit Sie sie später wiederfinden.</p>`;
            } else {
                // Nach Tagen gruppieren (chronologisch, wie in der Hauptansicht)
                let currentDay = null;
                const rows = list.map(e => {
                    const day = getDateGroupLabel(new Date(e.readAt));
                    let head = '';
                    if (day !== currentDay) { currentDay = day; head = `<li class="hist-day">${esc(day)}</li>`; }
                    return head + `<li>
                        <a class="src-index-item" href="${esc(e.link)}" target="_blank" rel="nofollow noopener noreferrer">
                            <span class="si-name">${esc(e.title)}</span>
                            <span class="si-count">${esc(e.category)}</span>
                            <span class="si-time">${esc(timeAgo(new Date(e.readAt)))}</span>
                        </a>
                    </li>`;
                }).join('');
                body = `<ul class="source-index-list">${rows}</ul>`;
            }

            container.innerHTML = `
                <section class="source-index history-page">
                    <button class="reader-back" onclick="goHome()">← Zur Übersicht</button>
                    <h1 class="intro-h1">Zuletzt gelesen</h1>
                    <p class="settings-hint">${list.length} Einträge, gespeichert ausschließlich lokal in
                       Ihrem Browser. Es werden keine Daten übertragen. Es werden maximal
                       ${HISTORY_MAX} Einträge vorgehalten.</p>
                    <div class="settings-group-body" style="margin-bottom:1rem;">
                        <button class="refresh-btn" onclick="clearHistory()">🗑 Verlauf löschen</button>
                    </div>
                    ${body}
                </section>`;

            document.title = 'Zuletzt gelesen | Nachrichten Kaipfstr';
            // Der Verlauf ist rein persönlich -> ausdrücklich NICHT indexieren.
            updateMetaTag('robots', 'noindex, nofollow');
            scrollToTop();
        }
        function markRead(id) {
            // Verlauf IMMER fortschreiben – auch bei erneutem Lesen, damit der
            // Artikel im Verlauf wieder nach oben rutscht.
            const it = appState.allNews.find(n => n.id === id);
            if (it) addToHistory(it);
            if (!id || isRead(id)) return;
            appState.readIds.push(id);
            saveReadState();
            // Sichtbare Karte(n) dieses Artikels als gelesen kennzeichnen
            const safeId = (window.CSS && CSS.escape) ? CSS.escape(id) : id;
            document.querySelectorAll(`.card[data-id="${safeId}"], .hero[data-id="${safeId}"]`).forEach(c => c.classList.add('is-read'));
            updateStatsNumbers();
        }
        function markAllRead() {
            const ids = appState.filteredNews.map(n => n.id);
            let added = 0;
            ids.forEach(id => { if (!isRead(id)) { appState.readIds.push(id); added++; } });
            saveReadState();
            document.querySelectorAll('.card, .hero').forEach(c => c.classList.add('is-read'));
            updateStatsNumbers();
            showToast(added + ' Meldung(en) als gelesen markiert', 'success');
        }

        // ---------------------------------------------------------------------
        //  SCHRIFTGRÖSSE (Lesekomfort, persistiert)
        // ---------------------------------------------------------------------
        const FONT_SIZES = ['', 'fs-large', 'fs-xlarge'];
        const FONT_LABELS = { '': 'A± Schrift', 'fs-large': 'A+ Größer', 'fs-xlarge': 'A++ Groß' };
        function applyFontSize(cls) {
            document.body.classList.remove('fs-large', 'fs-xlarge');
            if (cls) document.body.classList.add(cls);
            const btn = document.getElementById('fontsize-btn');
            if (btn) btn.textContent = FONT_LABELS[cls] || 'A± Schrift';
            try { localStorage.setItem('news_fontsize', cls); } catch (e) {}
        }
        function cycleFontSize() {
            const current = FONT_SIZES.find(c => c && document.body.classList.contains(c)) || '';
            const next = FONT_SIZES[(FONT_SIZES.indexOf(current) + 1) % FONT_SIZES.length];
            applyFontSize(next);
            showToast('Schriftgröße: ' + (FONT_LABELS[next] || 'Standard'), 'info');
        }
        function applySavedFontSize() {
            try {
                const saved = localStorage.getItem('news_fontsize') || '';
                if (FONT_SIZES.indexOf(saved) !== -1) applyFontSize(saved);
            } catch (e) {}
        }

        // ---------------------------------------------------------------------
        //  BARRIEREFREIHEITS-SCHALTER (Kontrast · Ruhe/Animationen · Lesbarkeit)
        //  Jeweils persistiert, mit aria-pressed für Screenreader.
        // ---------------------------------------------------------------------
        function _setToggle(cls, storageKey, btnId, on, label) {
            document.body.classList.toggle(cls, on);
            try { localStorage.setItem(storageKey, on ? '1' : '0'); } catch (e) {}
            const btn = document.getElementById(btnId);
            if (btn) btn.setAttribute('aria-pressed', String(on));
            if (label) showToast(label + (on ? ': an' : ': aus'), 'info');
        }
        function toggleContrast() {
            _setToggle('high-contrast', 'news_contrast', 'contrast-btn',
                !document.body.classList.contains('high-contrast'), 'Hoher Kontrast');
        }
        function toggleReduceMotion() {
            _setToggle('reduce-motion', 'news_motion', 'motion-btn',
                !document.body.classList.contains('reduce-motion'), 'Ruhiger Modus');
        }
        function toggleDyslexia() {
            _setToggle('dyslexia', 'news_dyslexia', 'dyslexia-btn',
                !document.body.classList.contains('dyslexia'), 'Gut lesbare Schrift');
        }
        // ---------------------------------------------------------------------
        //  EINSTELLUNGS-PANEL (Bottom-Sheet auf dem Handy)
        //  MOBILE-FIX: Verhindert, dass ~12 Steuer-Buttons den halben Bildschirm
        //  einnehmen. Öffnen/Schließen per Zahnrad, Hintergrund oder Escape.
        // ---------------------------------------------------------------------
        function toggleSettings(forceState) {
            const drawer = document.getElementById('settings-drawer');
            const backdrop = document.getElementById('settings-backdrop');
            const toggle = document.getElementById('settings-toggle');
            if (!drawer) return;
            const isOpen = !drawer.hasAttribute('hidden');
            const show = (forceState === undefined) ? !isOpen : forceState;

            if (show) {
                drawer.removeAttribute('hidden');
                if (backdrop) backdrop.removeAttribute('hidden');
                // Doppeltes rAF, damit die Einblend-Transition zuverlässig startet
                requestAnimationFrame(() => requestAnimationFrame(() => {
                    drawer.classList.add('open');
                    if (backdrop) backdrop.classList.add('open');
                }));
                const firstBtn = drawer.querySelector('button, select');
                if (firstBtn) firstBtn.focus();
            } else {
                drawer.classList.remove('open');
                if (backdrop) backdrop.classList.remove('open');
                // Erst nach der Transition wirklich ausblenden (a11y: nicht fokussierbar)
                setTimeout(() => {
                    if (!drawer.classList.contains('open')) {
                        drawer.setAttribute('hidden', '');
                        if (backdrop) backdrop.setAttribute('hidden', '');
                    }
                }, 260);
                if (toggle) toggle.focus();
            }
            if (toggle) toggle.setAttribute('aria-expanded', String(show));
        }

        // ---------------------------------------------------------------------
        //  QUELLEN-VERWALTUNG
        //  Der Nutzer kann einzelne Quellen abschalten. Abgewählte Feeds werden
        //  gar nicht erst abgerufen – das verkürzt die Ladezeit spürbar und
        //  spart mobiles Datenvolumen. Auswahl wird dauerhaft gespeichert.
        // ---------------------------------------------------------------------
        function loadDisabledSources() {
            try {
                const raw = localStorage.getItem('news_disabled_sources');
                const arr = raw ? JSON.parse(raw) : [];
                appState.disabledSources = Array.isArray(arr) ? arr : [];
            } catch (e) { appState.disabledSources = []; }
        }
        function saveDisabledSources() {
            try {
                localStorage.setItem('news_disabled_sources', JSON.stringify(appState.disabledSources));
            } catch (e) {}
        }
        function isSourceEnabled(cat) {
            return appState.disabledSources.indexOf(cat) === -1;
        }

        // Liefert die aktuell aktiven Feed-Konfigurationen.
        function activeFeeds() {
            return CONFIG.feeds.filter(f => isSourceEnabled(f.cat));
        }

        // ==================================================================
        //  SCHONFRIST JE QUELLE
        // ------------------------------------------------------------------
        //  Bisher wurden bei JEDEM Seitenaufruf alle Feeds neu geholt. Bei 40
        //  Quellen sind das 40 Proxy-Anfragen, auch wenn der Besucher die Seite
        //  nur kurz neu laedt. Das kostet Zeit, mobiles Datenvolumen und treibt
        //  die Proxys in ihre Ratenbegrenzung.
        //
        //  Jetzt wird je Quelle der Zeitpunkt des letzten ERFOLGREICHEN Abrufs
        //  gemerkt. Wurde eine Quelle vor weniger als SCHONFRIST_MS geladen,
        //  wird sie uebersprungen. Ihre Meldungen sind ohnehin im Archiv und
        //  bleiben damit sichtbar.
        //
        //  Wichtig: Uebersprungen wird nur, wenn wirklich noch Meldungen dieser
        //  Quelle vorliegen. Sonst wuerde ein geleertes Archiv dazu fuehren,
        //  dass eine Quelle stillschweigend leer bleibt.
        //  Ein manueller Klick auf "Aktualisieren" ignoriert die Schonfrist.
        // ==================================================================
        const SCHONFRIST_MS = 5 * 60 * 1000;
        const SCHONFRIST_KEY = 'nk_feed_laufzeiten_v1';

        function ladeLaufzeiten() {
            try {
                const roh = localStorage.getItem(SCHONFRIST_KEY);
                const obj = roh ? JSON.parse(roh) : null;
                return (obj && typeof obj === 'object') ? obj : {};
            } catch (e) { return {}; }
        }

        function merkeLaufzeit(url, zeit) {
            try {
                const alle = ladeLaufzeiten();
                alle[url] = zeit;
                localStorage.setItem(SCHONFRIST_KEY, JSON.stringify(alle));
            } catch (e) { /* Speicher gesperrt oder voll: unkritisch */ }
        }

        // Entscheidet, welche Feeds diesmal wirklich abgerufen werden.
        function feedsZumLaden(alleFeeds, erzwingen) {
            if (erzwingen) return { laden: alleFeeds, uebersprungen: [] };
            const laufzeiten = ladeLaufzeiten();
            const jetzt = Date.now();
            const vorhandeneKategorien = {};
            (appState.allNews || []).forEach(i => { vorhandeneKategorien[i.category] = true; });

            const laden = [], uebersprungen = [];
            alleFeeds.forEach(f => {
                const zuletzt = laufzeiten[f.url] || 0;
                const frisch = (jetzt - zuletzt) < SCHONFRIST_MS;
                if (frisch && vorhandeneKategorien[f.cat]) uebersprungen.push(f);
                else laden.push(f);
            });
            return { laden, uebersprungen };
        }

        // Baut die Schalterliste im Einstellungs-Panel auf.
        function renderSourceToggles() {
            const wrap = document.getElementById('source-toggles');
            if (!wrap) return;
            const esc = SecurityUtils.escapeHtml;

            // Kategorien eindeutig (mehrere Feeds können dieselbe Kategorie haben)
            const cats = [];
            CONFIG.feeds.forEach(f => { if (cats.indexOf(f.cat) === -1) cats.push(f.cat); });
            cats.sort((a, b) => a.localeCompare(b, 'de'));

            wrap.innerHTML = cats.map(cat => {
                const on = isSourceEnabled(cat);
                const id = 'src-tgl-' + cat.replace(/[^a-zA-Z0-9]/g, '');
                return `<label class="source-toggle ${on ? 'on' : 'off'}" for="${id}">
                    <input type="checkbox" id="${id}" ${on ? 'checked' : ''}
                           onchange="toggleSource('${esc(cat)}', this.checked)">
                    <span>${esc(cat)}</span>
                </label>`;
            }).join('');

            const counter = document.getElementById('source-toggle-count');
            if (counter) {
                const active = cats.filter(isSourceEnabled).length;
                counter.textContent = `(${active}/${cats.length} aktiv)`;
            }
        }

        // Einzelne Quelle an-/abschalten.
        function toggleSource(cat, enabled) {
            const i = appState.disabledSources.indexOf(cat);
            if (enabled && i !== -1) appState.disabledSources.splice(i, 1);
            else if (!enabled && i === -1) appState.disabledSources.push(cat);
            saveDisabledSources();
            renderSourceToggles();

            // Bereits geladene Meldungen der abgewählten Quelle sofort ausblenden
            // (sie bleiben im Archiv erhalten und kehren beim Wiedereinschalten zurück).
            filterData();
            updateCategoryCounts();
            showToast(cat + (enabled ? ' eingeschaltet' : ' ausgeschaltet'), 'info');
        }

        // Alle Quellen auf einmal an- oder abschalten.
        function setAllSources(enabled) {
            if (enabled) {
                appState.disabledSources = [];
            } else {
                const cats = [];
                CONFIG.feeds.forEach(f => { if (cats.indexOf(f.cat) === -1) cats.push(f.cat); });
                appState.disabledSources = cats.slice();
            }
            saveDisabledSources();
            renderSourceToggles();
            filterData();
            showToast(enabled ? 'Alle Quellen eingeschaltet' : 'Alle Quellen ausgeschaltet', 'info');
        }

        // ---------------------------------------------------------------------
        //  KOMPAKTE ANSICHT: dichte Listendarstellung ohne Bilder – ideal zum
        //  schnellen Überfliegen vieler Schlagzeilen auf dem Handy. Persistiert.
        // ---------------------------------------------------------------------
        function toggleCompactMode() {
            const on = !document.body.classList.contains('compact-mode');
            _setToggle('compact-mode', 'news_compact', 'compact-btn', on, 'Kompakte Ansicht');
        }

        // Gespeicherte A11y-Einstellungen beim Start anwenden (ohne Toast).
        function applySavedA11y() {
            try {
                const map = [
                    ['high-contrast', 'news_contrast', 'contrast-btn'],
                    ['reduce-motion', 'news_motion', 'motion-btn'],
                    ['dyslexia', 'news_dyslexia', 'dyslexia-btn'],
                    ['compact-mode', 'news_compact', 'compact-btn']
                ];
                map.forEach(([cls, key, btnId]) => {
                    if (localStorage.getItem(key) === '1') {
                        document.body.classList.add(cls);
                        const btn = document.getElementById(btnId);
                        if (btn) btn.setAttribute('aria-pressed', 'true');
                    }
                });
            } catch (e) {}
        }

        // ---------------------------------------------------------------------
        //  STATISTIK-PANEL (faktische Kennzahlen)
        // ---------------------------------------------------------------------
        function buildStatsPanel() {
            if (!appState.allNews.length) return null;
            const total = appState.allNews.length;
            const sources = new Set(appState.allNews.map(n => n.category)).size;
            const readCount = appState.allNews.filter(n => isRead(n.id)).length;
            const favCount = appState.favorites.length;
            const updated = appState.lastUpdated
                ? appState.lastUpdated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
                : '–';

            const wrap = document.createElement('div');
            wrap.className = 'stats-panel';
            wrap.id = 'stats-panel';
            wrap.setAttribute('aria-label', 'Statistik');
            wrap.innerHTML = `
                <div class="stat-chip"><span class="num" data-stat="total">${total}</span><span class="lbl">Meldungen</span></div>
                <div class="stat-chip"><span class="num" data-stat="sources">${sources}</span><span class="lbl">Quellen</span></div>
                <div class="stat-chip"><span class="num" data-stat="read">${readCount}</span><span class="lbl">Gelesen</span></div>
                <div class="stat-chip"><span class="num" data-stat="fav">${favCount}</span><span class="lbl">Favoriten</span></div>
                <div class="stat-chip"><span class="num" data-stat="updated">${updated}</span><span class="lbl">Aktualisiert</span></div>
            `;
            return wrap;
        }
        // Aktualisiert die Zahlen im vorhandenen Panel, ohne alles neu zu rendern.
        function updateStatsNumbers() {
            const panel = document.getElementById('stats-panel');
            if (!panel) return;
            const set = (k, v) => { const el = panel.querySelector(`[data-stat="${k}"]`); if (el) el.textContent = v; };
            set('total', appState.allNews.length);
            set('sources', new Set(appState.allNews.map(n => n.category)).size);
            set('read', appState.allNews.filter(n => isRead(n.id)).length);
            set('fav', appState.favorites.length);
        }

        // ---------------------------------------------------------------------
        //  TREND-THEMEN (faktenbasierte Häufigkeitsanalyse)
        //  Zählt, welche Begriffe in den aktuell geladenen ECHTEN Schlagzeilen am
        //  häufigsten vorkommen. Keine Interpretation, keine Wertung, keine
        //  KI-Vermutung – reine, nachvollziehbare Wortzählung. Ein Begriff zählt
        //  pro Meldung nur EINMAL, damit Wiederholungen im selben Titel nicht
        //  das Ergebnis verzerren.
        // ---------------------------------------------------------------------
        const STOPWORDS = new Set([
            'der','die','das','den','dem','des','ein','eine','einer','eines','einem','einen',
            'und','oder','aber','doch','denn','sondern','als','wie','wenn','weil','dass','ob',
            'ist','sind','war','waren','wird','werden','wurde','wurden','hat','habe','haben',
            'hatte','hatten','kann','können','muss','müssen','soll','sollen','will','wollen',
            'für','von','vom','mit','nach','bei','beim','aus','auf','über','unter','vor','zur',
            'zum','zu','im','in','an','am','um','durch','gegen','ohne','bis','seit','nicht',
            'auch','noch','nur','schon','mehr','sehr','so','es','er','sie','ich','wir','ihr',
            'man','sich','sein','ihre','ihren','seine','seinen','dieser','diese','dieses',
            'kein','keine','alle','allen','was','wer','wo','warum','wieder','immer','jetzt',
            'heute','neue','neuen','neuer','neues','mehr','gibt','geht','macht','sagt','sagte',
            'soll','beim','einem','einer','wegen','zwischen','sowie','laut','etwa','rund',
            'prozent','jahr','jahre','jahren','uhr','the','and','for','with','was','video',
            'live','news','update','mehr','dabei','dazu','damit','dann','ihm','ihn','deren'
        ]);

        function computeTrendingTopics(list, limit) {
            const counts = new Map();
            const source = (list || []).slice(0, 300); // Begrenzen: schnell auch mobil
            source.forEach(item => {
                if (!item || !item.title) return;
                // Nur Titel auswerten (klar, überprüfbar). Einmal pro Meldung zählen.
                const seenInItem = new Set();
                const words = String(item.title)
                    .replace(/[„“"»«'’\(\)\[\]\{\}]/g, ' ')
                    .split(/[\s,;:!?\.\/\-–—]+/);
                words.forEach(raw => {
                    const w = raw.replace(/[^\wäöüÄÖÜß]/g, '');
                    if (w.length < 4) return;                       // zu kurz
                    const lower = w.toLowerCase();
                    if (STOPWORDS.has(lower)) return;               // Füllwort
                    if (/^\d+$/.test(w)) return;                    // reine Zahl
                    if (seenInItem.has(lower)) return;              // pro Meldung 1×
                    seenInItem.add(lower);
                    const entry = counts.get(lower) || { count: 0, label: w };
                    entry.count++;
                    // Groß geschriebene Variante als Anzeigeform bevorzugen
                    if (/^[A-ZÄÖÜ]/.test(w)) entry.label = w;
                    counts.set(lower, entry);
                });
            });
            return Array.from(counts.entries())
                .map(([key, v]) => ({ key: key, label: v.label, count: v.count }))
                .filter(t => t.count >= 2)          // nur echte Häufungen
                .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de'))
                .slice(0, limit || 10);
        }

        // Baut die klickbare Themen-Leiste. Klick setzt den Suchbegriff.
        function buildTrendingBar() {
            const topics = computeTrendingTopics(appState.allNews, 10);
            if (topics.length < 3) return null;   // zu wenig Datenlage -> nichts behaupten
            const esc = SecurityUtils.escapeHtml;
            const wrap = document.createElement('div');
            wrap.className = 'trending-bar';
            wrap.id = 'trending-bar';
            wrap.setAttribute('aria-label', 'Häufigste Begriffe in den aktuellen Schlagzeilen');
            wrap.innerHTML =
                `<span class="trending-label" title="Reine Wortzählung aus den aktuell geladenen Schlagzeilen">🔥 Häufige Begriffe</span>` +
                topics.map(t =>
                    `<button type="button" class="trend-chip" onclick="searchTopic('${esc(t.key)}')"
                        title="${esc(t.label)} kommt in ${t.count} aktuellen Schlagzeilen vor">
                        ${esc(t.label)}<span class="trend-count">${t.count}</span>
                     </button>`
                ).join('');
            return wrap;
        }

        // ---------------------------------------------------------------------
        //  ÄHNLICHE MELDUNGEN (faktenbasiert über Wortüberschneidung)
        //  Vergleicht die aussagekräftigen Wörter zweier Schlagzeilen. Je mehr
        //  seltene Begriffe zwei Meldungen teilen, desto höher die Punktzahl.
        //  Das ist eine nachvollziehbare Mengenschnittmenge – keine KI-Deutung,
        //  keine Behauptung eines inhaltlichen Zusammenhangs. Deshalb wird der
        //  Bereich auch nur angezeigt, wenn es echte Überschneidungen gibt.
        // ---------------------------------------------------------------------
        function significantWords(text) {
            const set = new Set();
            if (!text) return set;
            String(text)
                .replace(/[„“"»«'’\(\)\[\]\{\}]/g, ' ')
                .split(/[\s,;:!?\.\/\-–—]+/)
                .forEach(raw => {
                    const w = raw.replace(/[^\wäöüÄÖÜß]/g, '').toLowerCase();
                    if (w.length >= 4 && !STOPWORDS.has(w) && !/^\d+$/.test(w)) set.add(w);
                });
            return set;
        }

        function findRelatedArticles(item, limit) {
            if (!item) return [];
            const base = significantWords(item.title);
            if (base.size === 0) return [];

            const scored = [];
            // Auf die neuesten 250 Meldungen begrenzen – schnell auch auf dem Handy
            appState.allNews.slice(0, 250).forEach(other => {
                if (!other || other.id === item.id) return;
                if (other.link && item.link && other.link === item.link) return; // identisch
                const words = significantWords(other.title);
                let shared = 0;
                const sharedWords = [];
                words.forEach(w => {
                    if (base.has(w)) { shared++; sharedWords.push(w); }
                });
                if (shared >= 2) {   // mindestens ZWEI gemeinsame Begriffe
                    // Normieren, damit lange Titel nicht automatisch gewinnen
                    const score = shared / Math.sqrt(words.size || 1);
                    scored.push({ item: other, score: score, shared: shared, words: sharedWords });
                }
            });

            return scored
                .sort((a, b) => b.score - a.score || b.item.timestamp - a.item.timestamp)
                .slice(0, limit || 4);
        }

        // Baut den HTML-Block „Ähnliche Meldungen" für den Lesemodus.
        function buildRelatedHtml(item) {
            const related = findRelatedArticles(item, 4);
            if (!related.length) return '';   // Keine echten Treffer -> nichts behaupten
            const esc = SecurityUtils.escapeHtml;
            const rows = related.map(r => {
                const o = r.item;
                const shared = r.words.slice(0, 3).join(', ');
                return `<li>
                    <button type="button" class="related-item" onclick="openArticleById('${esc(o.id)}')">
                        <span class="related-cat source-badge ${esc(o.badgeClass)}">${esc(o.category)}</span>
                        <span class="related-title">${esc(o.title)}</span>
                        <span class="related-meta">${esc(timeAgo(o.date))} · gemeinsame Begriffe: ${esc(shared)}</span>
                    </button>
                </li>`;
            }).join('');
            return `
                <section class="related-section" aria-labelledby="related-title">
                    <h2 id="related-title" class="related-heading">Ähnliche Meldungen</h2>
                    <p class="related-note">Ermittelt über gemeinsame Begriffe in den Schlagzeilen –
                       mindestens zwei Übereinstimmungen. Rein rechnerisch, ohne inhaltliche Bewertung.</p>
                    <ul class="related-list">${rows}</ul>
                </section>`;
        }

        // Klick auf ein Trend-Thema: als Suche übernehmen (sichtbar im Suchfeld).
        function searchTopic(term) {
            const input = document.getElementById('searchInput');
            if (input) input.value = term;
            appState.searchQuery = String(term).toLowerCase();
            appState.renderLimit = PAGE_SIZE;
            filterData();
            scrollToTop();
            showToast('Thema: ' + term, 'info');
            announce('Gefiltert nach Thema ' + term);
        }

        // ---------------------------------------------------------------------
        //  AUFMACHER / HERO
        // ---------------------------------------------------------------------
        function buildHero(item) {
            const esc = SecurityUtils.escapeHtml;
            const el = document.createElement('article');
            el.className = 'hero' + (isRead(item.id) ? ' is-read' : '');
            el.setAttribute('data-id', item.id);
            el.setAttribute('itemscope', '');
            el.setAttribute('itemtype', 'https://schema.org/NewsArticle');
            el.setAttribute('role', 'link');
            el.setAttribute('tabindex', '0');
            el.setAttribute('aria-label', 'Aufmacher: ' + item.title);
            // Herkunft auch am Aufmacher hinterlegen. Ohne das blieb die
            // neueste Meldung, die immer als Aufmacher laeuft, als einzige
            // ohne Regions- und Sprachkennzeichnung.
            el.setAttribute('data-region', item.region || 'Deutschland');
            el.setAttribute('data-lang', item.lang || 'de');
            const dateStr = item.date.toLocaleString('de-DE', { day: '2-digit', month: 'long', hour: '2-digit', minute: '2-digit' }) + ' Uhr';
            const readTime = item.readingTime || estimateReadingTime(item.description);
            const media = item.image
                ? `<div class="hero-media"><img src="${esc(item.image)}" alt="${esc(item.title)}" loading="eager" itemprop="image" onerror="this.closest('.hero-media').style.background='var(--gradient-brand)';this.remove()"></div>`
                : `<div class="hero-media" style="background:var(--gradient-brand)"></div>`;
            el.innerHTML = `
                ${media}
                <div class="hero-body">
                    <span class="hero-kicker">★ Aufmacher · <span itemprop="articleSection">${esc(item.category)}</span></span>
                    ${buildRegionBadge(item)}
                    ${buildHighlightBadge(item)}
                    <h2 class="hero-title" itemprop="headline"${langAttr(item)}>${esc(item.title)}</h2>
                    <p class="hero-excerpt" itemprop="description"${langAttr(item)}>${esc(item.description)}</p>
                    <div class="hero-meta">
                        <time datetime="${new Date(item.timestamp).toISOString()}" itemprop="datePublished">🕒 ${esc(dateStr)}</time>
                        <span>⏱ ca. ${readTime} Min. Lesezeit</span>
                        <span class="source-badge ${esc(item.badgeClass)}">${esc(item.category)}</span>
                    </div>
                </div>
            `;
            const open = () => openArticleById(item.id);
            el.addEventListener('click', open);
            el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
            return el;
        }

        // Öffnet einen Artikel in der Einzelansicht (aus Hero/Programm heraus).
        function openArticleById(id) {
            const index = appState.allNews.findIndex(n => n.id === id);
            if (index === -1) return;
            markRead(id);
            if (location.protocol.startsWith('http')) {
                // Stabiler Slug-Permalink (bleibt via Archiv auflösbar)
                history.pushState({ newsIndex: index }, '', `#/artikel/${articleSlug(appState.allNews[index])}`);
            }
            renderSingleNews(index);
            updateMetaForNews(index);
            scrollToTop();
        }

        function renderNews() {
            const container = document.getElementById('news-container');
            const noRes = document.getElementById('no-results');
            // Übersicht: Intro (mit der einzigen <h1>) einblenden.
            const intro = document.getElementById('intro');
            if (intro) intro.style.display = '';
            container.innerHTML = '';
            // Etwaigen „Mehr anzeigen“-Bereich immer zuerst entfernen (kein Stale-State).
            const staleMore = document.getElementById('load-more-wrap');
            if (staleMore) staleMore.remove();

            if (appState.filteredNews.length === 0) {
                noRes.style.display = 'block';
                return;
            } else {
                noRes.style.display = 'none';
            }

            // Verwende ein DocumentFragment für performantes Batch-Rendering.
            // Statt N-fachem Reflow (container.appendChild in der Schleife) wird
            // nur EINMAL ins DOM geschrieben -> spürbar schneller bei vielen Karten.
            const fragment = document.createDocumentFragment();
            const esc = SecurityUtils.escapeHtml;

            // PAGINIERUNG (Performance/Mobil): Es werden nur die ersten
            // appState.renderLimit Karten gerendert. Weitere lädt der Nutzer per
            // „Mehr anzeigen“-Button oder automatisch beim Scrollen nach. Das
            // verhindert, dass hunderte Karten auf einmal das DOM belasten.
            const limit = appState.renderLimit || PAGE_SIZE;
            const visibleNews = appState.filteredNews.slice(0, limit);

            // STATISTIK-PANEL: kompakte Kennzahlen (nur auf Startansicht ohne Suche).
            const showExtras = (appState.currentFilter === 'all' && !appState.searchQuery && appState.sortMode === 'newest');
            if (showExtras) {
                const statsEl = buildStatsPanel();
                if (statsEl) fragment.appendChild(statsEl);
                // TREND-THEMEN: häufigste Begriffe aus den echten Schlagzeilen
                const trendEl = buildTrendingBar();
                if (trendEl) fragment.appendChild(trendEl);
            }

            // AUFMACHER / HERO: neueste Meldung groß präsentieren (redaktioneller Look).
            let newsToRender = visibleNews;
            if (showExtras && visibleNews.length > 0) {
                fragment.appendChild(buildHero(visibleNews[0]));
                newsToRender = visibleNews.slice(1); // Aufmacher nicht doppelt zeigen
            }

            // Chronologische Gruppierung nur bei zeitlicher Sortierung sinnvoll.
            const chronological = (appState.sortMode === 'newest' || appState.sortMode === 'oldest');
            let currentGroup = null;

            newsToRender.forEach((item, index) => {
                // STRUKTURIERT & CHRONOLOGISCH: Datums-Trennüberschriften einfügen
                // (Heute / Gestern / Wochentag, Datum), sobald sich der Tag ändert.
                if (chronological) {
                    const groupLabel = getDateGroupLabel(item.date);
                    if (groupLabel !== currentGroup) {
                        currentGroup = groupLabel;
                        const header = document.createElement('h2');
                        header.className = 'date-group';
                        header.setAttribute('role', 'separator');
                        header.textContent = groupLabel;
                        fragment.appendChild(header);
                    }
                }

                const dateStr = item.date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute:'2-digit' }) + ' Uhr';
                const isFav = isFavorite(item.id);
                const readTime = item.readingTime || estimateReadingTime(item.description);

                const card = document.createElement('article');
                // Gelesen-Zustand optisch kennzeichnen (dezent abgedunkelt + Häkchen)
                card.className = 'card' + (isRead(item.id) ? ' is-read' : '');
                card.setAttribute('data-id', item.id);
                card.setAttribute('data-category', item.category);
                card.setAttribute('data-region', item.region || 'Deutschland');
                card.setAttribute('data-lang', item.lang || 'de');
                // BARRIEREFREIHEIT: Karte per Tastatur fokussier- & bedienbar machen.
                // Vorher konnten Tastatur-/Screenreader-Nutzer die ganze Karte nicht
                // öffnen (nur den „Vorschau lesen“-Link). Enter/Leertaste öffnet nun.
                card.setAttribute('tabindex', '0');
                card.setAttribute('role', 'article');
                card.setAttribute('aria-label', item.title + ' – ' + item.category + ', ' + timeAgo(item.date));
                // Add Microdata for SEO
                card.setAttribute('itemscope', '');
                card.setAttribute('itemtype', 'https://schema.org/NewsArticle');

                // BUGFIX (Anti-XSS): Alle dynamischen Werte werden beim Einfügen
                // in HTML escaped. Auch wenn die Quelle bereinigt ist, verhindert
                // das Escaping am Ausgabepunkt jede versehentliche Injektion.
                const imageBlock = item.image
                    ? `<div class="card-image">
                           <img src="${esc(item.image)}" alt="${esc(item.title)}" loading="lazy"
                                itemprop="image" onerror="this.parentNode.style.display='none'">
                       </div>`
                    : '';

                card.innerHTML = `
                    ${imageBlock}
                    <div class="card-header">
                        <span class="source-badge ${esc(item.badgeClass)}" itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
                            <span itemprop="name">${esc(item.category)}</span>
                        </span>
                        ${buildRegionBadge(item)}
                        ${buildHighlightBadge(item)}
                        <time class="time-stamp" datetime="${new Date(item.timestamp).toISOString()}" itemprop="datePublished" title="${esc(dateStr)}">${esc(timeAgo(item.date))}</time>
                    </div>
                    <div class="card-body">
                        <h2 class="card-title" itemprop="headline"${langAttr(item)}>${highlightTerms(esc(item.title))}</h2>
                        <p class="card-excerpt" itemprop="description"${langAttr(item)}>${highlightTerms(esc(item.description))}</p>
                        <span class="reading-time" aria-label="Geschätzte Lesezeit">⏱ ca. ${readTime} Min. Lesezeit</span>
                        <meta itemprop="url" content="${articlePermalink(item)}">
                        <meta itemprop="dateModified" content="${new Date(item.timestamp).toISOString()}">
                        <meta itemprop="articleSection" content="${esc(item.category)}">
                        <meta itemprop="keywords" content="${esc(item.category)}, Nachrichten, Deutschland, ${esc(item.title.split(' ').slice(0,3).join(', '))}">
                        ${buildAuchBei(item)}
                    </div>
                    <div class="card-footer">
                        <a href="javascript:void(0)" onclick="openModal('${esc(item.id)}')" class="read-btn" itemprop="url" aria-label="Vorschau von: ${esc(item.title)}">
                            Vorschau lesen &rarr;
                        </a>
                        <div class="card-actions">
                            <button type="button" class="icon-btn favorite-star ${isFav ? 'favorited' : ''}"
                                    onclick="toggleFavorite('${esc(item.id)}', this)"
                                    aria-pressed="${isFav}" title="Zu Favoriten hinzufügen"
                                    aria-label="Artikel zu Favoriten hinzufügen">${isFav ? '★' : '☆'}</button>
                            <button type="button" class="icon-btn share-btn"
                                    onclick="shareArticle('${esc(item.id)}')"
                                    title="Artikel teilen" aria-label="Artikel teilen">⤴</button>
                            <a href="${esc(item.link)}" target="_blank" rel="nofollow noopener noreferrer" class="read-btn" style="opacity:0.7" title="Zur Originalquelle">
                                Quelle &#8599;
                            </a>
                        </div>
                    </div>
                `;
                fragment.appendChild(card);
            });

            container.appendChild(fragment);

            // „Mehr anzeigen“-Bereich, falls noch weitere Meldungen vorhanden sind.
            const remaining = appState.filteredNews.length - visibleNews.length;
            let moreWrap = document.getElementById('load-more-wrap');
            if (moreWrap) moreWrap.remove();
            if (remaining > 0) {
                moreWrap = document.createElement('div');
                moreWrap.id = 'load-more-wrap';
                moreWrap.className = 'load-more-wrap';
                const nextChunk = Math.min(remaining, PAGE_SIZE);
                moreWrap.innerHTML =
                    `<button type="button" class="load-more-btn" onclick="loadMore()">
                        Mehr anzeigen (${nextChunk} von ${remaining} weiteren)
                     </button>`;
                container.parentNode.insertBefore(moreWrap, container.nextSibling);
                // Sentinel für automatisches Nachladen beim Scrollen (Infinite Scroll)
                observeLoadMoreSentinel(moreWrap);
            }

            updateResultsCount();
        }

        // Erhöht das Render-Limit und zeigt weitere Meldungen an.
        function loadMore() {
            appState.renderLimit = (appState.renderLimit || PAGE_SIZE) + PAGE_SIZE;
            renderNews();
        }

        // Automatisches Nachladen, sobald der „Mehr anzeigen“-Bereich sichtbar wird.
        let loadMoreObserver = null;
        function observeLoadMoreSentinel(el) {
            if (!('IntersectionObserver' in window)) return; // Fallback: nur Button
            if (loadMoreObserver) loadMoreObserver.disconnect();
            loadMoreObserver = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        loadMoreObserver.disconnect();
                        loadMore();
                    }
                });
            }, { rootMargin: '300px' });
            loadMoreObserver.observe(el);
        }

        // PERFORMANCE-BUGFIX: handleSearch() wurde per onkeyup bei JEDEM Tastendruck
        // ausgelöst und baute jedes Mal die komplette Kartenliste neu auf. Bei
        // mehreren hundert Meldungen führte das auf Mobilgeräten zu spürbarem
        // Ruckeln und verschluckten Eingaben. Jetzt entprellt (Debounce): gerendert
        // wird erst, wenn ~220 ms nicht mehr getippt wurde. Enter rendert sofort.
        let _searchTimer = null;
        function handleSearch(immediate) {
            const input = document.getElementById('searchInput');
            if (!input) return;
            const query = input.value.toLowerCase().trim();

            clearTimeout(_searchTimer);
            const run = () => {
                // Nur neu filtern, wenn sich die Suche tatsächlich geändert hat
                if (appState.searchQuery === query) return;
                appState.searchQuery = query;
                appState.renderLimit = PAGE_SIZE; // Bei neuer Suche wieder oben beginnen
                filterData();
            };

            if (immediate === true) run();
            else _searchTimer = setTimeout(run, 220);
        }

        function applyFilter(category, btnElement) {
            // UI Update
            document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
            if (btnElement) btnElement.classList.add('active');

            appState.currentFilter = category;
            appState.renderLimit = PAGE_SIZE; // Bei neuem Filter wieder oben beginnen
            filterData();
        }

        // skipRender=true: nur appState.filteredNews aktualisieren, NICHT neu rendern
        // (z. B. während der Nutzer einen Artikel liest -> kein Rauswurf).
        function filterData(skipRender) {
            let temp = appState.allNews.slice(); // Kopie, um Original nicht zu verändern

            // 1. Kategorie- bzw. Favoriten-Filter
            if (appState.currentFilter === 'favorites') {
                temp = temp.filter(item => isFavorite(item.id));
            } else if (appState.currentFilter !== 'all') {
                temp = temp.filter(item => item.category === appState.currentFilter);
            }

            // 1a. ABGESCHALTETE QUELLEN ausblenden (bleiben im Archiv erhalten)
            if (appState.disabledSources && appState.disabledSources.length) {
                temp = temp.filter(item => isSourceEnabled(item.category));
            }

            // 1c. REGIONS-FILTER. Meldungen ohne Regionsangabe stammen aus den
            //     deutschen Bestandsquellen und gelten daher als "Deutschland".
            if (appState.regionFilter && appState.regionFilter !== 'all') {
                temp = temp.filter(item => (item.region || 'Deutschland') === appState.regionFilter);
            }

            // 1d. SPRACH-FILTER. Ohne Angabe gilt Deutsch, damit die
            //     Bestandsquellen unverändert im Deutsch-Filter erscheinen.
            if (appState.langFilter && appState.langFilter !== 'all') {
                temp = temp.filter(item => (item.lang || 'de') === appState.langFilter);
            }

            // 1b. ZEITRAUM-FILTER (rein rechnerisch aus dem Veröffentlichungsdatum)
            if (appState.timeRange && appState.timeRange !== 'all') {
                const cutoff = getTimeRangeCutoff(appState.timeRange);
                if (cutoff !== null) {
                    temp = temp.filter(item => item.timestamp >= cutoff);
                }
            }

            // 2. Suchfilter (Titel + Beschreibung, mehrere Begriffe UND-verknüpft)
            if (appState.searchQuery.length > 0) {
                const terms = appState.searchQuery.split(/\s+/).filter(Boolean);
                temp = temp.filter(item => {
                    // Region und Herausgeber sind mitdurchsuchbar, damit etwa
                    // "Europa" oder "Guardian" direkt als Suchbegriff funktioniert.
                    const haystack = (item.title + ' ' + item.description + ' ' + item.category
                        + ' ' + (item.region || '') + ' ' + (item.publisher || '')).toLowerCase();
                    return terms.every(term => haystack.includes(term));
                });
            }

            // 3. Sortierung anwenden
            temp = sortNews(temp, appState.sortMode);

            appState.filteredNews = temp;
            // Mehrfachberichte auf dem GESAMTEN Bestand berechnen, nicht nur auf
            // der gefilterten Auswahl. Sonst verschwaende ein aktiver Filter den
            // Hinweis, obwohl die andere Meldung sehr wohl vorliegt.
            appState.mehrfachberichte = findeMehrfachberichte(appState.allNews || []);
            if (!skipRender) renderNews();
        }




        // ==================================================================
        //  EINKLAPPBARER HERKUNFTSBEREICH
        // ------------------------------------------------------------------
        //  Auf breiten Bildschirmen ist Platz genug, dort steht der Bereich
        //  offen. Auf dem Handy war er zuvor immer sichtbar und hat zusammen
        //  mit der Quellenleiste ueber 260 Pixel Hoehe belegt, sodass die
        //  erste Meldung erst weit unterhalb des Bildschirms begann.
        //  Zugeklappt zeigt die Zeile, was gerade ausgewaehlt ist, damit ein
        //  aktiver Filter nicht unbemerkt bleibt.
        // ==================================================================
        function initHerkunftBox() {
            const breit = window.matchMedia('(min-width: 681px)').matches;
            const box = document.getElementById('herkunft-box');
            if (box) box.open = breit;
            // Die Einleitung folgt derselben Regel: auf breiten Bildschirmen
            // offen, auf dem Handy zugeklappt. Der Text bleibt in beiden Faellen
            // im Dokument und damit fuer Suchmaschinen lesbar.
            const intro = document.querySelector('.intro-mehr');
            if (intro) intro.open = breit;
            aktualisiereHerkunftHinweis();
        }

        function aktualisiereHerkunftHinweis() {
            const el = document.getElementById('herkunft-hinweis');
            if (!el) return;
            const r = appState.regionFilter || 'all';
            const s = appState.langFilter || 'all';
            const sprachen = (CONFIG.languages || {});
            const teile = [];
            if (r !== 'all') teile.push(r);
            if (s !== 'all') teile.push(sprachen[s] || s);
            el.textContent = teile.length ? teile.join(' · ') : 'alle';
            el.classList.toggle('aktiv', teile.length > 0);
        }

        // ==================================================================
        //  REGIONEN-UNTERSEITEN  /region/<slug>
        // ------------------------------------------------------------------
        //  Gleiches Muster wie die Quellen-Unterseiten: eine echte, teilbare
        //  und indexierbare Adresse je Herkunftsregion, mit eigenem Titel,
        //  eigener Beschreibung und eigenen strukturierten Daten.
        //  Alle Zahlenangaben stammen aus den tatsaechlich geladenen Meldungen.
        //  _redirects liefert /region/* mit Status 200 aus, den Rest macht
        //  der Router.
        // ==================================================================

        // Kurzbeschreibung je Region. Rein beschreibend, ohne Wertung und ohne
        // Behauptungen ueber Vollstaendigkeit.
        const REGION_TEXTE = {
            'Deutschland':   'Meldungen deutscher Nachrichtenquellen.',
            'Europa':        'Meldungen europaeischer Quellen ausserhalb Deutschlands, '
                           + 'darunter Oesterreich, die Schweiz, Frankreich und die Europaeische Kommission.',
            'Nordamerika':   'Meldungen nordamerikanischer Quellen aus den Vereinigten Staaten und Kanada.',
            'Asien-Pazifik': 'Meldungen aus dem asiatisch-pazifischen Raum, darunter Australien, Japan und Indien.',
            'Weltweit':      'Meldungen international ausgerichteter Quellen ohne festen Laenderschwerpunkt.'
        };

        function regionSlug(region) {
            return categorySlug(region);
        }

        function findeRegionNachSlug(slug) {
            const s = String(slug || '').toLowerCase();
            return (CONFIG.regions || []).find(r => regionSlug(r) === s) || null;
        }

        function regionUrl(region) {
            return 'https://nachrichten.kaipfstr.de/region/' + regionSlug(region);
        }

        // Oeffnet eine Regionsseite: setzt den Filter, die Adresse und die Metadaten.
        function openRegionPage(region, pushUrl) {
            appState.regionFilter = region;
            appState.currentFilter = 'all';
            appState.renderLimit = PAGE_SIZE;
            document.querySelectorAll('.region-btn').forEach(b => {
                const treffer = b.getAttribute('data-region') === region;
                b.classList.toggle('active', treffer);
                b.setAttribute('aria-pressed', treffer ? 'true' : 'false');
            });
            document.querySelectorAll('.filter-btn').forEach(b => {
                b.classList.toggle('active', b.getAttribute('data-cat') === 'all');
            });
            if (pushUrl !== false && location.protocol.startsWith('http')) {
                history.pushState({ region: region }, '', '/region/' + regionSlug(region));
            }
            filterData();
            updateRegionCounts();
            updateMetaForRegion(region);
            scrollToTop();
        }

        // Titel, Beschreibung, Canonical und strukturierte Daten einer Regionsseite.
        function updateMetaForRegion(region) {
            const items = (appState.allNews || []).filter(n => (n.region || 'Deutschland') === region);
            const anzahl = items.length;
            const neueste = anzahl ? new Date(items[0].timestamp) : null;
            const quellen = [];
            items.forEach(i => { if (quellen.indexOf(i.category) === -1) quellen.push(i.category); });

            const titel = `Nachrichten aus ${region} | Nachrichten Kaipfstr`;
            const basis = REGION_TEXTE[region] || `Meldungen aus der Region ${region}.`;
            const beschreibung = anzahl
                ? `${basis} Aktuell ${anzahl} Meldungen aus ${quellen.length} `
                  + `${quellen.length === 1 ? 'Quelle' : 'Quellen'}`
                  + (neueste ? `, neueste vom ${neueste.toLocaleDateString('de-DE')}` : '')
                  + '. Jede Meldung verlinkt direkt auf den Originalartikel.'
                : basis;

            document.title = titel;
            updateMetaTag('description', beschreibung.substring(0, 300));
            updateMetaTag('og:title', titel);
            updateMetaTag('og:description', beschreibung.substring(0, 300));
            updateMetaTag('og:url', regionUrl(region));
            updateMetaTag('og:type', 'website');
            setCanonical(regionUrl(region));

            addJsonLd({
                "@context": "https://schema.org",
                "@type": "CollectionPage",
                "name": titel,
                "description": beschreibung,
                "url": regionUrl(region),
                "inLanguage": "de-DE",
                "isPartOf": {
                    "@type": "WebSite",
                    "name": "Nachrichten Kaipfstr",
                    "url": "https://nachrichten.kaipfstr.de"
                },
                "breadcrumb": {
                    "@type": "BreadcrumbList",
                    "itemListElement": [
                        { "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://nachrichten.kaipfstr.de/" },
                        { "@type": "ListItem", "position": 2, "name": "Regionen", "item": "https://nachrichten.kaipfstr.de/regionen" },
                        { "@type": "ListItem", "position": 3, "name": region, "item": regionUrl(region) }
                    ]
                },
                "mainEntity": {
                    "@type": "ItemList",
                    "numberOfItems": Math.min(anzahl, 20),
                    "itemListElement": items.slice(0, 20).map((it, i) => ({
                        "@type": "ListItem",
                        "position": i + 1,
                        "url": articlePermalink(it),
                        "name": it.title
                    }))
                }
            });
        }

        // Uebersicht aller Regionen, erreichbar unter /regionen.
        function renderRegionIndexPage() {
            const esc = SecurityUtils.escapeHtml;
            const container = document.getElementById('news-container');
            if (!container) return;
            const zaehler = countByRegion();
            const karten = (CONFIG.regions || []).map(r => {
                const n = zaehler[r] || 0;
                return `<a class="src-index-item" href="/region/${esc(regionSlug(r))}"
                           onclick="openRegionPage('${esc(r)}');return false;">
                            <strong>${esc(r)}</strong>
                            <span>${esc(REGION_TEXTE[r] || '')}</span>
                            <em>${n} ${n === 1 ? 'Meldung' : 'Meldungen'} geladen</em>
                        </a>`;
            }).join('');
            container.innerHTML = `
                <section class="source-index">
                    <h1>Nachrichten nach Herkunft</h1>
                    <p>Die Quellen dieser Seite sind nach Herkunftsregion gegliedert.
                       Die angegebenen Zahlen stammen aus den derzeit geladenen Meldungen
                       und aendern sich mit jedem Abruf.</p>
                    ${karten}
                </section>`;
            document.title = 'Nachrichten nach Herkunft | Nachrichten Kaipfstr';
            updateMetaTag('description',
                'Uebersicht aller Herkunftsregionen im Nachrichten-Aggregator: '
                + 'Deutschland, Europa, Nordamerika, Asien und Pazifik sowie weltweit.');
            setCanonical('https://nachrichten.kaipfstr.de/regionen');
            scrollToTop();
        }

        // ==================================================================
        //  MEHRFACHBERICHTERSTATTUNG
        // ------------------------------------------------------------------
        //  Bei 40 Quellen berichten regelmaessig mehrere ueber dasselbe
        //  Ereignis. Diese Funktion erkennt solche Faelle allein anhand der
        //  echten Schlagzeilen und weist sie auf der Karte aus.
        //
        //  Bewusst NICHT gemacht: Meldungen ausblenden oder zusammenlegen.
        //  Das wuerde Trefferzahlen und Filter verfaelschen. Es wird lediglich
        //  eine Zeile ergaenzt, die auf die anderen Berichte verlinkt.
        //
        //  Verfahren, absichtlich konservativ gewaehlt:
        //    1. Titel normalisieren (Kleinschreibung, Umlaute, Satzzeichen weg)
        //    2. Fuellwoerter und Woerter unter vier Zeichen verwerfen
        //    3. nur die verbleibenden, aussagekraeftigen Woerter vergleichen
        //    4. als dieselbe Sache gilt, was mindestens ZWEI seltene Woerter
        //       teilt UND innerhalb von 24 Stunden erschienen ist
        //    5. Meldungen derselben Quelle werden nie miteinander gebuendelt
        //  Lieber ein Treffer zu wenig als eine falsche Behauptung, zwei
        //  Meldungen handelten vom selben Ereignis.
        // ==================================================================

        const FUELLWOERTER = new Set([
            'aber','alle','allen','aller','alles','also','auch','auf','aus','bei','beim',
            'bis','dabei','dann','das','dass','dem','den','der','des','die','dies','diese',
            'diesem','diesen','dieser','doch','durch','ein','eine','einem','einen','einer',
            'eines','fuer','gegen','haben','hat','hatte','hier','ihre','ihren','immer','ist',
            'kann','koennen','mehr','mit','nach','nicht','noch','nur','oder','ohne','sein',
            'seine','sich','sie','sind','soll','sollen','ueber','und','uns','unter','vom',
            'von','vor','waehrend','war','waren','was','wegen','weil','welche','wenn','werden',
            'wie','wieder','wird','wurde','wurden','zum','zur','zwischen',
            'about','after','also','and','are','because','been','before','being','between',
            'but','can','could','does','first','for','from','has','have','how','into','its',
            'more','most','new','not','now','only','other','our','out','over','said','says',
            'she','should','some','such','than','that','the','their','them','then','there',
            'these','they','this','those','through','under','used','was','were','what','when',
            'where','which','while','who','will','with','would','you','your'
        ]);

        // Zerlegt eine Schlagzeile in aussagekraeftige Woerter.
        function titelWoerter(titel) {
            return String(titel || '')
                .toLowerCase()
                .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
                .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
                .replace(/[^a-z0-9\s]/g, ' ')
                .split(/\s+/)
                .filter(w => w.length >= 4 && !FUELLWOERTER.has(w));
        }

        // Baut eine Zuordnung: Meldung -> andere Meldungen zur selben Sache.
        // Rueckgabe ist eine Map von Artikel-ID auf ein Array anderer Meldungen.
        function findeMehrfachberichte(liste) {
            const treffer = new Map();
            if (!Array.isArray(liste) || liste.length < 2) return treffer;

            // Wortindex aufbauen. Nur Woerter, die in wenigen Meldungen
            // vorkommen, taugen als Hinweis. Ein Wort wie "deutschland" steht
            // in dutzenden Titeln und wuerde alles mit allem verbinden.
            const index = new Map();
            const woerterJe = new Map();
            liste.forEach(item => {
                const w = new Set(titelWoerter(item.title));
                woerterJe.set(item.id, w);
                w.forEach(wort => {
                    if (!index.has(wort)) index.set(wort, []);
                    index.get(wort).push(item);
                });
            });

            const OBERGRENZE = Math.max(3, Math.round(liste.length * 0.04));
            const TAG_MS = 24 * 60 * 60 * 1000;

            liste.forEach(item => {
                const meine = woerterJe.get(item.id);
                if (!meine || meine.size < 2) return;
                const punkte = new Map();
                meine.forEach(wort => {
                    const kandidaten = index.get(wort) || [];
                    if (kandidaten.length > OBERGRENZE) return;   // zu haeufiges Wort
                    kandidaten.forEach(anderer => {
                        if (anderer.id === item.id) return;
                        if (anderer.category === item.category) return;
                        if (Math.abs(anderer.timestamp - item.timestamp) > TAG_MS) return;
                        punkte.set(anderer.id, (punkte.get(anderer.id) || 0) + 1);
                    });
                });
                const passende = [];
                punkte.forEach((anzahl, id) => {
                    if (anzahl >= 2) {
                        const gefunden = liste.find(x => x.id === id);
                        if (gefunden) passende.push(gefunden);
                    }
                });
                if (passende.length) {
                    // Je Quelle nur EINE Meldung nennen, sonst wird die Zeile lang.
                    const proQuelle = new Map();
                    passende.sort((a, b) => b.timestamp - a.timestamp)
                            .forEach(x => { if (!proQuelle.has(x.category)) proQuelle.set(x.category, x); });
                    treffer.set(item.id, Array.from(proQuelle.values()).slice(0, 5));
                }
            });
            return treffer;
        }

        // Erzeugt die Zeile "Auch berichtet von ..." fuer eine Karte.
        function buildAuchBei(item) {
            const esc = SecurityUtils.escapeHtml;
            const andere = appState.mehrfachberichte && appState.mehrfachberichte.get(item.id);
            if (!andere || !andere.length) return '';
            const links = andere.map(x =>
                `<a href="${esc(safeUrl(x.link))}" target="_blank" rel="noopener nofollow"
                    title="${esc(x.title)}">${esc(x.category)}</a>`).join('');
            return `<div class="auch-bei">
                        <span class="auch-bei-label">Auch berichtet von</span>${links}
                    </div>`;
        }

        // ------------------------------------------------------------------
        //  HERKUNFT: Region, Sprache und Herausgeber einer Meldung
        // ------------------------------------------------------------------

        // Gibt ein lang-Attribut zurück, sobald die Meldung NICHT deutsch ist.
        // Screenreader wechseln dadurch in die richtige Aussprache, und
        // Suchmaschinen erkennen die Sprache des Textabschnitts korrekt.
        function langAttr(item) {
            const esc = SecurityUtils.escapeHtml;
            const l = item && item.lang ? item.lang : 'de';
            return l === 'de' ? '' : ` lang="${esc(l)}"`;
        }

        // Kleines Kennzeichen mit Region und, bei fremdsprachigen Meldungen,
        // dem Sprachkürzel. Deutsche Bestandsmeldungen bleiben unmarkiert,
        // damit die gewohnte Ansicht ruhig bleibt.
        function buildRegionBadge(item) {
            const esc = SecurityUtils.escapeHtml;
            const region = item.region || 'Deutschland';
            const lang = item.lang || 'de';
            if (region === 'Deutschland' && lang === 'de') return '';
            const sprache = (CONFIG.languages && CONFIG.languages[lang]) || lang;
            const kuerzel = lang === 'de' ? '' :
                `<span class="lang-tag" title="Sprache: ${esc(sprache)}">${esc(lang.toUpperCase())}</span>`;
            const herausgeber = item.publisher ? ` – Herausgeber: ${item.publisher}` : '';
            return `<span class="region-badge" title="Herkunft: ${esc(region)}${esc(herausgeber)}">`
                 + `${esc(region)}${kuerzel}</span>`;
        }

        // Zählt die Meldungen je Region, ausschließlich aus den tatsächlich
        // geladenen Daten. Keine geschätzten oder festen Werte.
        function countByRegion() {
            const zaehler = {};
            (appState.allNews || []).forEach(item => {
                const r = item.region || 'Deutschland';
                zaehler[r] = (zaehler[r] || 0) + 1;
            });
            return zaehler;
        }

        // Setzt den Regionsfilter und aktualisiert die Ansicht.
        function applyRegion(region, btnElement) {
            appState.regionFilter = region;
            document.querySelectorAll('.region-btn').forEach(b => {
                b.classList.remove('active');
                b.setAttribute('aria-pressed', 'false');
            });
            if (btnElement) {
                btnElement.classList.add('active');
                btnElement.setAttribute('aria-pressed', 'true');
            }
            appState.renderLimit = PAGE_SIZE;
            filterData();
            updateRegionCounts();
            aktualisiereHerkunftHinweis();
        }

        // Setzt den Sprachfilter und aktualisiert die Ansicht.
        function applyLang(lang, btnElement) {
            appState.langFilter = lang;
            document.querySelectorAll('.lang-btn').forEach(b => {
                b.classList.remove('active');
                b.setAttribute('aria-pressed', 'false');
            });
            if (btnElement) {
                btnElement.classList.add('active');
                btnElement.setAttribute('aria-pressed', 'true');
            }
            appState.renderLimit = PAGE_SIZE;
            filterData();
            aktualisiereHerkunftHinweis();
        }

        // Schreibt die echten Trefferzahlen in die Regionsschaltflächen.
        function updateRegionCounts() {
            const zaehler = countByRegion();
            const gesamt = (appState.allNews || []).length;
            document.querySelectorAll('.region-btn').forEach(btn => {
                const r = btn.getAttribute('data-region');
                const n = (r === 'all') ? gesamt : (zaehler[r] || 0);
                const slot = btn.querySelector('.count-badge');
                if (slot) slot.textContent = n;
                // Regionen ohne Meldungen werden ausgegraut, aber nicht versteckt,
                // damit die Leiste ihre Position behält.
                btn.classList.toggle('is-empty', n === 0 && r !== 'all');
            });
        }


        // ==================================================================
        //  MAERKTE
        // ------------------------------------------------------------------
        //  Grundsatz: Es wird nur angezeigt, was nachweislich abgerufen wurde.
        //  Kein Schaetzen, kein Auffuellen mit Platzhaltern, keine Angabe
        //  "Echtzeit", wo keine Echtzeitdaten vorliegen.
        //
        //  Quellen (beide kostenlos, ohne Registrierung, mit CORS-Freigabe):
        //    1. api.frankfurter.app  -> Referenzkurse der Europaeischen
        //       Zentralbank. Die EZB veroeffentlicht diese an Bankarbeitstagen
        //       einmal taeglich gegen 16:00 Uhr MEZ. Das sind ausdruecklich
        //       KEINE Handelskurse und keine Echtzeitdaten.
        //    2. api.coingecko.com    -> Kryptomarktdaten mit Zeitstempel der
        //       letzten Aktualisierung und Veraenderung der letzten 24 Stunden.
        //
        //  Beide Adressen wurden am 09.08.2026 geprueft: HTTP 200 und
        //  Access-Control-Allow-Origin: * , also im Browser direkt nutzbar.
        // ==================================================================

        const MARKETS = {
            fxUrl: 'https://api.frankfurter.app/latest?from=EUR&to=USD,GBP,CHF,JPY,PLN,CZK,SEK,NOK',
            cryptoUrl: 'https://api.coingecko.com/api/v3/simple/price'
                     + '?ids=bitcoin,ethereum&vs_currencies=eur'
                     + '&include_24hr_change=true&include_last_updated_at=true',
            cacheKey: 'nk_markets_v1',
            // Zwischenspeicher-Dauer. Die EZB-Kurse aendern sich ohnehin nur
            // einmal taeglich; haeufigeres Abrufen waere sinnlose Last.
            maxAgeMs: 15 * 60 * 1000,
            timeoutMs: 9000,
            waehrungen: {
                USD: 'US-Dollar', GBP: 'Britisches Pfund', CHF: 'Schweizer Franken',
                JPY: 'Japanischer Yen', PLN: 'Polnischer Zloty', CZK: 'Tschechische Krone',
                SEK: 'Schwedische Krone', NOK: 'Norwegische Krone'
            }
        };

        // Abruf mit Zeitlimit, damit eine haengende Verbindung die Seite nicht blockiert.
        async function fetchJsonWithTimeout(url, ms) {
            const ctrl = ('AbortController' in window) ? new AbortController() : null;
            const t = setTimeout(() => { if (ctrl) ctrl.abort(); }, ms || 9000);
            try {
                const res = await fetch(url, ctrl ? { signal: ctrl.signal } : undefined);
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return await res.json();
            } finally {
                clearTimeout(t);
            }
        }

        function formatNumber(value, digits) {
            return new Intl.NumberFormat('de-DE', {
                minimumFractionDigits: digits, maximumFractionDigits: digits
            }).format(value);
        }

        // Baut die Veraenderungsanzeige. Ohne belastbaren Wert wird nichts angezeigt.
        function buildChange(pct) {
            if (typeof pct !== 'number' || !isFinite(pct)) return '';
            const klasse = pct > 0.005 ? 'up' : (pct < -0.005 ? 'down' : 'flat');
            const zeichen = pct > 0 ? '+' : '';
            const pfeil = klasse === 'up' ? '▲' : (klasse === 'down' ? '▼' : '•');
            return `<span class="market-change ${klasse}">${pfeil} ${zeichen}${formatNumber(pct, 2)} %</span>`;
        }

        function readMarketCache() {
            try {
                const roh = localStorage.getItem(MARKETS.cacheKey);
                if (!roh) return null;
                const obj = JSON.parse(roh);
                if (!obj || !obj.zeit) return null;
                return obj;
            } catch (e) { return null; }
        }

        function writeMarketCache(daten) {
            try {
                localStorage.setItem(MARKETS.cacheKey, JSON.stringify(daten));
            } catch (e) { /* Speicher voll oder gesperrt: nicht kritisch */ }
        }

        // Laedt Devisen und Kryptowerte. Beide Bloecke sind voneinander
        // unabhaengig: faellt einer aus, wird der andere trotzdem angezeigt.
        async function loadMarkets(erzwingen) {
            const abschnitt = document.getElementById('maerkte');
            const koerper = document.getElementById('markets-body');
            const stand = document.getElementById('markets-updated');
            const hinweis = document.getElementById('markets-note');
            if (!abschnitt || !koerper) return;

            abschnitt.hidden = false;
            if (hinweis && !hinweis.textContent) {
                hinweis.textContent = 'Devisen sind Referenzkurse der Europäischen Zentralbank. '
                    + 'Die EZB veröffentlicht sie an Bankarbeitstagen einmal täglich gegen 16:00 Uhr MEZ. '
                    + 'Es handelt sich ausdrücklich nicht um Handelskurse und nicht um Echtzeitdaten. '
                    + 'Kryptowerte stammen von CoinGecko, der Zeitpunkt der letzten Aktualisierung steht am jeweiligen Wert. '
                    + 'Alle Angaben ohne Gewähr und ausdrücklich keine Anlageberatung.';
            }

            const zwischenspeicher = readMarketCache();
            const frisch = zwischenspeicher && (Date.now() - zwischenspeicher.zeit < MARKETS.maxAgeMs);
            if (zwischenspeicher && frisch && !erzwingen) {
                renderMarkets(zwischenspeicher);
                return;
            }
            if (zwischenspeicher) renderMarkets(zwischenspeicher);   // Alte Werte sofort zeigen
            if (stand) stand.textContent = 'wird geladen …';

            const [fx, krypto] = await Promise.all([
                fetchJsonWithTimeout(MARKETS.fxUrl, MARKETS.timeoutMs).catch(e => ({ _fehler: String(e.message || e) })),
                fetchJsonWithTimeout(MARKETS.cryptoUrl, MARKETS.timeoutMs).catch(e => ({ _fehler: String(e.message || e) }))
            ]);

            const daten = { zeit: Date.now(), fx: fx, krypto: krypto };
            // Nur speichern, wenn wenigstens ein Block echte Daten enthaelt.
            if ((fx && fx.rates) || (krypto && krypto.bitcoin)) writeMarketCache(daten);
            renderMarkets(daten);
        }

        function renderMarkets(daten) {
            // esc ist in diesem Projekt bewusst KEINE globale Funktion, sondern
            // wird pro Funktion aus SecurityUtils geholt. Ohne diese Zeile bricht
            // das Rendern mit "esc is not defined" ab.
            const esc = SecurityUtils.escapeHtml;
            const koerper = document.getElementById('markets-body');
            const stand = document.getElementById('markets-updated');
            if (!koerper) return;

            let html = '';

            // ---------------- Devisen ----------------
            if (daten.fx && daten.fx.rates) {
                const kurse = daten.fx.rates;
                const schluessel = Object.keys(kurse).sort();   // alphabetisch, stabil
                html += '<p class="markets-group-title">Devisen, Basis 1 Euro '
                     + `(EZB-Referenzkurs vom ${esc(formatEcbDate(daten.fx.date))})</p>`;
                html += '<ul class="markets-grid">';
                schluessel.forEach(code => {
                    const name = MARKETS.waehrungen[code] || code;
                    html += `<li class="market-item">
                        <span class="market-label">${esc(code)} · ${esc(name)}</span>
                        <span class="market-value">${esc(formatNumber(kurse[code], 4))}</span>
                        <span class="market-sub">1 EUR = ${esc(formatNumber(kurse[code], 4))} ${esc(code)}</span>
                    </li>`;
                });
                html += '</ul>';
            } else {
                html += '<p class="markets-group-title">Devisen</p>'
                     + '<p class="markets-error">Die Referenzkurse der Europäischen Zentralbank konnten gerade nicht '
                     + 'abgerufen werden. Es werden bewusst keine Ersatzwerte angezeigt.</p>';
            }

            // ---------------- Kryptowerte ----------------
            const k = daten.krypto;
            if (k && (k.bitcoin || k.ethereum)) {
                html += '<p class="markets-group-title">Kryptowerte in Euro</p>';
                html += '<ul class="markets-grid">';
                [['bitcoin', 'Bitcoin', 'BTC'], ['ethereum', 'Ethereum', 'ETH']].forEach(([id, name, kuerzel]) => {
                    const e = k[id];
                    if (!e || typeof e.eur !== 'number') return;
                    const aktualisiert = e.last_updated_at
                        ? new Date(e.last_updated_at * 1000).toLocaleTimeString('de-DE',
                            { hour: '2-digit', minute: '2-digit' }) + ' Uhr'
                        : '';
                    html += `<li class="market-item">
                        <span class="market-label">${esc(kuerzel)} · ${esc(name)}</span>
                        <span class="market-value">${esc(formatNumber(e.eur, 2))} €</span>
                        <span class="market-sub">${buildChange(e.eur_24h_change)}
                            ${aktualisiert ? ' · Stand ' + esc(aktualisiert) : ''}</span>
                    </li>`;
                });
                html += '</ul>';
            } else {
                html += '<p class="markets-group-title">Kryptowerte</p>'
                     + '<p class="markets-error">Die Kryptokurse konnten gerade nicht abgerufen werden. '
                     + 'Es werden bewusst keine Ersatzwerte angezeigt.</p>';
            }

            html += '<p class="markets-source">Quellen: Devisen über '
                 + '<a href="https://www.frankfurter.app/" rel="nofollow noopener" target="_blank">frankfurter.app</a> '
                 + 'auf Grundlage der Referenzkurse der Europäischen Zentralbank; Kryptowerte über '
                 + '<a href="https://www.coingecko.com/" rel="nofollow noopener" target="_blank">CoinGecko</a>. '
                 + 'Beide Dienste sind kostenlos und ohne Registrierung nutzbar.</p>';

            koerper.innerHTML = html;
            if (stand && daten.zeit) {
                stand.textContent = 'zuletzt abgerufen ' + new Date(daten.zeit)
                    .toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
            }
        }

        // Wandelt das ISO-Datum der EZB-Kurse in deutsche Schreibweise.
        function formatEcbDate(iso) {
            if (!iso) return 'unbekanntem Datum';
            const d = new Date(iso + 'T00:00:00');
            if (isNaN(d.getTime())) return iso;
            return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
        }

        // Sortiert eine Nachrichtenliste nach dem gewählten Modus
        function sortNews(list, mode) {
            const sorted = list.slice();
            switch (mode) {
                case 'oldest':
                    sorted.sort((a, b) => a.timestamp - b.timestamp);
                    break;
                case 'title':
                    sorted.sort((a, b) => a.title.localeCompare(b.title, 'de'));
                    break;
                case 'source':
                    sorted.sort((a, b) => a.category.localeCompare(b.category, 'de')
                        || b.timestamp - a.timestamp);
                    break;
                case 'region':
                    // Erst nach fester Regionsreihenfolge, dann neueste zuerst.
                    sorted.sort((a, b) => {
                        const ord = CONFIG.regions || [];
                        const ra = ord.indexOf(a.region || 'Deutschland');
                        const rb = ord.indexOf(b.region || 'Deutschland');
                        return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb)
                            || b.timestamp - a.timestamp;
                    });
                    break;
                case 'newest':
                default:
                    sorted.sort((a, b) => b.timestamp - a.timestamp);
                    break;
            }
            return sorted;
        }

        // ---------------------------------------------------------------------
        //  ZEITRAUM-FILTER
        //  Liefert den frühesten noch zulässigen Zeitstempel. Rein rechnerisch
        //  aus dem Veröffentlichungsdatum der Meldung – keine Schätzung.
        //  "Heute" meint bewusst den KALENDERTAG ab 00:00 Uhr Ortszeit, nicht
        //  die letzten 24 Stunden (dafür gibt es einen eigenen Eintrag).
        // ---------------------------------------------------------------------
        function getTimeRangeCutoff(range) {
            const now = Date.now();
            switch (range) {
                case '1h':   return now - 60 * 60 * 1000;
                case '6h':   return now - 6 * 60 * 60 * 1000;
                case '24h':  return now - 24 * 60 * 60 * 1000;
                case '7d':   return now - 7 * 24 * 60 * 60 * 1000;
                case 'today': {
                    const d = new Date();
                    return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
                }
                default: return null;   // 'all' -> keine Einschränkung
            }
        }

        const TIME_RANGE_LABELS = {
            all: 'Ganzer Zeitraum', '1h': 'Letzte Stunde', '6h': 'Letzte 6 Stunden',
            today: 'Heute', '24h': 'Letzte 24 Stunden', '7d': 'Letzte 7 Tage'
        };

        // Wird vom Zeitraum-Auswahlfeld aufgerufen.
        function applyTimeRange(range) {
            appState.timeRange = range;
            appState.renderLimit = PAGE_SIZE;   // wieder oben beginnen
            try { localStorage.setItem('news_range', range); } catch (e) {}
            filterData();
            const label = TIME_RANGE_LABELS[range] || 'Ganzer Zeitraum';
            showToast('Zeitraum: ' + label, 'info');
            announce('Zeitraum eingegrenzt auf ' + label);
        }

        // Gespeicherten Zeitraum beim Start wiederherstellen.
        function applySavedTimeRange() {
            try {
                const saved = localStorage.getItem('news_range');
                if (saved && TIME_RANGE_LABELS[saved]) {
                    appState.timeRange = saved;
                    const sel = document.getElementById('rangeSelect');
                    if (sel) sel.value = saved;
                }
            } catch (e) {}
        }

        // Wird vom Sortier-Dropdown aufgerufen
        function applySort(mode) {
            appState.sortMode = mode;
            appState.renderLimit = PAGE_SIZE; // Bei neuer Sortierung wieder oben beginnen
            filterData();
        }

        function updateTicker() {
            const ticker = document.getElementById('news-ticker');
            const wrap = document.querySelector('.ticker-wrap');
            if (!ticker || appState.allNews.length === 0) return;

            // Von der Quelle gekennzeichnete, noch aktuelle Eilmeldungen zuerst.
            // Sie laufen genauso ab wie die Abzeichen (getHighlightState).
            const breaking = appState.allNews.filter(n => getHighlightState(n).breaking);

            if (breaking.length > 0) {
                // Laufband deutlich als Eilmeldungs-Band kennzeichnen
                if (wrap) wrap.classList.add('has-breaking');
                const line = breaking.slice(0, 3).map(n => `+++ EILMELDUNG: ${n.title} `).join('');
                ticker.innerText = line + '+++';
                announce(breaking.length + ' Eilmeldung(en) im Laufband');
            } else {
                if (wrap) wrap.classList.remove('has-breaking');
                // Sonst wie bisher die fünf neuesten Schlagzeilen
                const headlines = appState.allNews.slice(0, 5).map(n => `+++ ${n.title} `).join('');
                ticker.innerText = headlines + " +++ Nachrichten Kaipfstr +++";
            }
        }

        // --- 5. MODAL LOGIC ---
        let lastFocusedElement = null;
        window.openModal = function(id) {
            const item = appState.allNews.find(n => n.id === id);
            if(!item) return;

            markRead(id); // Vorschau geöffnet -> als gelesen markieren
            const esc = SecurityUtils.escapeHtml;
            const modalBody = document.getElementById('modal-body');
            const dateStr = new Date(item.timestamp).toLocaleString('de-DE');
            const readTime = item.readingTime || estimateReadingTime(item.description);
            lastFocusedElement = document.activeElement;

            // BUGFIX (Anti-XSS): category/title/dateStr werden escaped. fullContent
            // ist bereits über SecurityUtils.sanitizeHtml bereinigt (Whitelist).
            const imageBlock = item.image
                ? `<img src="${esc(item.image)}" alt="${esc(item.title)}" loading="lazy"
                        style="width:100%;border-radius:8px;margin-bottom:1rem;"
                        onerror="this.style.display='none'">`
                : '';

            modalBody.innerHTML = `
                <div style="border-bottom:1px solid #333; padding-bottom:1rem; margin-bottom:1rem;">
                    <span class="source-badge ${esc(item.badgeClass)}">${esc(item.category)}</span>
                    <span style="color:#888; margin-left:10px;">${esc(dateStr)}</span>
                    <span style="color:#888; margin-left:10px;">⏱ ca. ${readTime} Min.</span>
                    <h2 id="modal-title" style="margin-top:10px; font-size:1.5rem; color:var(--text-primary);">${esc(item.title)}</h2>
                </div>
                ${imageBlock}
                <div style="line-height:1.8; color:#ddd; font-size:1rem;">
                    ${item.fullContent} </div>
                <div style="margin-top:2rem; display:flex; gap:12px; justify-content:center; flex-wrap:wrap;">
                    <a href="${esc(item.link)}" target="_blank" rel="nofollow noopener noreferrer" class="filter-btn active" style="text-decoration:none; display:inline-block;">Originalartikel lesen ↗</a>
                    <button type="button" class="filter-btn" onclick="shareArticle('${esc(item.id)}')">Teilen ⤴</button>
                    <button type="button" class="filter-btn" onclick="toggleFavorite('${esc(item.id)}')">${isFavorite(item.id) ? '★ Gemerkt' : '☆ Merken'}</button>
                </div>
            `;

            const modal = document.getElementById('article-modal');
            modal.classList.add('open');
            modal.setAttribute('aria-hidden', 'false');
            document.body.style.overflow = 'hidden'; // Stop background scrolling

            // Fokus in den Dialog setzen (Barrierefreiheit)
            const closeBtn = modal.querySelector('.close-modal');
            if (closeBtn) closeBtn.focus();
        };

        window.closeModal = function() {
            const modal = document.getElementById('article-modal');
            modal.classList.remove('open');
            modal.setAttribute('aria-hidden', 'true');
            document.body.style.overflow = 'auto';
            // Fokus zurück auf das auslösende Element (A11y)
            if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') {
                lastFocusedElement.focus();
            }
        };

        // Close on outside click
        document.getElementById('article-modal').addEventListener('click', function(e) {
            if (e.target === this) closeModal();
        });

        // --- 6. SEO INJECTION (JSON-LD) ---
        // BUGFIX: generateSEO hängte bei jedem Refresh zwei neue <script>-Blöcke,
        // einen <link rel="next"> und einen weiteren 'load'-Listener an. Über die
        // Zeit (Auto-Refresh) wuchs der <head> unbegrenzt. Jetzt: keyed Injection
        // (ersetzt statt anhängen) und einmalige Listener/Links.
        let seoLoadListenerAdded = false;
        function generateSEO() {
            const newsItems = appState.allNews.slice(0, 20).map(item => ({
                "@type": "NewsArticle",
                "headline": item.title,
                "datePublished": new Date(item.timestamp).toISOString(),
                "dateModified": new Date(item.timestamp).toISOString(),
                "description": item.description,
                "inLanguage": "de-DE", "isAccessibleForFree": true, "articleSection": item.category,
                "url": item.link,
                "publisher": {
                    "@type": "Organization",
                    "name": "Nachrichten Kaipfstr",
                    "logo": {
                        "@type": "ImageObject",
                        "url": "https://nachrichten.kaipfstr.de/assets/logo.png"
                    }
                },
                "author": {
                    "@type": "Organization",
                    "name": item.category
                },
                "mainEntityOfPage": {
                    "@type": "WebPage",
                    "@id": item.link
                }
            }));

            const mainSchema = {
                "@context": "https://schema.org",
                "@type": "CollectionPage",
                "name": "Nachrichten Kaipfstr | Live News Feed",
                "description": "Nachrichten-Aggregator, der aktuelle RSS-Feeds etablierter deutscher Quellen bündelt und live darstellt.",
                "url": "https://nachrichten.kaipfstr.de",
                "publisher": {
                    "@type": "Organization",
                    "name": "Nachrichten Kaipfstr",
                    "url": "https://nachrichten.kaipfstr.de",
                    "logo": {
                        "@type": "ImageObject",
                        "url": "https://nachrichten.kaipfstr.de/assets/logo.png"
                    },
                    "sameAs": [
                        "https://twitter.com/kaipfstr_news"
                    ]
                },
                "breadcrumb": {
                    "@type": "BreadcrumbList",
                    "itemListElement": [
                        {
                            "@type": "ListItem",
                            "position": 1,
                            "name": "Startseite",
                            "item": "https://nachrichten.kaipfstr.de"
                        }
                    ]
                },
                "hasPart": newsItems,
                "potentialAction": {
                    "@type": "SearchAction",
                    "target": "https://nachrichten.kaipfstr.de/search?q={search_term_string}",
                    "query-input": "required name=search_term_string"
                }
            };

            addJsonLd(mainSchema, 'seo-collection');

            // Zusätzliches Schema für Website (keyed -> ersetzt statt anhängen)
            const websiteSchema = {
                "@context": "https://schema.org",
                "@type": "WebSite",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "description": "Live Nachrichten aus Politik und Gesellschaft.",
                "inLanguage": "de-DE",
                "publisher": {
                    "@type": "Organization",
                    "name": "Kaipfstr Redaktion"
                },
                "potentialAction": {
                    "@type": "SearchAction",
                    "target": "https://nachrichten.kaipfstr.de/search?q={search_term_string}",
                    "query-input": "required name=search_term_string"
                }
            };
            addJsonLd(websiteSchema, 'seo-website-2');

            // Performance-Monitor für schnelles Laden (Listener nur EINMAL binden)
            if (seoLoadListenerAdded) return;
            seoLoadListenerAdded = true;
            window.addEventListener('load', function() {
                const loadTime = performance.now();
                console.log('Seite geladen in ' + loadTime + ' ms');
                // Simuliere Lazy Loading für Karten
                const cards = document.querySelectorAll('.card');
                const observer = new IntersectionObserver((entries) => {
                    entries.forEach(entry => {
                        if (entry.isIntersecting) {
                            entry.target.style.opacity = '1';
                            observer.unobserve(entry.target);
                        }
                    });
                });
                cards.forEach(card => {
                    card.style.opacity = '0';
                    card.style.transition = 'opacity 0.5s';
                    observer.observe(card);
                });
            });
        }

        // --- 7. UTILS ---
        function showError(msg) {
            const el = document.getElementById('error-display');
            if (!el) return;
            el.textContent = msg;
            el.className = 'error-toast'; // Basis-Stil (rot) wiederherstellen
            el.style.display = 'block';
            clearTimeout(el._timer);
            el._timer = setTimeout(() => { el.style.display = 'none'; }, 4000);
        }

        // Start Everything
        document.addEventListener("DOMContentLoaded", function() {
            initApp();
            setupRouting();
            handleRoute();
            initHerkunftBox();
            // MAERKTE: bewusst NACH dem Start der Nachrichten und leicht verzoegert.
            // Die Kurse sind Beiwerk und duerfen die Bandbreite beim ersten
            // Seitenaufbau nicht mit den Nachrichten-Feeds streitig machen.
            setTimeout(function() {
                try { loadMarkets(false); } catch (e) { console.warn('Maerkte:', e); }
            }, 1200);
        });

        // NEUE ERGÄNZUNGEN: MEHR FEATURES
        function exportNews() {
            const dataStr = JSON.stringify(appState.filteredNews, null, 2);
            const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
            const exportFileDefaultName = 'nachrichten_export.json';
            const linkElement = document.createElement('a');
            linkElement.setAttribute('href', dataUri);
            linkElement.setAttribute('download', exportFileDefaultName);
            linkElement.click();
        }

        function toggleAutoRefresh() {
            appState.autoRefresh = !appState.autoRefresh;
            const indicator = document.getElementById('auto-refresh-indicator');
            if (appState.autoRefresh) {
                indicator.textContent = 'Auto-Refresh: An (5min)';
                appState.refreshInterval = setInterval(() => {
                    loadLiveFeeds();
                }, 5 * 60 * 1000); // 5 Minuten
            } else {
                indicator.textContent = 'Auto-Refresh: Aus';
                clearInterval(appState.refreshInterval);
            }
        }

        // Erweitere loadLiveFeeds mit Progress-Bar
        // BUGFIX: Diese (gewinnende) Version hatte weder Null-Guard für die
        // Progress-Bar, noch einen Mock-Fallback, noch per-Feed-Fehlerfang, und
        // schrieb den Cache als reines JSON (inkonsistent zu decryptCache).
        // Alles konsolidiert & robust gemacht.
        let isLoadingFeeds = false;
        async function loadLiveFeeds(erzwingen) {
            // Reentrancy-Schutz: verhindert doppelte parallele Ladevorgänge
            if (isLoadingFeeds) {
                console.log('Ladevorgang läuft bereits – übersprungen.');
                return;
            }
            // OFFLINE-GUARD (Akku & Datenvolumen schonen): Wenn das Gerät offline ist
            // und bereits Meldungen vorliegen, gar nicht erst 14 Feeds × mehrere Proxys
            // ins Leere anfragen. Sobald die Verbindung zurückkehrt, lädt der
            // 'online'-Listener automatisch nach.
            if (typeof navigator !== 'undefined' && navigator.onLine === false
                && appState.allNews && appState.allNews.length > 0) {
                console.log('Offline – Ladevorgang übersprungen, gespeicherte Meldungen bleiben sichtbar.');
                showToast('Offline – gespeicherte Meldungen werden angezeigt', 'info');
                return;
            }

            isLoadingFeeds = true;

            // Vorherige Links merken (permanent & stabil), um nach dem Laden echte
            // NEUE Meldungen zu erkennen. IDs eignen sich nicht (werden pro Abruf neu
            // erzeugt) – der Quell-Link ist der zuverlässige Schlüssel.
            const prevLinks = new Set(appState.allNews.map(n => n.link).filter(l => l && l !== '#'));

            const progressBar = document.getElementById('progress-bar');
            const progressFill = document.getElementById('progress-fill');
            if (progressBar) progressBar.style.opacity = '1';
            if (progressFill) progressFill.style.width = '0%';

            // Beim Kaltstart (noch keine Daten) sofort Skeletons zeigen statt leerer Fläche.
            if (!appState.allNews || appState.allNews.length === 0) {
                const loadingEl = document.getElementById('loading-indicator');
                if (loadingEl) loadingEl.style.display = 'none';
                showSkeletons(6);
            }

            // NUR AKTIVE QUELLEN laden. Abgewählte Feeds werden gar nicht erst
            // abgerufen -> weniger Wartezeit und weniger mobiles Datenvolumen.
            // SCHONFRIST: kuerzlich erfolgreich geladene Quellen ueberspringen.
            // Ihre Meldungen liegen im Archiv und bleiben sichtbar. Ein manueller
            // Klick auf "Jetzt laden" setzt sich darueber hinweg.
            const alleAktiven = activeFeeds();
            const auswahl = feedsZumLaden(alleAktiven, erzwingen === true);
            const feedsToLoad = auswahl.laden;
            if (auswahl.uebersprungen.length) {
                console.info(`Schonfrist: ${auswahl.uebersprungen.length} von `
                    + `${alleAktiven.length} Quellen wurden vor weniger als `
                    + `${Math.round(SCHONFRIST_MS / 60000)} Minuten geladen und `
                    + `diesmal uebersprungen.`);
            }
            if (feedsToLoad.length === 0) {
                isLoadingFeeds = false;
                if (progressBar) progressBar.style.opacity = '0';
                // Zwei voellig verschiedene Faelle, die frueher dieselbe, dann
                // irrefuehrende Meldung erzeugt haetten:
                //   a) der Nutzer hat wirklich jede Quelle abgeschaltet
                //   b) alle Quellen wurden gerade erst geladen (Schonfrist)
                if (alleAktiven.length === 0) {
                    showToast('Alle Quellen sind abgeschaltet, bitte mindestens eine aktivieren', 'info');
                } else {
                    showToast('Alle Quellen wurden gerade erst geladen. '
                        + 'Über „Jetzt laden“ lässt sich das Aktualisieren erzwingen.', 'info');
                }
                finalizeLoading();
                return;
            }

            let completed = 0;
            const total = feedsToLoad.length;
            const collected = []; // gesammelte Items über alle Feeds hinweg

            // PROGRESSIVES LADEN: Jeder Feed wird als eigene Aufgabe ausgeführt.
            // Sobald einer fertig ist, werden seine Meldungen SOFORT eingemischt und
            // gerendert – der Nutzer sieht die ersten Nachrichten, ohne auf den
            // langsamsten Feed zu warten. Parallelität ist begrenzt (Mobil/Netz).
            // Quellen-Status zurücksetzen und je Feed faktisch erfassen,
            // ob und wie viele Meldungen geladen wurden (für Transparenz-Anzeige).
            appState.feedStatus = feedsToLoad.map(f => ({ cat: f.cat, count: 0, ok: false }));

            const tasks = feedsToLoad.map((feed, feedIndex) => {
                return async () => {
                    const started = Date.now();
                    const result = await fetchWithFallback(feed);
                    const arr = Array.isArray(result) ? result : [];
                    if (appState.feedStatus[feedIndex]) {
                        appState.feedStatus[feedIndex].count = arr.length;
                        appState.feedStatus[feedIndex].ok = arr.length > 0;
                        appState.feedStatus[feedIndex].ms = Date.now() - started;
                    }
                    // Nur ERFOLGREICHE Abrufe merken. Ein Fehlschlag darf keine
                    // Schonfrist ausloesen, sonst bliebe eine kaputte Quelle
                    // minutenlang unversucht.
                    if (arr.length) merkeLaufzeit(feed.url, Date.now());
                    return arr;
                };
            });

            // Throttle für die progressive Darstellung: Bei aktuell 40 Feeds würde ein
            // Voll-Neuaufbau pro Feed (bis zu 14×) ruckeln. Wir rendern daher
            // höchstens alle ~350 ms zwischen – der finale Render erfolgt ohnehin
            // in finalizeLoading(). Das hält die Oberfläche flüssig (auch mobil).
            let lastProgressiveRender = 0;
            await runWithConcurrency(tasks, CONFIG.maxConcurrentFeeds || 4, (result, index) => {
                completed++;
                if (progressFill) {
                    progressFill.style.width = `${(completed / total) * 100}%`;
                }
                if (result && result.length) {
                    // Pro Feed auf maxItemsPerFeed begrenzen (DOM schlank halten)
                    const limited = result.slice(0, CONFIG.maxItemsPerFeed || 40);
                    collected.push.apply(collected, limited);
                    // Zwischenstand einmischen (sortiert & dedupliziert)
                    appState.allNews = dedupeNews(collected.slice().sort((a, b) => b.timestamp - a.timestamp));

                    const now = Date.now();
                    const isLast = (completed === total);
                    if (appState.allNews.length && (isLast || now - lastProgressiveRender > 350)) {
                        lastProgressiveRender = now;
                        const loadingEl = document.getElementById('loading-indicator');
                        if (loadingEl) loadingEl.style.display = 'none';
                        // Liest der Nutzer gerade? Dann nur Daten aktualisieren, nicht rendern.
                        filterData(isReadingArticle());
                        updateTicker();
                    }
                }
            });

            // Endgültige, sortierte & deduplizierte Gesamtliste
            const merged = collected.slice().sort((a, b) => b.timestamp - a.timestamp);
            const fresh = dedupeNews(merged);

            // ================= SCHWERWIEGENDER BUGFIX (Datenverlust) =============
            // FRÜHER: `appState.allNews = fresh.length ? mergeIntoArchive(fresh) : fresh;`
            // Wenn ALLE Feeds fehlschlugen (z. B. offline, Funkloch im Zug, Proxy-
            // Ausfall), war `fresh` leer -> appState.allNews wurde auf [] gesetzt.
            // Damit wurden die bereits aus dem ARCHIV geladenen Meldungen GELÖSCHT,
            // die Platzhalter-Meldung ersetzte den gesamten Bestand UND wurde
            // anschließend sogar in den Cache geschrieben. Ergebnis: Nutzer verlor
            // bei jedem Verbindungsabbruch seine gesamte lesbare Nachrichtenliste.
            //
            // JETZT: Bei leerem Ergebnis bleibt der vorhandene Bestand erhalten
            // (Archiv/Cache). Nichts wird überschrieben, nichts geht verloren.
            // =====================================================================
            let loadFailedCompletely = false;
            if (fresh.length) {
                appState.allNews = mergeIntoArchive(fresh);
            } else {
                loadFailedCompletely = true;
                // Bestehende Meldungen behalten; notfalls aus dem Archiv nachladen.
                if (!appState.allNews || appState.allNews.length === 0) {
                    const archived = loadArchive();
                    if (archived.length) appState.allNews = archived;
                }
                console.warn('Keine frischen Meldungen – vorhandener Bestand bleibt erhalten.');
            }
            console.log(`Insgesamt (inkl. Archiv): ${appState.allNews.length} Meldungen`);

            // Fallback-Hinweis NUR, wenn wirklich gar nichts vorhanden ist
            // (weder frische Daten noch Archiv) – z. B. beim allerersten Aufruf offline.
            if (appState.allNews.length === 0) {
                console.warn('Keine Nachrichten verfügbar – zeige Hinweis-Meldung.');
                const offline = (typeof navigator !== 'undefined' && navigator.onLine === false);
                appState.allNews = [{
                    id: 'mock-info-1',
                    title: offline
                        ? 'Keine Internetverbindung'
                        : 'Aktuell konnten keine Live-Feeds geladen werden',
                    link: '#',
                    date: new Date(),
                    description: offline
                        ? 'Ihr Gerät ist derzeit offline. Sobald wieder eine Verbindung besteht, werden die aktuellen Nachrichten automatisch geladen.'
                        : 'Die externen RSS-Quellen sind derzeit nicht erreichbar (z. B. wegen CORS-Proxy-Ausfall). Bitte versuchen Sie es später erneut oder klicken Sie auf „Manuell Laden“.',
                    fullContent: offline
                        ? '<p>Ihr Gerät ist derzeit offline. Bereits geladene Nachrichten stehen weiterhin zur Verfügung, sobald sie einmal abgerufen wurden.</p>'
                        : '<p>Die externen RSS-Quellen sind derzeit nicht erreichbar. Dies liegt üblicherweise an einer temporären Nichtverfügbarkeit der kostenlosen CORS-Proxy-Dienste.</p>',
                    image: '',
                    category: 'Hinweis',
                    badgeClass: 'badge-tagesschau',
                    timestamp: Date.now(),
                    readingTime: 1
                }];
            } else if (loadFailedCompletely) {
                // Faktenbasierte Rückmeldung: Bestand ist alt, aber nicht verloren.
                showToast('Keine Verbindung zu den Quellen – zeige gespeicherte Meldungen', 'info');
            }

            // Konsistente, verschlüsselte (Base64) Cache-Speicherung – passend zu initApp.
            // BUGFIX: Bei einem komplett fehlgeschlagenen Ladevorgang wird der Cache
            // NICHT überschrieben (sonst würde die Platzhalter-Meldung den guten
            // Cache-Stand dauerhaft ersetzen).
            if (!loadFailedCompletely) {
                try {
                    localStorage.setItem('news_cache', encryptCache(appState.allNews));
                    localStorage.setItem('news_cache_time', new Date().getTime());
                } catch (e) {
                    console.warn('Cache konnte nicht gespeichert werden (evtl. Speicher voll):', e);
                }
            }

            if (progressFill) progressFill.style.width = '100%';
            setTimeout(() => { if (progressBar) progressBar.style.opacity = '0'; }, 600);

            isLoadingFeeds = false;
            appState.lastUpdated = new Date();

            // Echte neue Meldungen ermitteln (Links, die es vorher nicht gab).
            // Nur wenn es überhaupt einen vorherigen Stand gab (nicht beim Kaltstart).
            if (prevLinks.size > 0) {
                const newItems = appState.allNews.filter(n => n.link && n.link !== '#' && !prevLinks.has(n.link));
                updateNewBanner(newItems.length);
            }

            finalizeLoading();
        }

        // Zeigt/aktualisiert den „X neue Meldungen"-Hinweis. Er erscheint nur, wenn
        // der Nutzer den Anfang NICHT sieht (liest gerade oder ist weiter unten),
        // damit frisch geladene Meldungen nicht unbemerkt bleiben – ohne zu stören.
        function updateNewBanner(count) {
            const banner = document.getElementById('new-items-banner');
            if (!banner) return;
            const countEl = document.getElementById('new-items-count');
            const shouldShow = count > 0 && (isReadingArticle() || (window.scrollY || 0) > 400);
            if (shouldShow) {
                if (countEl) countEl.textContent = count;
                banner.hidden = false;
                banner.classList.add('visible');
                announce(count + ' neue Meldungen verfügbar');
            } else {
                banner.hidden = true;
                banner.classList.remove('visible');
            }
        }

        // Klick auf den Hinweis: zur Übersicht, nach oben, frisch rendern.
        function showNewItems() {
            const banner = document.getElementById('new-items-banner');
            if (banner) { banner.hidden = true; banner.classList.remove('visible'); }
            if (isReadingArticle()) {
                goHome();
            } else {
                renderNews();
                scrollToTop();
            }
        }

        // Entfernt doppelte Meldungen anhand von Link bzw. Titel (Feeds überschneiden sich)
        function dedupeNews(list) {
            const seen = new Set();
            const out = [];
            for (const item of list) {
                const key = (item.link && item.link !== '#')
                    ? item.link
                    : (item.title || '') + '|' + item.category;
                if (!seen.has(key)) {
                    seen.add(key);
                    out.push(item);
                }
            }
            return out;
        }

        // ---------------------------------------------------------------------
        //  PERMANENTE, INDEXIERBARE LINKS
        //  Der dauerhaft verfügbare Link einer Meldung ist ihre Original-Quell-URL
        //  (die bleibt beim Verlag bestehen). Nur wenn ausnahmsweise kein gültiger
        //  Quell-Link vorliegt, wird ein stabiler interner Permalink genutzt.
        // ---------------------------------------------------------------------
        function articleSlug(item) {
            const base = (item && item.title) ? item.title : 'artikel';
            const slug = base.toLowerCase()
                .replace(/[äöü]/g, m => ({ 'ä': 'ae', 'ö': 'oe', 'ü': 'ue' }[m]))
                .replace(/ß/g, 'ss')
                .replace(/[^a-z0-9]+/g, '-')
                .replace(/^-+|-+$/g, '')
                .substring(0, 60);
            const suffix = (item && item.id) ? String(item.id).slice(-6) : 'x';
            return (slug || 'artikel') + '-' + suffix;
        }
        // =====================================================================
        //  INDEXIERBARE QUELLEN-UNTERSEITEN  /quelle/<slug>
        //  ------------------------------------------------------------------
        //  Jede der 40 Quellen bekommt eine eigene, echte Adresse mit eigenem
        //  Titel, eigener Beschreibung, eigener Canonical-URL und eigenem
        //  JSON-LD. Damit kann Google jede Quellenseite getrennt erfassen.
        //  _redirects liefert /quelle/* mit Status 200 aus, der Router setzt
        //  anschliessend den passenden Filter.
        // =====================================================================
        function categorySlug(cat) {
            return String(cat || '')
                .toLowerCase()
                .replace(/[äöü]/g, m => ({ 'ä': 'ae', 'ö': 'oe', 'ü': 'ue' }[m]))
                .replace(/ß/g, 'ss')
                .replace(/[^a-z0-9]+/g, '-')
                .replace(/^-+|-+$/g, '');
        }

        // Liefert alle Kategorien (eindeutig, alphabetisch) – Basis für Links,
        // Sitemap-Pflege und Routing.
        function allCategories() {
            const cats = [];
            CONFIG.feeds.forEach(f => { if (cats.indexOf(f.cat) === -1) cats.push(f.cat); });
            return cats.sort((a, b) => a.localeCompare(b, 'de'));
        }

        function findCategoryBySlug(slug) {
            const s = String(slug || '').toLowerCase();
            return allCategories().find(c => categorySlug(c) === s) || null;
        }

        function categoryUrl(cat) {
            return 'https://nachrichten.kaipfstr.de/quelle/' + categorySlug(cat);
        }

        // Setzt Titel, Beschreibung, Canonical und strukturierte Daten für eine
        // Quellen-Unterseite. Alle Angaben sind faktisch: Anzahl und Zeitraum
        // stammen aus den tatsächlich geladenen Meldungen dieser Quelle.
        function updateMetaForCategory(cat) {
            const items = appState.allNews.filter(n => n.category === cat);
            const count = items.length;
            const newest = count ? new Date(items[0].timestamp) : null;

            const title = `${cat} – aktuelle Meldungen | Nachrichten Kaipfstr`;
            const desc = count
                ? `Aktuell ${count} Meldungen der Quelle ${cat}, chronologisch sortiert`
                  + (newest ? `, neueste vom ${newest.toLocaleDateString('de-DE')}` : '')
                  + '. Jede Meldung verlinkt direkt auf den Originalartikel.'
                : `Meldungen der Quelle ${cat} im Nachrichten-Aggregator Nachrichten Kaipfstr.`;

            document.title = title;
            updateMetaTag('description', desc.substring(0, 300));
            updateMetaTag('og:title', title);
            updateMetaTag('og:description', desc.substring(0, 300));
            updateMetaTag('og:url', categoryUrl(cat));
            updateMetaTag('og:type', 'website');
            setCanonical(categoryUrl(cat));

            // Strukturierte Daten: Sammelseite + Brotkrümel + Artikelliste
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "CollectionPage",
                "name": title,
                "description": desc,
                "url": categoryUrl(cat),
                "inLanguage": "de-DE",
                "isPartOf": {
                    "@type": "WebSite",
                    "name": "Nachrichten Kaipfstr",
                    "url": "https://nachrichten.kaipfstr.de"
                },
                "breadcrumb": {
                    "@type": "BreadcrumbList",
                    "itemListElement": [
                        { "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://nachrichten.kaipfstr.de/" },
                        { "@type": "ListItem", "position": 2, "name": "Quellen", "item": "https://nachrichten.kaipfstr.de/quellen" },
                        { "@type": "ListItem", "position": 3, "name": cat, "item": categoryUrl(cat) }
                    ]
                },
                "mainEntity": {
                    "@type": "ItemList",
                    "numberOfItems": Math.min(count, 40),
                    "itemListElement": items.slice(0, 40).map((it, i) => ({
                        "@type": "ListItem",
                        "position": i + 1,
                        "url": articlePermalink(it),
                        "name": it.title
                    }))
                }
            }, 'route-jsonld');
        }

        // ---------------------------------------------------------------------
        //  ÜBERSICHTSSEITE ALLER QUELLEN  /quellen
        //  Eine echte, crawlbare Verteilerseite: von hier aus erreicht ein
        //  Suchmaschinen-Crawler jede einzelne Quellen-Unterseite über einen
        //  gewöhnlichen <a>-Link. Zahlen stammen aus den geladenen Meldungen.
        // ---------------------------------------------------------------------
        // Baut die crawlbaren Quellen-Links im Footer (echte <a>-Elemente!).
        function renderCategoryLinks() {
            const nav = document.getElementById('category-links');
            if (!nav) return;
            const esc = SecurityUtils.escapeHtml;
            const cats = allCategories();
            const links = cats.map(cat => {
                const n = appState.allNews.filter(x => x.category === cat).length;
                return `<a href="/quelle/${esc(categorySlug(cat))}"
                    onclick="openCategoryPage('${esc(cat)}');return false;"
                    title="Alle Meldungen von ${esc(cat)}">${esc(cat)}${n ? ` <span class="cl-n">${n}</span>` : ''}</a>`;
            }).join('');
            nav.innerHTML = `<a href="/quellen" class="cat-link-all"
                onclick="renderSourceIndexPage();history.pushState({},'','/quellen');return false;">Alle Quellen im Überblick</a>`
                + `<a class="cat-link" href="/regionen"
                onclick="renderRegionIndexPage();history.pushState({},'','/regionen');return false;">Nachrichten nach Herkunft</a>`
                + (CONFIG.regions || []).map(r =>
                    `<a class="cat-link" href="/region/${categorySlug(r)}"
                        onclick="openRegionPage('${r}');return false;">${r}</a>`).join('')
                + links;
        }

        function renderSourceIndexPage() {
            const container = document.getElementById('news-container');
            if (!container) return;
            const esc = SecurityUtils.escapeHtml;
            const intro = document.getElementById('intro');
            if (intro) intro.style.display = 'none';
            const staleMore = document.getElementById('load-more-wrap');
            if (staleMore) staleMore.remove();
            const noRes = document.getElementById('no-results');
            if (noRes) noRes.style.display = 'none';

            const cats = allCategories();
            const rows = cats.map(cat => {
                const items = appState.allNews.filter(n => n.category === cat);
                const newest = items.length ? timeAgo(items[0].date) : 'noch nicht geladen';
                return `<li>
                    <a class="src-index-item" href="/quelle/${esc(categorySlug(cat))}"
                       onclick="openCategoryPage('${esc(cat)}');return false;">
                        <span class="si-name">${esc(cat)}</span>
                        <span class="si-count">${items.length} Meldungen</span>
                        <span class="si-time">${esc(newest)}</span>
                    </a>
                </li>`;
            }).join('');

            container.innerHTML = `
                <section class="source-index">
                    <button class="reader-back" onclick="goHome()">← Zur Übersicht</button>
                    <h1 class="intro-h1">Alle Quellen im Überblick</h1>
                    <p class="settings-hint">Diese Seite listet alle ${cats.length} Quellen auf, die
                       Nachrichten Kaipfstr auswertet. Jede Quelle hat eine eigene Unterseite mit
                       ihren aktuellen Meldungen. Die Zahlen zeigen den derzeit geladenen Bestand.</p>
                    <ul class="source-index-list">${rows}</ul>
                </section>`;

            document.title = 'Alle Quellen (' + cats.length + ') | Nachrichten Kaipfstr';
            updateMetaTag('description',
                'Übersicht aller ' + cats.length + ' Nachrichtenquellen, die Nachrichten Kaipfstr auswertet – '
                + 'jede mit eigener Unterseite: ' + cats.slice(0, 12).join(', ') + ' und weitere.');
            updateMetaTag('og:url', 'https://nachrichten.kaipfstr.de/quellen');
            setCanonical('https://nachrichten.kaipfstr.de/quellen');

            addJsonLd({
                "@context": "https://schema.org",
                "@type": "CollectionPage",
                "name": "Alle Quellen – Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de/quellen",
                "inLanguage": "de-DE",
                "mainEntity": {
                    "@type": "ItemList",
                    "numberOfItems": cats.length,
                    "itemListElement": cats.map((c, i) => ({
                        "@type": "ListItem", "position": i + 1,
                        "url": categoryUrl(c), "name": c
                    }))
                }
            }, 'route-jsonld');
            scrollToTop();
        }

        // Öffnet eine Quellen-Unterseite (Filter + Adresse + Metadaten).
        function openCategoryPage(cat, pushUrl) {
            appState.currentFilter = cat;
            appState.renderLimit = PAGE_SIZE;
            document.querySelectorAll('.filter-btn').forEach(b => {
                b.classList.toggle('active', b.getAttribute('data-cat') === cat);
            });
            if (pushUrl !== false && location.protocol.startsWith('http')) {
                history.pushState({ category: cat }, '', '/quelle/' + categorySlug(cat));
            }
            filterData();
            updateMetaForCategory(cat);
            scrollToTop();
        }

        function articlePermalink(item) {
            if (item && item.link && /^https?:\/\//i.test(item.link)) return item.link;
            return 'https://nachrichten.kaipfstr.de/#/artikel/' + articleSlug(item);
        }

        // ---------------------------------------------------------------------
        //  ARTIKEL-ARCHIV (localStorage) – damit einmal gesehene Nachrichten
        //  dauerhaft verfügbar & indexierbar bleiben, auch wenn sie den Live-Feed
        //  längst verlassen haben. Begrenzt auf ARCHIVE_MAX Einträge.
        // ---------------------------------------------------------------------
        const ARCHIVE_MAX = 600;
        function loadArchive() {
            try {
                const raw = localStorage.getItem('news_archive');
                const arr = raw ? decryptCache(raw) : null;
                if (!Array.isArray(arr)) return [];
                // BUGFIX: JSON speichert Datumsangaben als String. Ohne Rehydrierung
                // schlug später `item.date.toLocaleDateString()` in renderNews/buildHero
                // mit einer TypeError-Exception fehl, sobald Archiv-Einträge in
                // appState.allNews gelangten. Datum hier zuverlässig wiederherstellen.
                arr.forEach(x => {
                    x.date = new Date(x.timestamp || x.date || Date.now());
                    if (isNaN(x.date.getTime())) x.date = new Date();
                });
                return arr;
            } catch (e) { return []; }
        }
        function saveArchive(list) {
            try {
                // Neueste zuerst, auf ARCHIVE_MAX begrenzen
                const trimmed = list.slice(0, ARCHIVE_MAX);
                localStorage.setItem('news_archive', encryptCache(trimmed));
            } catch (e) { /* Speicher evtl. voll – unkritisch */ }
        }
        // Führt aktuelle Meldungen mit dem Archiv zusammen (dedupliziert, sortiert).
        function mergeIntoArchive(current) {
            const archive = loadArchive();
            const merged = dedupeNews(
                current.concat(archive).sort((a, b) => b.timestamp - a.timestamp)
            );
            saveArchive(merged);
            return merged;
        }

        // Cache-Kodierung (Base64).
        // WICHTIG (faktenbasiert): Das ist KEINE echte Verschlüsselung, sondern nur
        // eine Base64-Kodierung. Der Cache liegt clientseitig und enthält ohnehin nur
        // bereits öffentliche Nachrichten – ein Schutzbedarf besteht nicht.
        //
        // BUGFIX: btoa()/atob() arbeiten nur mit Latin1. Deutsche Umlaute (ä, ö, ü)
        // oder Emojis in Titeln lösten eine "InvalidCharacterError"-Exception aus,
        // wodurch der Cache faktisch NIE gespeichert wurde. Jetzt Unicode-sicher.
        function encryptCache(data) {
            try {
                const json = JSON.stringify(data);
                // Unicode -> UTF-8 -> Base64
                return btoa(unescape(encodeURIComponent(json)));
            } catch (e) {
                console.warn('Cache-Kodierung fehlgeschlagen:', e);
                return '';
            }
        }

        function decryptCache(data) {
            try {
                // Base64 -> UTF-8 -> Unicode
                return JSON.parse(decodeURIComponent(escape(atob(data))));
            } catch (e) {
                return null;
            }
        }

        // Erweitere initApp mit entschlüsseltem Cache
        // BUGFIX: Diese (gewinnende) initApp-Version las den Cache mit decryptCache
        // (Base64), während die frühere loadLiveFeeds ihn als reines JSON schrieb.
        // Ergebnis: Cache-Load schlug fehl -> LEERE Seite bei Wiederbesuch < 15 Min.
        // Jetzt: robustes Laden mit Fallback auf Live-Fetch, wenn Cache leer/defekt.
        async function initApp() {
            loadFavorites();
            loadReadState();
            loadDisabledSources();      // Quellen-Auswahl wiederherstellen
            renderSourceToggles();      // Schalterliste im Panel aufbauen
            applySavedTimeRange();      // gespeicherten Zeitraum wiederherstellen
            // Archiv sofort als Basis laden -> alte Meldungen/Permalinks bleiben
            // verfügbar (auch offline), bis frische Daten eintreffen.
            const archived = loadArchive();
            if (archived.length) {
                archived.forEach(x => x.date = new Date(x.timestamp));
                appState.allNews = archived;
            }
            const cached = localStorage.getItem('news_cache');
            const cacheTime = localStorage.getItem('news_cache_time');
            const now = new Date().getTime();

            if (cached && cacheTime && (now - cacheTime < 15 * 60 * 1000)) {
                console.log("Lade aus Cache …");
                // Versuche zuerst Base64-Entschlüsselung, dann reines JSON als Fallback.
                let parsed = decryptCache(cached);
                if (!parsed) {
                    try { parsed = JSON.parse(cached); } catch (e) { parsed = null; }
                }
                appState.allNews = Array.isArray(parsed) ? parsed : [];
                appState.allNews.forEach(x => x.date = new Date(x.timestamp));

                if (appState.allNews.length > 0) {
                    finalizeLoading();
                    // Im Hintergrund still aktualisieren, wenn Cache älter als 5 Min.
                    if (now - cacheTime > 5 * 60 * 1000) {
                        setTimeout(() => loadLiveFeeds(), 1200);
                    }
                } else {
                    console.warn("Cache leer/defekt -> lade Live-Daten.");
                    loadLiveFeeds();
                }
            } else {
                console.log("Lade Live-Daten …");
                loadLiveFeeds();
            }
        }

        // Erweitere finalizeLoading mit Cache-Verschlüsselung
        // Erkennt, ob der Nutzer gerade einen Einzelartikel liest (Lesemodus).
        // Wird genutzt, um Hintergrund-Aktualisierungen NICHT den Artikel wegrendern
        // zu lassen (BUGFIX: sonst wurde man beim Lesen zur Liste zurückgeworfen).
        function isReadingArticle() {
            if (document.querySelector('.reader')) return true;
            const h = window.location.hash || '';
            return /^#\/(artikel|news)\//.test(h);
        }

        function finalizeLoading() {
            const loadingEl = document.getElementById('loading-indicator');
            if (loadingEl) loadingEl.style.display = 'none';

            // BUGFIX: Wenn gerade ein Artikel gelesen wird, die Liste NICHT neu
            // rendern (sonst Rauswurf aus dem Lesemodus bei Auto-Refresh). Daten &
            // gefilterte Liste werden dennoch aktualisiert – nur ohne DOM-Neuaufbau.
            if (isReadingArticle()) {
                // Nur Daten/gefilterte Liste aktualisieren, KEIN Neu-Rendern.
                filterData(true);
            } else {
                // Aktuellen Filter/Suche beibehalten, statt hart zurückzusetzen.
                filterData();
            }
            updateTicker();
            generateSEO();
            maximizeSEOIndexing();     // BUGFIX: wurde vorher nie aufgerufen (dead code)
            updateCategoryCounts();
            updateLastUpdatedLabel();
            renderSourceStatus();      // Faktische Ladeergebnisse je Quelle anzeigen
            renderCategoryLinks();     // Crawlbare Links zu allen Quellen-Unterseiten
            renderArticleIndex();      // Crawlbaren Link-Index + ItemList-Schema erzeugen

            // Verschlüsselte Cache-Speicherung (nur wenn nicht bereits geschrieben)
            try {
                if (!localStorage.getItem('news_cache')) {
                    localStorage.setItem('news_cache', encryptCache(appState.allNews));
                }
            } catch (e) { /* Speicher evtl. voll – nicht kritisch */ }

            // Service Worker Registrierung für Offline-Fähigkeit.
            // Hinweis: Funktioniert nur über http(s) mit vorhandener /sw.js,
            // nicht über file:// – dann wird der Fehler still ignoriert.
            if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
                navigator.serviceWorker.register('/sw.js').then(function() {
                    console.log('Service Worker registriert');
                }).catch(function() {
                    console.log('Service Worker nicht verfügbar (kein Problem).');
                });
            }
        }

        // Aktualisiert die "Zuletzt aktualisiert"-Anzeige im Header
        function updateLastUpdatedLabel() {
            const el = document.getElementById('last-updated');
            if (!el) return;
            const t = appState.lastUpdated || new Date();
            el.textContent = 'Zuletzt aktualisiert: ' + t.toLocaleTimeString('de-DE');
        }

        // ---------------------------------------------------------------------
        //  QUELLEN-STATUS: zeigt faktisch, welche Feeds beim letzten Ladevorgang
        //  erreichbar waren, wie viele Meldungen sie lieferten und wie lange es
        //  dauerte. Reine Messwerte – keine Schätzungen, keine Behauptungen.
        // ---------------------------------------------------------------------
        function renderSourceStatus() {
            const wrap = document.getElementById('source-status');
            const list = document.getElementById('source-status-list');
            const summary = document.getElementById('source-status-summary');
            if (!wrap || !list || !appState.feedStatus || !appState.feedStatus.length) return;

            const esc = SecurityUtils.escapeHtml;
            const okCount = appState.feedStatus.filter(s => s.ok).length;
            const total = appState.feedStatus.length;
            // Wegen der Schonfrist werden nicht immer alle Quellen abgerufen.
            // Die Bezugsgroesse ist daher die Zahl der TATSAECHLICH versuchten
            // Quellen, nicht die Gesamtzahl. Sonst stuende dort etwa "1/40",
            // obwohl 39 Quellen nur uebersprungen und keineswegs kaputt waren.
            const gesamtAktiv = (typeof activeFeeds === 'function') ? activeFeeds().length : total;
            const uebersprungen = Math.max(0, gesamtAktiv - total);

            if (summary) {
                summary.textContent = `Quellen-Status: ${okCount} von ${total} abgerufenen Quellen erreichbar `
                    + (okCount === total ? '✓' : '⚠')
                    + (uebersprungen ? ` · ${uebersprungen} vor Kurzem geladen und übersprungen` : '');
            }

            list.innerHTML = appState.feedStatus.map(s => {
                const cls = s.ok ? 'ok' : 'fail';
                const icon = s.ok ? '✓' : '✕';
                const detail = s.ok
                    ? `<span class="src-count">${s.count}</span>`
                    : `<span class="src-count">0</span>`;
                const t = (typeof s.ms === 'number') ? ` title="Ladezeit: ${s.ms} ms"` : '';
                return `<span class="src-chip ${cls}"${t}>${icon} ${esc(s.cat)} ${detail}</span>`;
            }).join('');

            wrap.hidden = false;
        }

        // Zeigt pro Filter-Button die Anzahl der Meldungen an (Badge)
        function updateCategoryCounts() {
            const counts = { all: appState.allNews.length };
            appState.allNews.forEach(n => {
                counts[n.category] = (counts[n.category] || 0) + 1;
            });
            document.querySelectorAll('.filter-btn').forEach(btn => {
                const cat = btn.getAttribute('data-cat');
                if (cat && counts[cat] !== undefined) {
                    let badge = btn.querySelector('.count-badge');
                    if (!badge) {
                        badge = document.createElement('span');
                        badge.className = 'count-badge';
                        btn.appendChild(badge);
                    }
                    badge.textContent = counts[cat];
                }
            });
        }

        // Zeigt die Trefferanzahl unter der Suchleiste an
        function updateResultsCount() {
            const el = document.getElementById('results-count');
            if (!el) return;
            const total = appState.allNews.length;
            const shown = appState.filteredNews.length;
            const text = shown === total
                ? `${total} Meldungen`
                : `${shown} von ${total} Meldungen`;
            el.textContent = text;
            // Regionszaehler mitziehen: sie haengen an denselben Daten und wuerden
            // sonst nach dem Laden auf dem Startwert 0 stehen bleiben.
            if (typeof updateRegionCounts === 'function') updateRegionCounts();
            // Screenreader-Ansage (entprellt, damit progressive Renders nicht spammen)
            announce(text + ' angezeigt');
        }

        // BARRIEREFREIHEIT: Trägt eine Meldung in die Live-Region ein. Entprellt,
        // damit schnelle Aufeinanderfolgen zu EINER Vorlesung zusammengefasst werden.
        let _announceTimer = null;
        function announce(message) {
            const live = document.getElementById('a11y-live');
            if (!live) return;
            clearTimeout(_announceTimer);
            _announceTimer = setTimeout(() => {
                live.textContent = '';           // Reset erzwingt erneute Ansage
                live.textContent = message;
            }, 500);
        }

        // HINWEIS / BUGFIX: Hier stand zuvor eine ZWEITE Service-Worker-Registrierung
        // über eine `data:text/javascript,...`-URL. Diese wurde von der eigenen
        // Content-Security-Policy blockiert (worker-src fällt auf script-src zurück,
        // das kein `data:` erlaubt) UND konkurrierte mit der korrekten Registrierung
        // von `/sw.js` in finalizeLoading(). Der Inline-Worker wurde daher entfernt.
        //
        // Stattdessen: einmalige Prüfung auf Service-Worker-Updates. Wird ein neuer
        // /sw.js gefunden, aktivieren wir ihn sofort (skipWaiting) und laden neu.
        if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
            navigator.serviceWorker.addEventListener('controllerchange', function() {
                // Neuer Service Worker hat übernommen -> Seite einmalig neu laden,
                // damit aktualisierte Assets sicher greifen.
                if (window.__swReloaded) return;
                window.__swReloaded = true;
                console.log('Neuer Service Worker aktiv – lade Seite neu.');
            });
            navigator.serviceWorker.ready.then(function(registration) {
                // Regelmäßig (alle 30 Min.) im Hintergrund nach Updates suchen.
                setInterval(function() {
                    registration.update().catch(function() {});
                }, 30 * 60 * 1000);
            }).catch(function() { /* kein SW aktiv – unkritisch */ });
        }

        // --- MOBILE DETECTION & OPTIMIZATION ---
        function detectMobile() {
            return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
        }

        function optimizeForMobile() {
            if (detectMobile()) {
                console.log('Mobile device detected, optimizing...');
                // Add mobile-specific meta tags
                // BUGFIX A11Y/KOMPATIBILITÄT: „user-scalable=no“ / „maximum-scale=1“
                // entfernt – das Sperren des Pinch-Zooms verstößt gegen WCAG 1.4.4
                // und erschwert die Nutzung auf Handys/Tablets. Zoom bleibt erlaubt.
                updateMetaTag('viewport', 'width=device-width, initial-scale=1.0, viewport-fit=cover');
                updateMetaTag('mobile-web-app-capable', 'yes');
                updateMetaTag('apple-mobile-web-app-capable', 'yes');
                updateMetaTag('apple-mobile-web-app-status-bar-style', 'black-translucent');
                
                // Add touch event listeners for better UX
                document.addEventListener('touchstart', function() {}, { passive: true });
                
                // Adjust loading for mobile networks
                appState.mobileMode = true;
                
                // For mobile, prioritize faster proxies
                CONFIG.mobilePriority = ['cors-anywhere', 'thingproxy', 'codetabs', 'corsproxy'];
                
                // Add mobile-specific CSS for better performance
                const mobileCSS = document.createElement('style');
                mobileCSS.textContent = `
                    @media (max-width: 768px) {
                        body { font-size: 16px; } /* Prevent zoom */
                        .card { touch-action: manipulation; }
                        .news-grid { display: block; } /* Stack cards vertically */
                        .card { margin-bottom: 20px; width: 100%; }
                    }
                `;
                document.head.appendChild(mobileCSS);
            }
        }

        // Call on load
        optimizeForMobile();
        
        // Ensure automatic loading on mobile devices
        if (detectMobile()) {
            // Force fresh load on mobile to avoid cache issues
            setTimeout(() => {
                if (appState.allNews.length === 0) {
                    console.log('Mobile: Forcing fresh load');
                    loadLiveFeeds();
                }
            }, 500);
        }

        // --- MAXIMUM SEO INDEXING ENHANCEMENT ---
        function maximizeSEOIndexing() {
            // Add OpenGraph for every possible element
            document.querySelectorAll('.card').forEach((card, index) => {
                const item = appState.allNews[index];
                if (item) {
                    card.setAttribute('data-og-title', item.title);
                    card.setAttribute('data-og-description', item.description.substring(0, 200));
                    card.setAttribute('data-og-url', `${articlePermalink(item)}`);
                    card.setAttribute('data-og-image', 'https://nachrichten.kaipfstr.de/assets/og-image.png');
                    card.setAttribute('data-twitter-card', 'summary_large_image');
                    card.setAttribute('data-twitter-title', item.title);
                    card.setAttribute('data-twitter-description', item.description.substring(0, 200));
                }
            });
            
            // Add breadcrumb navigation for SEO
            // BUGFIX: Nur EINMAL einfügen. Zuvor wurde bei jedem Refresh eine
            // weitere versteckte Breadcrumb-<nav> an den Body-Anfang gehängt.
            if (document.getElementById('seo-breadcrumb')) {
                // bereits vorhanden -> nur strukturierte Daten aktualisieren
            } else {
            const breadcrumb = document.createElement('nav');
            breadcrumb.id = 'seo-breadcrumb';
            breadcrumb.setAttribute('aria-label', 'Breadcrumb');
            breadcrumb.innerHTML = `
                <ol itemscope itemtype="https://schema.org/BreadcrumbList">
                    <li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
                        <a itemprop="item" href="https://nachrichten.kaipfstr.de">
                            <span itemprop="name">Startseite</span>
                        </a>
                        <meta itemprop="position" content="1">
                    </li>
                    <li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
                        <span itemprop="name">Nachrichten</span>
                        <meta itemprop="position" content="2">
                    </li>
                </ol>
            `;
            breadcrumb.style.display = 'none'; // Hidden but accessible to crawlers
            document.body.insertBefore(breadcrumb, document.body.firstChild);
            }

            // Add FAQ schema for common questions (faktenbasiert formuliert)
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "FAQPage",
                "mainEntity": [
                    {
                        "@type": "Question",
                        "name": "Was ist Nachrichten Kaipfstr?",
                        "acceptedAnswer": {
                            "@type": "Answer",
                            "text": "Nachrichten Kaipfstr ist ein Nachrichten-Aggregator, der öffentlich verfügbare RSS-Feeds etablierter deutscher Quellen (u. a. Tagesschau, Deutschlandfunk, Bundesregierung, Spiegel, FAZ, Zeit) bündelt und übersichtlich darstellt."
                        }
                    },
                    {
                        "@type": "Question",
                        "name": "Sind die Nachrichten kostenlos?",
                        "acceptedAnswer": {
                            "@type": "Answer",
                            "text": "Ja, die Nutzung ist kostenlos. Die Inhalte stammen aus frei zugänglichen RSS-Feeds; die Urheberrechte verbleiben bei den jeweiligen Quellen."
                        }
                    },
                    {
                        "@type": "Question",
                        "name": "Werden personenbezogene Daten gespeichert?",
                        "acceptedAnswer": {
                            "@type": "Answer",
                            "text": "Nein. Die Seite speichert lediglich einen lokalen Zwischenspeicher (localStorage) im Browser des Nutzers, um Wiederholbesuche zu beschleunigen. Es werden keine personenbezogenen Daten an den Betreiber übertragen."
                        }
                    }
                ]
            }, 'faq');

            // Add HowTo schema for using the site
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "HowTo",
                "name": "Nachrichten auf Nachrichten Kaipfstr finden",
                "description": "So finden Sie schnell die neuesten Nachrichten",
                "step": [
                    {
                        "@type": "HowToStep",
                        "name": "Suchen",
                        "text": "Verwenden Sie die Suchleiste, um nach bestimmten Themen zu suchen."
                    },
                    {
                        "@type": "HowToStep",
                        "name": "Filtern",
                        "text": "Nutzen Sie die Filter-Buttons, um Nachrichten nach Quelle zu filtern."
                    },
                    {
                        "@type": "HowToStep",
                        "name": "Merken",
                        "text": "Klicken Sie auf den Stern, um Artikel als Favorit zu speichern."
                    }
                ]
            }, 'howto');

            // Add WebSite schema for better indexing
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "WebSite",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "description": "Nachrichten-Aggregator für öffentlich verfügbare RSS-Feeds deutscher Quellen.",
                "inLanguage": "de-DE",
                "publisher": {
                    "@type": "Organization",
                    "name": "Nachrichten Kaipfstr"
                },
                "potentialAction": {
                    "@type": "SearchAction",
                    "target": "https://nachrichten.kaipfstr.de/search?q={search_term_string}",
                    "query-input": "required name=search_term_string"
                }
            }, 'seo-website');

            // Add Organization schema
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "Organization",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "logo": "https://nachrichten.kaipfstr.de/assets/logo.png",
                "sameAs": [
                    "https://twitter.com/kaipfstr_news",
                    "https://www.facebook.com/nachrichten.kaipfstr",
                    "https://www.instagram.com/nachrichten.kaipfstr"
                ]
            }, 'seo-org');

            // Add hidden structured data for all news items (als EIN @graph, keyed).
            // BUGFIX: Zuvor wurde bei jedem Refresh ein neues Skript angehängt, ohne
            // die alten zu entfernen -> DOM wuchs unbegrenzt. Jetzt: ein Skript mit
            // festem key, das bei Aktualisierung ersetzt wird. Auf 60 Artikel
            // begrenzt, damit die Seitengröße handhabbar bleibt.
            const allNewsSchema = appState.allNews.slice(0, 60).map((item, index) => ({
                "@context": "https://schema.org",
                "@type": "NewsArticle",
                "headline": item.title,
                "description": item.description,
                "image": item.image || "https://nachrichten.kaipfstr.de/assets/og-image.png",
                "datePublished": new Date(item.timestamp).toISOString(),
                "dateModified": new Date(item.timestamp).toISOString(),
                "publisher": {
                    "@type": "Organization",
                    "name": "Nachrichten Kaipfstr",
                    "url": "https://nachrichten.kaipfstr.de"
                },
                "url": `${articlePermalink(item)}`,
                "mainEntityOfPage": {
                    "@type": "WebPage",
                    "@id": `${articlePermalink(item)}`
                },
                "inLanguage": "de-DE", "isAccessibleForFree": true, "articleSection": item.category,
                "keywords": `${item.category}, Nachrichten, Deutschland, ${item.title.split(' ').slice(0,5).join(', ')}`
            }));
            addJsonLd(allNewsSchema, 'seo-news');
        }

        // ---------------------------------------------------------------------
        //  CRAWLBARER ARTIKEL-INDEX (für maximale Indexierung)
        //  Erzeugt echte <a>-Links auf die PERMANENTEN Quell-URLs jeder Meldung
        //  sowie ein ItemList-Schema (schema.org) mit allen Permalinks. So kann
        //  Google jede einzelne Nachricht entdecken und indexieren.
        // ---------------------------------------------------------------------
        const ARTICLE_INDEX_MAX = 250;
        function renderArticleIndex() {
            const listEl = document.getElementById('article-index-list');
            if (!listEl || !appState.allNews.length) return;
            const esc = SecurityUtils.escapeHtml;

            const items = appState.allNews.slice(0, ARTICLE_INDEX_MAX);
            // Sichtbare, crawlbare Linkliste (ein echter <a> je Meldung)
            let html = '<ul class="article-index-ul">';
            items.forEach(item => {
                const url = articlePermalink(item);
                const when = new Date(item.timestamp).toISOString().slice(0, 10);
                html += `<li><a href="${esc(url)}" rel="nofollow noopener"
                    hreflang="de"><span class="ai-cat">${esc(item.category)}</span>
                    <span class="ai-title">${esc(item.title)}</span>
                    <time datetime="${esc(when)}">${esc(when)}</time></a></li>`;
            });
            html += '</ul>';
            if (items.length >= ARTICLE_INDEX_MAX) {
                html += `<p class="ai-note">Es werden die neuesten ${ARTICLE_INDEX_MAX} von insgesamt ${appState.allNews.length} archivierten Meldungen verlinkt.</p>`;
            }
            listEl.innerHTML = html;

            // ItemList-Schema mit allen Permalinks (leichtgewichtig, nur URLs/Namen)
            const itemListSchema = {
                "@context": "https://schema.org",
                "@type": "ItemList",
                "name": "Alle Meldungen – Nachrichten Kaipfstr",
                "description": "Index aller aktuell verfügbaren und archivierten Nachrichten mit dauerhaften Quell-Links.",
                "numberOfItems": items.length,
                "itemListElement": items.map((item, i) => ({
                    "@type": "ListItem",
                    "position": i + 1,
                    "url": articlePermalink(item),
                    "name": item.title
                }))
            };
            addJsonLd(itemListSchema, 'seo-itemlist');
        }

        function setupRouting() {
            window.addEventListener('popstate', handleRoute);
            window.addEventListener('hashchange', handleRoute);

            // BUGFIX: Der frühere globale Klick-Handler fing JEDEN Klick innerhalb
            // einer Karte ab (inkl. Favoriten-Stern, Teilen-Button und Quell-Link)
            // und navigierte per e.preventDefault() zwangsweise zur Einzelansicht.
            // Dadurch funktionierten die neuen Buttons nicht und externe Links
            // wurden blockiert. Jetzt: nur Klick auf Titel/Kartenkörper navigiert;
            // interaktive Steuerelemente behalten ihr eigenes Verhalten.
            document.addEventListener('click', function(e) {
                // Interaktive Elemente NICHT abfangen
                if (e.target.closest('a, button, input, .icon-btn, .card-actions')) {
                    return;
                }
                const card = e.target.closest('.card');
                if (!card) return;

                const id = card.dataset.id;
                const index = appState.allNews.findIndex(n => n.id === id);
                if (index !== -1) {
                    // Einzelansicht öffnen (History nur setzen, wenn über http(s),
                    // damit ein Reload auf file:// nicht ins Leere läuft).
                    if (location.protocol.startsWith('http')) {
                        // Stabiler Slug-Permalink statt verschiebbarer /news/N-Route
                        history.pushState({ newsIndex: index }, '', `#/artikel/${articleSlug(appState.allNews[index])}`);
                    }
                    renderSingleNews(index);
                    updateMetaForNews(index);
                    window.scrollTo({ top: 0, behavior: 'smooth' });
                }
            });

            // BARRIEREFREIHEIT: Enter/Leertaste auf einer fokussierten Karte öffnet
            // die Meldung (Tastaturbedienung, WCAG 2.1.1). Interaktive Kinder
            // (Links/Buttons) behalten ihr eigenes Verhalten.
            document.addEventListener('keydown', function(e) {
                if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
                const active = document.activeElement;
                if (!active || !active.classList || !active.classList.contains('card')) return;
                if (e.target.closest('a, button, input, .icon-btn, .card-actions')) return;
                e.preventDefault();
                const index = appState.allNews.findIndex(n => n.id === active.dataset.id);
                if (index !== -1) {
                    if (location.protocol.startsWith('http')) {
                        history.pushState({ newsIndex: index }, '', `#/artikel/${articleSlug(appState.allNews[index])}`);
                    }
                    renderSingleNews(index);
                    updateMetaForNews(index);
                    window.scrollTo({ top: 0, behavior: 'smooth' });
                }
            });
        }

        function handleRoute(event) {
            // Unterstützt sowohl echte Pfade (/news/N, bei SPA-Server-Rewrite) als
            // auch Hash-Routen (#/news/N, funktioniert auf jedem statischen Host).
            const path = window.location.pathname;
            const hash = window.location.hash;

            // STABILER SLUG-PERMALINK: #/artikel/<slug>-<id6> bleibt auch nach
            // Feed-Wechsel auflösbar, da das Archiv die Meldung vorhält.
            //
            // BUGFIX: Bisher wurde NUR die Hash-Form (#/artikel/…) ausgewertet.
            // Die Datei _redirects leitet jedoch auch den Pfad /artikel/<slug> auf
            // index.html um (Status 200). Beim direkten Aufruf oder Neuladen einer
            // solchen Adresse fand das Routing nichts und landete auf der Startseite
            // – der geteilte Link zeigte also den falschen Inhalt. Jetzt werden
            // BEIDE Formen erkannt (Pfad und Hash), inklusive URL-Dekodierung für
            // Umlaute und Sonderzeichen im Slug.
            // REGIONEN-UEBERSICHT: /regionen
            if (/^\/regionen\/?$/.test(path) || hash === '#/regionen') {
                renderRegionIndexPage();
                return;
            }

            // REGIONS-UNTERSEITE: /region/<slug> (bzw. #/region/<slug>)
            const regionMatch = path.match(/^\/region\/([^/]+)\/?$/)
                             || hash.match(/^#\/region\/([^/]+)$/);
            if (regionMatch) {
                let gewuenscht = regionMatch[1];
                try { gewuenscht = decodeURIComponent(gewuenscht); } catch (e) {}
                const region = findeRegionNachSlug(gewuenscht);
                if (region) {
                    openRegionPage(region, false);
                    return;
                }
                // Unbekannte Region: Uebersicht statt Fehlermeldung
                renderRegionIndexPage();
                return;
            }

            // QUELLEN-UNTERSEITE: /quelle/<slug> (bzw. #/quelle/<slug>)
            const catMatch = path.match(/^\/quelle\/([^/]+)\/?$/)
                          || hash.match(/^#\/quelle\/([^/]+)$/);
            if (catMatch) {
                let wantedCat = catMatch[1];
                try { wantedCat = decodeURIComponent(wantedCat); } catch (e) {}
                const cat = findCategoryBySlug(wantedCat);
                if (cat) {
                    // Adresse NICHT erneut setzen (wir sind bereits dort)
                    openCategoryPage(cat, false);
                    return;
                }
                // Unbekannte Quelle -> Übersicht statt Fehlermeldung
                renderNews();
                updateMetaForHome();
                return;
            }

            // ÜBERSICHT ALLER QUELLEN: /quellen
            if (/^\/quellen\/?$/.test(path) || /^#\/quellen$/.test(hash)) {
                renderSourceIndexPage();
                return;
            }

            // LESEVERLAUF: /verlauf (bewusst nicht indexiert – rein persönlich)
            if (/^\/verlauf\/?$/.test(path) || /^#\/verlauf$/.test(hash)) {
                renderHistoryPage();
                return;
            }

            const slugMatch = hash.match(/^#\/artikel\/(.+)$/)
                           || path.match(/^\/artikel\/(.+?)\/?$/);
            if (slugMatch) {
                let wanted = slugMatch[1];
                try { wanted = decodeURIComponent(wanted); } catch (e) { /* ungültige Kodierung ignorieren */ }
                const idx = appState.allNews.findIndex(n => articleSlug(n) === wanted
                    || (n.id && wanted.endsWith(String(n.id).slice(-6))));
                if (idx !== -1) {
                    renderSingleNews(idx);
                    updateMetaForNews(idx);
                } else {
                    // Noch nicht geladen -> laden und erneut versuchen
                    Promise.resolve(loadLiveFeeds()).then(() => {
                        const j = appState.allNews.findIndex(n => articleSlug(n) === wanted);
                        if (j !== -1) { renderSingleNews(j); updateMetaForNews(j); }
                        else { renderNews(); updateMetaForHome(); }
                    });
                }
                return;
            }

            const match = path.match(/^\/news\/(\d+)$/) || hash.match(/^#\/news\/(\d+)$/);
            if (match) {
                const index = parseInt(match[1]) - 1;
                if (appState.allNews[index]) {
                    renderSingleNews(index);
                    updateMetaForNews(index);
                } else {
                    // If news not loaded yet, load and then render
                    loadLiveFeeds().then(() => {
                        if (appState.allNews[index]) {
                            renderSingleNews(index);
                            updateMetaForNews(index);
                        }
                    });
                }
            } else {
                // Home page
                // BUGFIX: Beim Kaltstart NICHT rendern, solange noch keine Daten da
                // sind und gerade geladen wird – sonst würden die Skeleton-Karten
                // sofort durch „Keine Nachrichten gefunden“ überschrieben (Flackern).
                if (appState.allNews && appState.allNews.length > 0) {
                    renderNews();
                }
                updateMetaForHome();
            }
        }

        function renderSingleNews(index) {
            const item = appState.allNews[index];
            if (!item) { renderNews(); return; } // BUGFIX: Guard gegen undefined
            if (item.id) markRead(item.id); // Einzelansicht -> als gelesen markieren
            // Laufende Sprachausgabe beenden, wenn ein anderer Artikel geöffnet wird
            // (sonst spricht der Browser den alten Text im Hintergrund weiter).
            if (typeof _speakingId !== 'undefined' && _speakingId && _speakingId !== item.id) {
                stopSpeaking(true);
            }
            // Einzelansicht: Intro-<h1> ausblenden, damit pro Ansicht nur EINE
            // <h1> aktiv ist (die Artikelüberschrift) – sauber für SEO.
            const intro = document.getElementById('intro');
            if (intro) intro.style.display = 'none';
            // „Mehr anzeigen“-Bereich in der Einzelansicht entfernen.
            const staleMore = document.getElementById('load-more-wrap');
            if (staleMore) staleMore.remove();
            const esc = SecurityUtils.escapeHtml;
            const dateStr = new Date(item.timestamp).toLocaleString('de-DE', {
                weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
            });
            const readTime = item.readingTime || estimateReadingTime(item.description);
            // Leadbild nur zeigen, wenn vorhanden (angenehmer, ruhiger Lesemodus).
            const lead = item.image
                ? `<figure class="reader-figure"><img src="${esc(item.image)}" alt="${esc(item.title)}" loading="eager" onerror="this.closest('.reader-figure').remove()"><figcaption>Bildquelle: ${esc(item.category)}</figcaption></figure>`
                : '';
            // Anti-XSS: title/category escaped; fullContent bereits sanitisiert.
            document.getElementById('news-container').innerHTML = `
                <article class="reader single-news" data-index="${index}" itemscope itemtype="https://schema.org/NewsArticle">
                    <button class="reader-back" onclick="goHome()" aria-label="Zurück zur Übersicht">← Zur Übersicht</button>
                    <nav class="reader-crumbs" aria-label="Brotkrümel">
                        <a href="#" onclick="goHome();return false;">Startseite</a>
                        <span aria-hidden="true">›</span>
                        <span>${esc(item.category)}</span>
                    </nav>
                    <span class="source-badge ${esc(item.badgeClass)}" itemprop="articleSection">${esc(item.category)}</span>
                    <h1 class="reader-title" itemprop="headline">${esc(item.title)}</h1>
                    <div class="reader-meta">
                        <span>🕒 <time datetime="${new Date(item.timestamp).toISOString()}" itemprop="datePublished">${esc(dateStr)}</time></span>
                        <span>·</span>
                        <span>⏱ ca. ${readTime} Min. Lesezeit</span>
                    </div>
                    ${lead}
                    <div class="reader-body" itemprop="articleBody">${item.fullContent || esc(item.description)}</div>
                    <div class="reader-actions">
                        <a href="${esc(item.link)}" target="_blank" rel="nofollow noopener noreferrer" class="filter-btn active" style="text-decoration:none;">Vollständigen Artikel bei der Quelle lesen →</a>
                        <button type="button" class="filter-btn" id="speak-btn" onclick="speakArticle('${esc(item.id)}')" aria-pressed="false" title="Artikel vorlesen lassen">🔊 Vorlesen</button>
                        <button type="button" class="filter-btn" onclick="copyPermalink('${esc(item.id)}')">🔗 Permanenten Link kopieren</button>
                        <button type="button" class="filter-btn" onclick="shareArticle('${esc(item.id)}')">Teilen ⤴</button>
                        <button type="button" class="filter-btn" onclick="toggleFavorite('${esc(item.id)}')">${isFavorite(item.id) ? '★ Gemerkt' : '☆ Merken'}</button>
                    </div>
                    <p class="reader-note">Dieser Inhalt stammt aus dem öffentlichen RSS-Feed von <strong>${esc(item.category)}</strong>. Alle Rechte verbleiben bei der Quelle.</p>
                    ${buildRelatedHtml(item)}
                    <nav class="reader-nav" aria-label="Artikelnavigation">
                        <button type="button" class="reader-nav-btn" onclick="readerNav(${index}, -1)" ${index <= 0 ? 'disabled' : ''} aria-label="Neuerer Artikel">
                            <span class="rn-dir">← Neuer</span>
                            <span class="rn-title">${index > 0 ? esc(appState.allNews[index - 1].title.substring(0, 48)) : ''}</span>
                        </button>
                        <button type="button" class="reader-nav-btn next" onclick="readerNav(${index}, 1)" ${index >= appState.allNews.length - 1 ? 'disabled' : ''} aria-label="Älterer Artikel">
                            <span class="rn-dir">Älter →</span>
                            <span class="rn-title">${index < appState.allNews.length - 1 ? esc(appState.allNews[index + 1].title.substring(0, 48)) : ''}</span>
                        </button>
                    </nav>
                </article>
            `;
            document.getElementById('no-results').style.display = 'none';

            // WISCH-GESTE (mobil): horizontal wischen blättert zwischen Artikeln.
            attachReaderSwipe(index);
        }

        // Blättert im Lesemodus zum benachbarten Artikel (delta -1 = neuer, +1 = älter).
        function readerNav(index, delta) {
            const target = index + delta;
            if (target < 0 || target >= appState.allNews.length) return;
            const item = appState.allNews[target];
            if (location.protocol.startsWith('http')) {
                history.pushState({ newsIndex: target }, '', `#/artikel/${articleSlug(item)}`);
            }
            renderSingleNews(target);
            updateMetaForNews(target);
            window.scrollTo({ top: 0, behavior: 'smooth' });
        }

        // Registriert Touch-Wischgesten auf dem Lesebereich (nur horizontal,
        // ausreichend lang, nicht während vertikalem Scrollen) -> mobilfreundlich.
        function attachReaderSwipe(index) {
            const reader = document.querySelector('.reader');
            if (!reader) return;
            let startX = 0, startY = 0, tracking = false;
            reader.addEventListener('touchstart', (e) => {
                if (e.touches.length !== 1) { tracking = false; return; }
                startX = e.touches[0].clientX;
                startY = e.touches[0].clientY;
                tracking = true;
            }, { passive: true });
            reader.addEventListener('touchend', (e) => {
                if (!tracking) return;
                tracking = false;
                const dx = e.changedTouches[0].clientX - startX;
                const dy = e.changedTouches[0].clientY - startY;
                // Nur klare horizontale Wische auswerten
                if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 1.6) {
                    if (dx < 0) readerNav(index, 1);   // nach links wischen -> älter
                    else readerNav(index, -1);         // nach rechts wischen -> neuer
                }
            }, { passive: true });
        }

        function updateMetaForNews(index) {
            const item = appState.allNews[index];
            document.title = `${item.title} | Nachrichten Kaipfstr`;

            // Update meta tags for SEO
            updateMetaTag('description', item.description.substring(0, 160));
            updateMetaTag('og:title', item.title);
            updateMetaTag('og:description', item.description.substring(0, 160));
            updateMetaTag('og:url', `${articlePermalink(item)}`);
            updateMetaTag('og:image', item.image || 'https://nachrichten.kaipfstr.de/assets/og-image.png');
            updateMetaTag('article:published_time', new Date(item.timestamp).toISOString());
            updateMetaTag('article:section', item.category);

            // Per-Artikel-Canonical auf die PERMANENTE Quell-URL setzen -> Google
            // ordnet den Inhalt der dauerhaft verfügbaren Originalseite zu.
            setCanonical(articlePermalink(item));
            
            // Add JSON-LD for news article to boost Google indexing
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "NewsArticle",
                "headline": item.title,
                "description": item.description,
                "datePublished": new Date(item.timestamp).toISOString(),
                "dateModified": new Date(item.timestamp).toISOString(),
                "publisher": {
                    "@type": "Organization",
                    "name": "Nachrichten Kaipfstr",
                    "url": "https://nachrichten.kaipfstr.de",
                    "logo": {
                        "@type": "ImageObject",
                        "url": "https://nachrichten.kaipfstr.de/assets/logo.png"
                    }
                },
                "url": `${articlePermalink(item)}`,
                "mainEntityOfPage": {
                    "@type": "WebPage",
                    "@id": `${articlePermalink(item)}`
                },
                "inLanguage": "de-DE", "isAccessibleForFree": true, "articleSection": item.category,
                "keywords": `${item.category}, Nachrichten, Deutschland, ${item.title.split(' ').slice(0,5).join(', ')}`
            }, 'route-jsonld');
        }

        function updateMetaForHome() {
            document.title = "Nachrichten Kaipfstr | Politik, Meinungen & Fakten - Live Feed";
            updateMetaTag('description', "Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen. Unabhängig, faktenbasiert und live. Politik, Tagesschau und exklusive Meinungen auf nachrichten.kaipfstr.de.");
            updateMetaTag('og:title', "Nachrichten Kaipfstr | Politik, Meinungen & Fakten - Live Feed");
            updateMetaTag('og:description', "Alle Schlagzeilen auf einen Blick. Tagesschau, Politik und Meinungen.");
            updateMetaTag('og:url', "https://nachrichten.kaipfstr.de");
            updateMetaTag('og:image', "https://nachrichten.kaipfstr.de/assets/og-image.png");
            // Canonical zurück auf die Startseite
            setCanonical("https://nachrichten.kaipfstr.de");

            // Add JSON-LD for organization
            addJsonLd({
                "@context": "https://schema.org",
                "@type": "Organization",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "logo": "https://nachrichten.kaipfstr.de/assets/logo.png",
                "sameAs": [
                    "https://twitter.com/kaipfstr_news",
                    "https://www.facebook.com/nachrichten.kaipfstr"
                ]
            }, 'route-jsonld');
        }

        function updateMetaTag(name, content) {
            let meta = document.querySelector(`meta[name="${name}"], meta[property="${name}"]`);
            if (!meta) {
                meta = document.createElement('meta');
                if (name.startsWith('og:') || name.startsWith('article:')) {
                    meta.setAttribute('property', name);
                } else {
                    meta.name = name;
                }
                document.head.appendChild(meta);
            }
            meta.content = content;
        }

        // Setzt/aktualisiert den <link rel="canonical"> im <head>.
        function setCanonical(url) {
            let link = document.querySelector('link[rel="canonical"]');
            if (!link) {
                link = document.createElement('link');
                link.rel = 'canonical';
                document.head.appendChild(link);
            }
            link.href = url;
        }

        // SEO-BUGFIX: Die alte addJsonLd-Funktion entfernte per
        // querySelector('script[type="application/ld+json"]') IMMER das ERSTE
        // JSON-LD-Skript – also die STATISCHEN Schemas aus dem <head> (Website,
        // BreadcrumbList, NewsMediaOrganization). Bei jedem Aufruf wurde damit
        // wertvolle strukturierte Daten für Google zerstört.
        //
        // Neue Logik:
        //  - Statische <head>-Schemas bleiben UNANGETASTET.
        //  - Dynamische Skripte tragen ein data-jsonld-Attribut.
        //  - Mit einem "key" ersetzt man gezielt ein zuvor gesetztes dynamisches
        //    Schema (z. B. das der aktuellen Route); ohne key wird angehängt,
        //    aber inhaltsgleiche Duplikate werden vermieden.
        function addJsonLd(data, key) {
            const json = JSON.stringify(data);
            if (key) {
                document.querySelectorAll(`script[data-jsonld-key="${key}"]`).forEach(s => s.remove());
            } else {
                // Duplikate am Inhalt erkennen und überspringen
                const existing = document.querySelectorAll('script[data-jsonld-dynamic]');
                for (const s of existing) {
                    if (s.textContent === json) return;
                }
            }
            const script = document.createElement('script');
            script.type = 'application/ld+json';
            script.setAttribute('data-jsonld-dynamic', '1');
            if (key) script.setAttribute('data-jsonld-key', key);
            script.textContent = json;
            document.head.appendChild(script);
        }

        // --- ENHANCED MOBILE & SEO DETECTION ---
        function detectDeviceType() {
            const ua = navigator.userAgent.toLowerCase();
            return {
                isMobile: /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(ua),
                isTablet: /ipad|android(?!.*mobile)|tablet/i.test(ua),
                isDesktop: !/mobile|android|tablet|iphone|ipad/i.test(ua),
                isTouchDevice: () => (('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0)),
                isIOS: /iphone|ipad|ipod/.test(ua),
                isAndroid: /android/.test(ua),
                isWindows: /windows|win32/.test(ua),
                isMac: /macintosh|macintel/.test(ua),
                isLinux: /linux/.test(ua)
            };
        }

        const deviceInfo = detectDeviceType();
        
        // --- ENHANCED MOBILE OPTIMIZATION ---
        function optimizeForMobileEnhanced() {
            if (deviceInfo.isMobile || deviceInfo.isTablet) {
                // Add mobile-specific body class
                document.body.classList.add('is-mobile-device');
                if (deviceInfo.isTouchDevice()) document.body.classList.add('is-touch-device');
                
                // Optimize viewport for different devices
                if (deviceInfo.isIOS) {
                    document.body.classList.add('is-ios');
                }
                if (deviceInfo.isAndroid) {
                    document.body.classList.add('is-android');
                }
                
                // Auto-load news on mobile – aber nur, wenn noch KEINE Daten da sind.
                // BUGFIX: Vorher wurde bei jedem 'load' erneut geladen, auch wenn
                // initApp bereits (aus Cache) gerendert hatte -> doppelter Traffic.
                window.addEventListener('load', function() {
                    setTimeout(function() {
                        if (!appState.allNews || appState.allNews.length === 0) {
                            Promise.resolve(loadLiveFeeds()).catch(e => console.log('Mobile auto-load error:', e));
                        }
                    }, 500);
                }, false);

                // BUGFIX A11Y: Zoom NICHT deaktivieren (WCAG 1.4.4). Viewport erlaubt
                // weiterhin Pinch-Zoom; lediglich viewport-fit=cover für Notch-Geräte.
                const viewportMeta = document.querySelector('meta[name="viewport"]');
                if (viewportMeta) {
                    viewportMeta.setAttribute('content', 'width=device-width, initial-scale=1.0, viewport-fit=cover');
                }

                // Auf Mobilgeräten Parallelität senken (weniger gleichzeitige
                // Verbindungen = stabiler in Mobilfunknetzen) und Timeout leicht erhöhen.
                CONFIG.maxConcurrentFeeds = 3;
                CONFIG.timeoutMs = 10000;

                // Prioritize faster proxies for mobile
                CONFIG.mobilePriority = ['rss2json', 'codetabs', 'allorigins', 'thingproxy'];
                
                // Add mobile-specific CSS for better performance
                const mobileCSS = document.createElement('style');
                mobileCSS.textContent = `
                    @media (max-width: 768px) {
                        body { font-size: 16px; } /* Prevent zoom */
                        .card { touch-action: manipulation; cursor: pointer; }
                        .news-grid { display: block; } /* Stack cards vertically */
                        .card { margin-bottom: 12px; width: 100%; }
                        * { -webkit-touch-callout: none; }
                        img { pointer-events: none; -webkit-user-select: none; user-select: none; }
                    }
                `;
                document.head.appendChild(mobileCSS);
            }
        }

        // Call enhanced mobile optimization
        optimizeForMobileEnhanced();
        
        // --- ENHANCED SEO INDEXING SYSTEM ---
        function maximizeSEOIndexingEnhanced() {
            // Add hidden meta descriptions for each news article
            if (appState.allNews && appState.allNews.length > 0) {
                appState.allNews.forEach((item, index) => {
                    const metaDesc = document.createElement('meta');
                    metaDesc.name = `description-${index}`;
                    metaDesc.content = item.title + '. ' + item.description.substring(0, 150);
                    document.head.appendChild(metaDesc);
                });
            }
            
            // Add comprehensive JSON-LD schemas for all news items
            const allNewsSchema = [];
            appState.allNews.forEach((item, index) => {
                allNewsSchema.push({
                    "@context": "https://schema.org",
                    "@type": "NewsArticle",
                    "headline": item.title,
                    "description": item.description,
                    "image": item.image || "https://nachrichten.kaipfstr.de/assets/og-image.png",
                    "datePublished": item.timestamp,
                    "dateModified": item.timestamp,
                    "author": {
                        "@type": "Organization",
                        "name": item.category,
                        "url": item.link
                    },
                    "publisher": {
                        "@type": "Organization",
                        "name": "Nachrichten Kaipfstr",
                        "logo": "https://nachrichten.kaipfstr.de/assets/logo.png"
                    },
                    "inLanguage": "de-DE", "isAccessibleForFree": true, "articleSection": item.category,
                    "keywords": `${item.category}, Nachrichten, Deutschland, ${item.title.split(' ').slice(0,5).join(', ')}`,
                    "@id": `${articlePermalink(item)}`,
                    "mainEntityOfPage": {
                        "@type": "WebPage",
                        "@id": `${articlePermalink(item)}`
                    }
                });
            });
            
            // Add WebSite Schema
            const websiteSchema = {
                "@context": "https://schema.org",
                "@type": "WebSite",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "description": "Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen",
                "potentialAction": {
                    "@type": "SearchAction",
                    "target": "https://nachrichten.kaipfstr.de/search?q={search_term_string}",
                    "query-input": "required name=search_term_string"
                }
            };
            
            // Add Organization Schema
            const orgSchema = {
                "@context": "https://schema.org",
                "@type": "NewsMediaOrganization",
                "name": "Nachrichten Kaipfstr",
                "url": "https://nachrichten.kaipfstr.de",
                "logo": "https://nachrichten.kaipfstr.de/assets/logo.png",
                "award": "Unabhängiger Nachrichten-Aggregator für aktuelle deutsche RSS-Quellen",
                "founder": "Kaipfstr Redaktion",
                "foundingDate": "2026-01-01"
            };
            
            // Add all schemas
            allNewsSchema.forEach(schema => addJsonLd(schema));
            addJsonLd(websiteSchema);
            addJsonLd(orgSchema);
            
            // Update card metadata for social sharing
            document.querySelectorAll('.card').forEach((card, index) => {
                const item = appState.allNews[index];
                if (item) {
                    card.setAttribute('data-og-title', item.title);
                    card.setAttribute('data-og-description', item.description.substring(0, 200));
                    card.setAttribute('data-og-url', `${articlePermalink(item)}`);
                    card.setAttribute('data-og-image', 'https://nachrichten.kaipfstr.de/assets/og-image.png');
                    card.setAttribute('data-twitter-card', 'summary_large_image');
                    card.setAttribute('data-twitter-title', item.title);
                    card.setAttribute('data-twitter-description', item.description.substring(0, 200));
                }
            });
        }
        
        // --- UNIVERSAL BROWSER COMPATIBILITY FUNCTION ---
        function ensureBrowserCompatibility() {
            // Polyfill for older browsers
            if (!window.requestAnimationFrame) {
                window.requestAnimationFrame = function(callback) {
                    return setTimeout(callback, 1000 / 60);
                };
            }
            
            // Add support for ES6 features in older browsers
            if (!Array.prototype.includes) {
                Array.prototype.includes = function(searchElement) {
                    return this.indexOf(searchElement) > -1;
                };
            }
            
            // String.prototype.startsWith polyfill
            if (!String.prototype.startsWith) {
                String.prototype.startsWith = function(search, pos) {
                    return this.substr(!pos || pos < 0 ? 0 : +pos).indexOf(search) === 0;
                };
            }
            
            // String.prototype.endsWith polyfill
            if (!String.prototype.endsWith) {
                String.prototype.endsWith = function(search, this_len) {
                    if (this_len === undefined || this_len > this.length) {
                        this_len = this.length;
                    }
                    return this.substring(this_len - search.length, this_len) === search;
                };
            }
            
            // Object.assign polyfill for IE
            if (!Object.assign) {
                Object.defineProperty(Object, 'assign', {
                    enumerable: false,
                    configurable: true,
                    writable: true,
                    value: function(target) {
                        if (target === undefined || target === null) {
                            throw new TypeError('Cannot convert undefined or null to object');
                        }
                        var output = Object(target);
                        for (var index = 1; index < arguments.length; index++) {
                            var source = arguments[index];
                            if (source !== undefined && source !== null) {
                                for (var nextKey in source) {
                                    if (Object.prototype.hasOwnProperty.call(source, nextKey)) {
                                        output[nextKey] = source[nextKey];
                                    }
                                }
                            }
                        }
                        return output;
                    }
                });
            }
        }
        
        // Initialize compatibility on page load
        ensureBrowserCompatibility();
        
        // --- PERFORMANCE MONITORING & LOGGING ---
        function logPerformanceMetrics() {
            if (window.performance && window.performance.timing) {
                const timing = window.performance.timing;
                const metrics = {
                    'DNS Lookup': timing.domainLookupEnd - timing.domainLookupStart,
                    'TCP Connection': timing.connectEnd - timing.connectStart,
                    'Request Time': timing.responseStart - timing.requestStart,
                    'Response Time': timing.responseEnd - timing.responseStart,
                    'DOM Interactive': timing.domInteractive - timing.navigationStart,
                    'DOM Complete': timing.domComplete - timing.navigationStart,
                    'Total Load Time': timing.loadEventEnd - timing.navigationStart
                };
                console.table(metrics);
            }
        }
        
        // Log metrics after page load
        window.addEventListener('load', logPerformanceMetrics);
        
        // --- ACCESSIBILITY ENHANCEMENTS ---
        function enhanceAccessibility() {
            // Add ARIA labels (mit Null-Guards)
            const searchEl = document.getElementById('searchInput');
            if (searchEl) searchEl.setAttribute('aria-label', 'Nachrichten durchsuchen');
            document.querySelectorAll('.filter-btn').forEach((btn) => {
                btn.setAttribute('aria-label', `Filter: ${btn.textContent.trim()}`);
            });

            // Zentrale Tastatursteuerung (erweitert)
            document.addEventListener('keydown', function(e) {
                const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);

                // Escape schließt Dialoge / Einzelansicht
                if (e.key === 'Escape') {
                    closeModal();
                    toggleShortcutHelp(false);
                    toggleSettings(false);   // Einstellungs-Panel ebenfalls schließen
                    return;
                }
                // Strg+K: Suche fokussieren
                if (e.ctrlKey && (e.key === 'k' || e.key === 'K')) {
                    e.preventDefault();
                    if (searchEl) searchEl.focus();
                    return;
                }
                // Ab hier: nur reagieren, wenn NICHT in einem Eingabefeld getippt wird
                if (typing) return;

                if (e.key === '?') { e.preventDefault(); toggleShortcutHelp(); }
                else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); loadLiveFeeds(); }
                else if (e.key === 't' || e.key === 'T') { e.preventDefault(); toggleTheme(); }
                else if (e.key === 'Home') { scrollToTop(); }
                else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); toggleCompactMode(); }
                else if (e.key === 's' || e.key === 'S') { e.preventDefault(); toggleSettings(); }
                // Pfeiltasten im Lesemodus: zum benachbarten Artikel blättern
                else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
                    const reader = document.querySelector('.reader[data-index]');
                    if (reader) {
                        const idx = parseInt(reader.getAttribute('data-index'), 10);
                        if (!isNaN(idx)) { e.preventDefault(); readerNav(idx, e.key === 'ArrowLeft' ? -1 : 1); }
                    }
                }
            });
        }

        // =====================================================================
        //  NEUE FEATURES: FAVORITEN · TEILEN · CSV-EXPORT · DESIGN · HILFE
        // =====================================================================

        // --- FAVORITEN (im localStorage persistiert) ---
        function loadFavorites() {
            try {
                const raw = localStorage.getItem('news_favorites');
                appState.favorites = raw ? JSON.parse(raw) : [];
                if (!Array.isArray(appState.favorites)) appState.favorites = [];
            } catch (e) {
                appState.favorites = [];
            }
        }

        function saveFavorites() {
            try {
                localStorage.setItem('news_favorites', JSON.stringify(appState.favorites));
            } catch (e) {
                console.warn('Favoriten konnten nicht gespeichert werden:', e);
            }
        }

        function isFavorite(id) {
            return appState.favorites.indexOf(id) !== -1;
        }

        function toggleFavorite(id, btnEl) {
            const idx = appState.favorites.indexOf(id);
            let nowFav;
            if (idx === -1) {
                appState.favorites.push(id);
                nowFav = true;
                showToast('Zu Favoriten hinzugefügt ★', 'success');
            } else {
                appState.favorites.splice(idx, 1);
                nowFav = false;
                showToast('Aus Favoriten entfernt', 'info');
            }
            saveFavorites();

            // Button-Zustand direkt aktualisieren (falls übergeben)
            if (btnEl) {
                btnEl.classList.toggle('favorited', nowFav);
                btnEl.textContent = nowFav ? '★' : '☆';
                btnEl.setAttribute('aria-pressed', String(nowFav));
            }
            // Wenn gerade der Favoriten-Filter aktiv ist, Ansicht neu aufbauen
            if (appState.currentFilter === 'favorites') {
                filterData();
            } else {
                // Alle sichtbaren Sterne dieses Artikels synchronisieren
                // Kompatibilität: CSS.escape gibt es nicht in sehr alten Browsern.
                const safeId = (window.CSS && CSS.escape) ? CSS.escape(id) : id;
                document.querySelectorAll(`.card[data-id="${safeId}"] .favorite-star`).forEach(b => {
                    b.classList.toggle('favorited', nowFav);
                    b.textContent = nowFav ? '★' : '☆';
                    b.setAttribute('aria-pressed', String(nowFav));
                });
            }
        }

        // --- TEILEN (Web Share API mit Zwischenablage-Fallback) ---
        function shareArticle(id) {
            const item = appState.allNews.find(n => n.id === id);
            if (!item) return;
            const shareData = {
                title: item.title,
                text: item.title + ' — via Nachrichten Kaipfstr',
                url: (item.link && item.link !== '#') ? item.link : location.href
            };
            if (navigator.share) {
                navigator.share(shareData).catch(() => { /* Nutzer hat abgebrochen */ });
            } else if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(shareData.url)
                    .then(() => showToast('Link in die Zwischenablage kopiert', 'success'))
                    .catch(() => showToast('Teilen wird nicht unterstützt', 'info'));
            } else {
                showToast('Teilen wird von diesem Browser nicht unterstützt', 'info');
            }
        }

        // ---------------------------------------------------------------------
        //  VORLESEN (Text-to-Speech über die native Web Speech API)
        //  Ideal beim Pendeln/Kochen: Artikel anhören statt lesen. Nutzt die im
        //  Browser bzw. Betriebssystem vorhandenen Stimmen – keine externe
        //  Verbindung, keine Datenübertragung, CSP-konform.
        //  Verfügbarkeit ist geräteabhängig; fehlt die Funktion, wird das
        //  ehrlich mitgeteilt statt so zu tun, als würde es klappen.
        // ---------------------------------------------------------------------
        let _speakingId = null;

        function speechSupported() {
            return typeof window !== 'undefined'
                && 'speechSynthesis' in window
                && typeof window.SpeechSynthesisUtterance === 'function';
        }

        function stopSpeaking(silent) {
            if (!speechSupported()) return;
            try { window.speechSynthesis.cancel(); } catch (e) {}
            _speakingId = null;
            updateSpeakButton(false);
            if (!silent) announce('Vorlesen beendet');
        }

        function updateSpeakButton(active) {
            const btn = document.getElementById('speak-btn');
            if (!btn) return;
            btn.textContent = active ? '⏹ Vorlesen stoppen' : '🔊 Vorlesen';
            btn.setAttribute('aria-pressed', String(active));
            btn.classList.toggle('active', active);
        }

        function speakArticle(id) {
            if (!speechSupported()) {
                showToast('Vorlesen wird von diesem Browser nicht unterstützt', 'info');
                return;
            }
            // Erneuter Klick auf denselben Artikel -> beenden (Umschalter)
            if (_speakingId === id) { stopSpeaking(); return; }

            const item = appState.allNews.find(n => n.id === id);
            if (!item) return;
            stopSpeaking(true);   // laufende Ausgabe zuerst sauber abbrechen

            // Reinen Text erzeugen: Titel, Quelle, dann der Fließtext.
            const bodyText = SecurityUtils.stripHtml(item.fullContent || item.description);
            const full = item.title + '. Quelle: ' + item.category + '. ' + bodyText;

            // Lange Texte in Sätze aufteilen. Hintergrund: Mehrere Browser brechen
            // sehr lange Äußerungen nach ~200-300 Zeichen ab. Stückweises Vorlesen
            // ist deutlich zuverlässiger.
            const chunks = full.match(/[^.!?]+[.!?]*\s*/g) || [full];
            const parts = [];
            let buffer = '';
            chunks.forEach(c => {
                if ((buffer + c).length > 200) { if (buffer) parts.push(buffer); buffer = c; }
                else { buffer += c; }
            });
            if (buffer) parts.push(buffer);

            _speakingId = id;
            updateSpeakButton(true);
            announce('Vorlesen gestartet');

            let index = 0;
            const speakNext = () => {
                if (_speakingId !== id || index >= parts.length) {
                    if (_speakingId === id) { _speakingId = null; updateSpeakButton(false); }
                    return;
                }
                const u = new SpeechSynthesisUtterance(parts[index++]);
                u.lang = 'de-DE';
                u.rate = 1.0;
                u.pitch = 1.0;
                // Deutsche Stimme bevorzugen, falls vorhanden
                try {
                    const voices = window.speechSynthesis.getVoices() || [];
                    const de = voices.find(v => /^de/i.test(v.lang));
                    if (de) u.voice = de;
                } catch (e) {}
                u.onend = speakNext;
                u.onerror = () => { _speakingId = null; updateSpeakButton(false); };
                try { window.speechSynthesis.speak(u); }
                catch (e) { _speakingId = null; updateSpeakButton(false); }
            };
            speakNext();
        }

        // Beim Verlassen der Seite oder Wechsel des Artikels Ausgabe stoppen,
        // damit nicht im Hintergrund weitergesprochen wird.
        window.addEventListener('beforeunload', function() { stopSpeaking(true); });
        window.addEventListener('pagehide', function() { stopSpeaking(true); });

        // Kopiert den PERMANENTEN Link einer Meldung (Quell-URL) in die Zwischenablage.
        function copyPermalink(id) {
            const item = appState.allNews.find(n => n.id === id);
            if (!item) return;
            const url = articlePermalink(item);
            const done = () => showToast('Permanenter Link kopiert', 'success');
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done));
            } else {
                fallbackCopy(url, done);
            }
        }
        // Fallback für Browser ohne Clipboard-API (älteres iOS/Safari)
        function fallbackCopy(text, cb) {
            try {
                const ta = document.createElement('textarea');
                ta.value = text;
                ta.style.position = 'fixed';
                ta.style.opacity = '0';
                document.body.appendChild(ta);
                ta.focus(); ta.select();
                document.execCommand('copy');
                document.body.removeChild(ta);
                if (cb) cb();
            } catch (e) {
                showToast('Kopieren nicht unterstützt', 'info');
            }
        }

        // --- CSV-EXPORT der aktuellen Ansicht ---
        // ---------------------------------------------------------------------
        //  RSS-EXPORT der aktuellen Ansicht
        //  Erzeugt eine gültige RSS-2.0-Datei aus den gerade gefilterten Meldungen
        //  und bietet sie zum Download an. Jeder Eintrag verweist auf die
        //  PERMANENTE Original-Quell-URL – es wird nichts erfunden oder umgeleitet.
        // ---------------------------------------------------------------------
        function exportNewsRSS() {
            const items = appState.filteredNews.slice(0, 200);
            if (!items.length) {
                showToast('Keine Meldungen zum Exportieren', 'info');
                return;
            }
            // XML-Sonderzeichen maskieren (eigene Funktion: hier gelten XML-Regeln)
            const xml = (s) => String(s === undefined || s === null ? '' : s)
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&apos;');

            const now = new Date().toUTCString();
            const scope = appState.currentFilter === 'all'
                ? 'Alle Quellen'
                : appState.currentFilter;

            let out = '<?xml version="1.0" encoding="UTF-8"?>\n';
            out += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">\n';
            out += '  <channel>\n';
            out += '    <title>Nachrichten Kaipfstr – ' + xml(scope) + '</title>\n';
            out += '    <link>https://nachrichten.kaipfstr.de/</link>\n';
            out += '    <description>Export der aktuellen Ansicht. Die Inhalte stammen aus '
                 + 'öffentlichen RSS-Feeds; alle Rechte verbleiben bei den jeweiligen Quellen.</description>\n';
            out += '    <language>de-DE</language>\n';
            out += '    <lastBuildDate>' + now + '</lastBuildDate>\n';
            out += '    <generator>Nachrichten Kaipfstr (clientseitiger Export)</generator>\n';
            out += '    <atom:link href="https://nachrichten.kaipfstr.de/feeds.opml" rel="related" type="text/x-opml"/>\n';

            items.forEach(item => {
                const link = articlePermalink(item);
                out += '    <item>\n';
                out += '      <title>' + xml(item.title) + '</title>\n';
                out += '      <link>' + xml(link) + '</link>\n';
                out += '      <guid isPermaLink="true">' + xml(link) + '</guid>\n';
                out += '      <description>' + xml(item.description) + '</description>\n';
                out += '      <pubDate>' + new Date(item.timestamp).toUTCString() + '</pubDate>\n';
                out += '      <category>' + xml(item.category) + '</category>\n';
                out += '      <dc:creator>' + xml(item.category) + '</dc:creator>\n';
                if (item.image) {
                    out += '      <enclosure url="' + xml(item.image) + '" type="image/jpeg"/>\n';
                }
                out += '    </item>\n';
            });

            out += '  </channel>\n</rss>\n';

            const blob = new Blob([out], { type: 'application/rss+xml;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'nachrichten_export.xml';
            a.click();
            setTimeout(() => URL.revokeObjectURL(url), 1000);
            showToast(items.length + ' Meldungen als RSS exportiert', 'success');
        }

        // Lädt die OPML-Abonnementliste aller Quellen (für Feedreader).
        function openFeedsOpml() {
            window.open('/feeds.opml', '_blank', 'noopener');
            showToast('OPML-Liste geöffnet – in den Feedreader importierbar', 'info');
        }

        function exportNewsCSV() {
            const rows = [['Titel', 'Quelle', 'Datum', 'Link', 'Beschreibung']];
            appState.filteredNews.forEach(item => {
                rows.push([
                    item.title,
                    item.category,
                    new Date(item.timestamp).toISOString(),
                    item.link,
                    item.description
                ]);
            });
            // CSV-sicher escapen (Anführungszeichen verdoppeln, in Quotes setzen)
            const csv = rows.map(r => r.map(cell => {
                const s = String(cell === undefined || cell === null ? '' : cell).replace(/"/g, '""');
                return `"${s}"`;
            }).join(';')).join('\r\n');
            // BOM für korrekte Umlaut-Darstellung in Excel
            const blob = new Blob(['﻿' + csv], { type: 'text/csv;charset=utf-8;' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'nachrichten_export.csv';
            a.click();
            setTimeout(() => URL.revokeObjectURL(url), 1000);
            showToast(`${appState.filteredNews.length} Meldungen als CSV exportiert`, 'success');
        }

        // --- HELL/DUNKEL-DESIGN (persistiert) ---
        function toggleTheme() {
            const isLight = document.body.classList.toggle('theme-light');
            try { localStorage.setItem('news_theme', isLight ? 'light' : 'dark'); } catch (e) {}
            const themeColor = document.querySelector('meta[name="theme-color"]');
            if (themeColor) themeColor.setAttribute('content', isLight ? '#f4f5f7' : '#0a0a0a');
            showToast(isLight ? 'Helles Design aktiv' : 'Dunkles Design aktiv', 'info');
        }

        function applySavedTheme() {
            try {
                if (localStorage.getItem('news_theme') === 'light') {
                    document.body.classList.add('theme-light');
                }
            } catch (e) {}
        }

        // --- TASTENKÜRZEL-HILFE ---
        function toggleShortcutHelp(forceState) {
            const el = document.getElementById('shortcut-help');
            if (!el) return;
            const show = (forceState === undefined) ? !el.classList.contains('open') : forceState;
            el.classList.toggle('open', show);
            el.setAttribute('aria-hidden', String(!show));
        }

        // Schließen der Hilfe bei Klick auf den Hintergrund
        (function initShortcutHelpBackdrop() {
            const el = document.getElementById('shortcut-help');
            if (el) el.addEventListener('click', function(e) { if (e.target === el) toggleShortcutHelp(false); });
        })();

        // --- ZUR STARTSEITE / NACH OBEN ---
        // BUGFIX (zwei Fehler in einer Funktion):
        //
        // 1) Die Adresse wurde nicht zurückgesetzt. `location.pathname` enthält
        //    NIEMALS ein "#", der Ausdruck `.replace(/#.*$/, '')` bewirkte also
        //    gar nichts: Von /quelle/tagesschau aus wurde erneut genau diese
        //    Adresse gesetzt. Angezeigt wurde die Startseite, in der Adresszeile
        //    stand aber weiter die Quellenseite – nach einem Neuladen erschien
        //    plötzlich wieder die gefilterte Ansicht. Inhalt und Adresse liefen
        //    auseinander, auch für Suchmaschinen (Canonical sagte "/", der
        //    Inhalt war gefiltert).
        //
        // 2) `appState.currentFilter = appState.currentFilter;` war wirkungslos
        //    (Zuweisung an sich selbst). Der Quellen-Filter blieb dadurch aktiv,
        //    obwohl updateMetaForHome() die Seite als Startseite auswies.
        //
        // Jetzt: Adresse wirklich auf "/" zurücksetzen, Filter und Zeitraum-
        // Auswahl sauber zurücksetzen, Schaltflächen mitziehen.
        function goHome() {
            // Sprachausgabe beenden, wenn der Lesemodus verlassen wird
            if (typeof stopSpeaking === 'function') stopSpeaking(true);

            if (location.protocol.startsWith('http')) {
                // Pfad UND Fragment entfernen -> echte Startseite
                history.pushState({}, '', '/');
            } else if (location.hash) {
                // Auf file:// nur das Fragment leeren
                history.replaceState({}, '', location.pathname);
            }

            // Filter tatsächlich zurücksetzen (vorher wirkungslose Zuweisung)
            appState.currentFilter = 'all';
            appState.renderLimit = PAGE_SIZE;
            document.querySelectorAll('.filter-btn').forEach(b => {
                b.classList.toggle('active', b.getAttribute('data-cat') === 'all');
            });

            const noRes = document.getElementById('no-results');
            if (noRes) noRes.style.display = 'none';
            const intro = document.getElementById('intro');
            if (intro) intro.style.display = '';

            filterData();          // wendet den zurückgesetzten Filter an
            updateMetaForHome();
            scrollToTop();
        }

        function scrollToTop() {
            window.scrollTo({ top: 0, behavior: 'smooth' });
        }

        // --- TOAST-BENACHRICHTIGUNGEN (mehrere Typen) ---
        function showToast(msg, type) {
            const el = document.getElementById('error-display');
            if (!el) return;
            el.textContent = msg;
            el.className = 'error-toast' + (type ? ' ' + type : '');
            el.style.display = 'block';
            clearTimeout(el._timer);
            el._timer = setTimeout(() => { el.style.display = 'none'; }, 3500);
        }

        // --- NACH-OBEN-BUTTON: Sichtbarkeit beim Scrollen steuern ---
        // Kombinierter Scroll-Handler: Nach-oben-Button + Lese-Fortschrittsbalken.
        // BUGFIX/PERFORMANCE: Beide Effekte teilen sich EINEN throttled Handler
        // (requestAnimationFrame), statt mehrere Scroll-Listener zu registrieren.
        // ---------------------------------------------------------------------
        //  PULL-TO-REFRESH (Touch-Geräte): Am Seitenanfang nach unten ziehen
        //  aktualisiert die Nachrichten – so, wie man es von Apps kennt.
        //  Greift NICHT, wenn: bereits geladen wird, ein Artikel gelesen wird,
        //  das Einstellungs-Panel offen ist oder die Seite nicht ganz oben steht.
        // ---------------------------------------------------------------------
        (function initPullToRefresh() {
            // Nur auf echten Touch-Geräten aktivieren (Maus-Nutzer brauchen es nicht)
            const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
            if (!isTouch) return;

            const indicator = document.getElementById('ptr-indicator');
            const spinner = document.getElementById('ptr-spinner');
            const textEl = document.getElementById('ptr-text');
            if (!indicator) return;

            const TRIGGER = 78;   // Pixel, ab denen ausgelöst wird
            const MAX_PULL = 120; // maximale sichtbare Zugstrecke
            let startY = 0, pulling = false, distance = 0, refreshing = false;

            function reset(instant) {
                indicator.style.transition = instant ? 'none' : 'transform 0.25s, opacity 0.25s';
                indicator.style.transform = 'translateX(-50%) translateY(-100%)';
                indicator.style.opacity = '0';
                if (spinner) spinner.classList.remove('spinning');
                pulling = false;
                distance = 0;
            }

            document.addEventListener('touchstart', function(e) {
                if (refreshing || e.touches.length !== 1) return;
                // Nur am absoluten Seitenanfang, nicht im Lesemodus/Panel
                const atTop = (window.scrollY || document.documentElement.scrollTop || 0) <= 0;
                const drawer = document.getElementById('settings-drawer');
                const drawerOpen = drawer && !drawer.hasAttribute('hidden');
                if (!atTop || drawerOpen || isReadingArticle()) return;
                startY = e.touches[0].clientY;
                pulling = true;
                distance = 0;
            }, { passive: true });

            document.addEventListener('touchmove', function(e) {
                if (!pulling || refreshing) return;
                const dy = e.touches[0].clientY - startY;
                if (dy <= 0) { reset(true); return; }
                // Gedämpfte Bewegung (fühlt sich natürlich an)
                distance = Math.min(MAX_PULL, dy * 0.55);
                indicator.style.transition = 'none';
                indicator.style.opacity = String(Math.min(1, distance / TRIGGER));
                indicator.style.transform =
                    'translateX(-50%) translateY(' + (distance - 46) + 'px)';
                if (spinner) spinner.textContent = distance >= TRIGGER ? '↻' : '↓';
                if (textEl) {
                    textEl.textContent = distance >= TRIGGER
                        ? 'Loslassen zum Aktualisieren'
                        : 'Zum Aktualisieren ziehen';
                }
            }, { passive: true });

            document.addEventListener('touchend', function() {
                if (!pulling || refreshing) { return; }
                if (distance >= TRIGGER) {
                    refreshing = true;
                    if (spinner) { spinner.textContent = '↻'; spinner.classList.add('spinning'); }
                    if (textEl) textEl.textContent = 'Wird aktualisiert …';
                    indicator.style.transition = 'transform 0.2s';
                    indicator.style.transform = 'translateX(-50%) translateY(8px)';
                    if (typeof announce === 'function') announce('Nachrichten werden aktualisiert');

                    Promise.resolve(loadLiveFeeds())
                        .catch(function() {})
                        .then(function() {
                            refreshing = false;
                            reset(false);
                        });
                } else {
                    reset(false);
                }
                pulling = false;
            }, { passive: true });
        })();

        (function initScrollEffects() {
            const btn = document.getElementById('back-to-top');
            const bar = document.getElementById('read-progress');
            let ticking = false;
            function onScroll() {
                if (ticking) return;
                ticking = true;
                window.requestAnimationFrame(function() {
                    const scrollTop = window.scrollY || document.documentElement.scrollTop || 0;
                    if (btn) btn.classList.toggle('visible', scrollTop > 400);
                    // „Neue Meldungen"-Hinweis ausblenden, sobald man wieder oben ist
                    if (scrollTop < 200) {
                        const nb = document.getElementById('new-items-banner');
                        if (nb && nb.classList.contains('visible') && !isReadingArticle()) {
                            nb.hidden = true; nb.classList.remove('visible');
                        }
                    }
                    if (bar) {
                        const docH = document.documentElement.scrollHeight - window.innerHeight;
                        const pct = docH > 0 ? Math.min(100, (scrollTop / docH) * 100) : 0;
                        bar.style.width = pct + '%';
                        bar.setAttribute('aria-valuenow', Math.round(pct));
                    }
                    ticking = false;
                });
            }
            window.addEventListener('scroll', onScroll, { passive: true });
            window.addEventListener('resize', onScroll, { passive: true });
            onScroll();
        })();

        // =====================================================================
        //  VERSIONSSTAND DER WEBSITE
        //  ------------------------------------------------------------------
        //  SITE_LAST_MODIFIED wird bei JEDER Bearbeitung dieser Datei von Hand
        //  aktualisiert. Damit lässt sich im Footer ablesen, ob der Browser
        //  wirklich die neueste Fassung anzeigt – oder noch eine ältere aus dem
        //  Cache bzw. vom Service Worker.
        //
        //  Faktenhinweis: Der Wert beschreibt den Bearbeitungsstand des
        //  Quelltextes, NICHT die Aktualität der Nachrichten. Wie frisch die
        //  Meldungen sind, steht separat unter „Zuletzt aktualisiert“ im Kopf.
        // =====================================================================
        const SITE_LAST_MODIFIED = '2026-07-28T13:08:42Z';

        function renderSiteVersion() {
            const timeEl = document.getElementById('sv-time');
            const ageEl = document.getElementById('sv-age');
            const cacheEl = document.getElementById('sv-cache');
            const d = new Date(SITE_LAST_MODIFIED);
            if (isNaN(d.getTime())) return;

            if (timeEl) {
                timeEl.setAttribute('datetime', SITE_LAST_MODIFIED);
                timeEl.textContent = d.toLocaleString('de-DE', {
                    day: '2-digit', month: '2-digit', year: 'numeric',
                    hour: '2-digit', minute: '2-digit'
                }) + ' Uhr';
            }
            if (ageEl) {
                // Wie alt ist der Quelltext-Stand? (rein rechnerisch aus dem Datum)
                ageEl.textContent = 'bearbeitet ' + timeAgo(d);
            }
            if (cacheEl) {
                // Wann wurde DIESER Seitenaufruf geladen? Weicht dieser Zeitpunkt
                // stark vom Stand ab, sieht man eine gecachte Fassung.
                cacheEl.textContent = 'geladen ' + new Date().toLocaleTimeString('de-DE', {
                    hour: '2-digit', minute: '2-digit', second: '2-digit'
                });
            }
            // Auch als Meta-Angabe hinterlegen (maschinenlesbar, für Suchmaschinen)
            updateMetaTag('last-modified', SITE_LAST_MODIFIED);
        }
        renderSiteVersion();

        // Zeitgeber starten, der abgelaufene Eilmeldungs-/NEU-Kennzeichnungen
        // automatisch wieder entfernt (siehe getHighlightState).
        startHighlightExpiryTimer();

        // Design & Schriftgröße vor dem ersten Rendern anwenden (verhindert Aufblitzen)
        applySavedTheme();
        applySavedFontSize();
        applySavedA11y();

        // Initialize accessibility
        enhanceAccessibility();

        // Initialize routing after app loads
        // Note: Routing is now initialized in the main DOMContentLoaded listener above

    </script>
</body>
</html>