Browser-Cache zeigte alte v2.css ohne v2-menu-toggle-display:none-Regel.
Mit ?v=1.0.0 wird auf Versionsspruenge sauber neu geladen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Klick öffnet /api/auth/forgot-password → 302 zur Keycloak-Reset-Page mit
client_id + redirect_uri (auf eigene Domain). Keycloak schickt Mail mit
Reset-Link, User setzt neues Passwort, kommt zurück.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beide Routes liefern jetzt all_canonical_keys() (ohne Landesregierung) als Dropdown-
Optionen. Verhindert Tippfehler und gibt nur tatsaechlich erkannte Parteien zur Auswahl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend (Filter sind seit jeher da):
- /api/feed.xml?bundesland=&partei=&limit=
- /api/subscriptions GET/POST/DELETE
UI:
- /v2/feed: Form mit BL/Partei/Limit, generiert Feed-URL live, Buttons Oeffnen/
URL-Kopieren/In-Feedly. Default-BL aus Header-Selektor uebernommen
- /v2/abos: Liste eigener Abos + Form zum Anlegen/Loeschen, BL-Dropdown,
Partei-Freitext, Frequenz daily/weekly
- Sidebar 'Daten'-Gruppe um beide Eintraege erweitert (statt Direkt-Link auf
/api/feed.xml)
- Beide Routen mit Depends(require_auth) — Anonyme bekommen 401-Redirect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: '— Pruefen' + '— Daten'-Labels waren sichtbar, aber alle Eintraege darin
hidden — nur ein verlorener Header. Jetzt: ganzer Gruppen-Container hinter
{% if is_authenticated %} → Anonymous-User sieht nur 'Lesen'-Gruppe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: Button-Text 'Übersprungen', der Grund nur als Tooltip — User versteht
nicht warum. Jetzt: 'Nicht abstimmbar' + sichtbare Italic-Begruendung unter der
Zeile mit dem konkreten Reason-Text vom Server (Backend liefert reason, typ
und typ_normiert).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: filterte stale-Jobs raus, bei leerer aktiver Queue display:none → User sah nichts.
Jetzt: immer sichtbar mit 'Queue leer · N Worker bereit' wenn nichts aktiv.
Tooltip zeigt Stale-Jobs als 'letzter Lauf'-Liste, wenn keine aktiven Jobs da sind.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser-PDF-Reader (Chrome, Firefox) ignorieren das von /OpenAction-Eintrag im
PDF-Catalog (#88f9c7d) komplett. Der zuverlaessige Weg: URL-Hash-Anker '#page=N'.
Drei Stellen angepasst:
- redline_utils.build_pdf_href: haengt #page={seite} an die URL
- embeddings._build_zitat_url (rebind): analog
- v2/components/quote_card.html: bei alten DB-Eintraegen ohne Hash wird er
on-the-fly aus dem 'seite='-Query-Param erzeugt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundesland-Auswahl:
- Topbar: einziger BL-Selektor mit localStorage.gwoe.bl-Persistenz
- BL-Felder entfernt aus durchsuchen.html, landtag_suche.html, neu.html, auswertungen.html
- Screens hoeren auf v2-bl-changed CustomEvent + initial via window.v2GetGlobalBl()
Sichtbarkeit (Sidebar):
- Durchsuchen + Tags: immer
- Merkliste / Neuer Antrag / Landtag-Suche / Auswertungen / Export / Feed: nur eingeloggt
- Cluster + Batch-Analyse + Administration: nur Admin
Server-Side Schutz:
- _v2_template_context()-Helper liefert is_authenticated, is_admin, v2_bundeslaender
- HTML-Routen mit Depends(require_auth) bzw. require_admin
- 401/403-Browser-Requests redirecten auf /?login=1 statt JSON-Error
Queue-Widget (#149):
- Neues Component-Partial v2/components/queue_widget.html
- Statusbar unten links + Hover-Tooltip mit den letzten 20 Jobs
- 5s-Polling auf /api/queue/status, blendet sich aus wenn keine Jobs
Smoke-Test angepasst an neue Auth-Erwartungen (302 fuer auth-protected Routen).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vorher: Klick 'Analysieren' -> POST /api/analyze-drucksache -> sofort
window.location.href = '/antrag/{ds}' -> aber Job laeuft noch im Hintergrund
-> Detail-Seite zeigt 'Antrag nicht gefunden'.
Jetzt:
- already_checked -> sofortiger Redirect
- skipped (nicht abstimmbar) -> Hinweistext im Form
- queued -> Polling auf /status/{job_id} alle 2s, max 3 Min
- completed -> Redirect zur Detail-Seite
- failed/rejected -> Fehlermeldung mit Grund
Anwendung in v2/screens/landtag_suche.html + v2/screens/neu.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registrierung:
- POST /api/auth/register: erstellt User in Keycloak mit enabled=false
- GET /api/auth/pending-users: Liste nicht-freigeschalteter User (Admin)
- POST /api/auth/approve-user: User freischalten (Admin)
- Registrierungs-Dialog im Hamburger-Menü
- Admin: "Freischaltungen"-Button (nur sichtbar mit admin-Rolle)
Matrix:
- Zeilen-Header klickbar → Erklärung der Berührungsgruppe mit
konkretem Lebensalltag-Beispiel
- Spalten-Header klickbar → Erklärung des Werts mit Staatsprinzip
- Feld-Erklärungen: 25 konkrete Bürger:innen-Texte (Schule, Bus,
Miete, Steuer, Spielplatz...)
- Spalten nummeriert: "1. Menschenwürde" etc.
Neue Issues angelegt:
#104 Zeitreihe, #105 Clustering, #106 Abstimmungsverhalten,
#107 Vergleichsansicht, #108 Empfehlungen, #109 Share-Buttons
Klick auf jedes Matrix-Feld öffnet ein Modal mit:
- Feld-Code + voller Name (z.B. "D4: Soziale Gestaltung")
- Zeile + Spalte in Klartext
- "Was bedeutet das für Bürger:innen?" Erklärung (25 Texte)
- Falls bewertet: Aspekt aus der LLM-Analyse + Rating-Farbe
- Falls nicht bewertet: "Dieses Feld wird vom Antrag nicht berührt"
Spaltenüberschriften: "1. Menschenwürde" statt nur "Menschenwürde"
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
reconstruct_zitate droppt Zitate nicht mehr bei No-Match, sondern
markiert sie als verified=false. Das ist ehrlicher: paraphrasierte
Zitate sind wertvoller Kontext, sie brauchen nur ein visuelles
Unterscheidungsmerkmal.
UI:
- Verifizierte Zitate: grüner solid Border, "✓ verifiziert"
- Paraphrasierte Zitate: gelber dashed Border, "~ paraphrasiert
(nicht wörtlich im Programm)"
- Warning-Text: "Zu diesem Themenkomplex konnten keine konkreten
Formulierungen im Wahlprogramm gefunden werden"
- Antragsteller:in / Landesregierung als farbige Badges
Zitat-Model: neues Optional[bool] Feld "verified".
Tests: 206 passed (test_drops angepasst auf neues Verhalten).
Fußzeile unter jedem Assessment-Detail jetzt mit:
- Bewertungsdatum ("Bewertet am DD.MM.YYYY") aus updated_at
- Quelle + Modell (batch-reanalyze / webapp, qwen-plus)
- "Neu bewerten"-Button (Auth-pflichtig, ausgegraut ohne Login)
Flow: Klick → DELETE /api/assessment/delete → POST /api/analyze-drucksache
→ Queue → pollAnalysis → Detail neu laden
Neuer DELETE-Endpoint /api/assessment/delete mit require_auth.
API-Response erweitert um updatedAt, source, model für beide
Endpoints (list + single assessment).
Tests: 206 passed.
Refs: #97
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
User-Feedback: "Kontext geht verloren wenn nur 1 Seite kommt".
Änderung: render_highlighted_page liefert jetzt das GESAMTE Wahlprogramm-
PDF mit gelber Highlight-Annotation auf der Fundstelle, statt eines
1-Seiten-Auszugs. Der Browser öffnet das vollständige Programm.
Frontend hängt #page=N an die URL → Browser scrollt direkt zur
Fundstelle. found_page wird als X-Found-Page Header mitgeliefert,
falls der Text auf einer anderen Seite als angefordert gefunden wurde
(Pre-#60 halluzinierte Seitennummern).
Return-Typ geändert: (bytes, int) statt bytes — zweiter Wert ist die
1-indexed Seitennummer wo der Treffer tatsächlich liegt.
Tests angepasst: Tuple-Unpacking, Size-Check entfernt (volles PDF ist
größer als 1-Seiten-Extract, der alte Vergleich war obsolet).
Refs: #47
Problem: Alle Assessments in der Prod-DB haben Pre-#47-URLs
(/static/referenzen/X.pdf#page=N). Die _chunk_pdf_url-Änderung wirkt
nur auf NEUE Analysen, die noch nicht stattgefunden haben.
Fix (zwei Seiten):
1. Endpoint /api/wahlprogramm-cite akzeptiert jetzt auch pdf=<filename>
als Alternative zu pid=<programm_id>. Reverse-Lookup über PROGRAMME-
Registry: pdf-Filename → programm_id. Damit können die statischen
URLs aus Pre-#47-Assessments trotzdem an den Cite-Endpoint geleitet
werden.
2. Frontend: neue JS-Funktion makeCiteUrl(z) die JEDE Zitat-URL on-the-
fly umschreibt:
- /static/referenzen/X.pdf#page=N + z.text
→ /api/wahlprogramm-cite?pdf=X.pdf&seite=N&q=<urlencoded text>
- /api/wahlprogramm-cite?... → durchreichen (schon Cite-URL)
- Fallback: URL unverändert
Funktioniert retroaktiv für ALLE ~31 Assessments in der DB, ohne
Re-Analyse. Sobald ein User auf ein Zitat klickt, wird die Seite
des Wahlprogramms mit gelber Markierung gerendert.
Tests: 194/194 grün.
Refs: #47
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
Sachsen-Adapter (#26/#38) ist Eigensystem mit ASP.NET-Webforms-Postbacks
(__VIEWSTATE/__CALLBACKID, siehe bundeslaender.py:343-348) und braucht
HAR-Aufnahme → Blocker für autonome Bearbeitung. Phase E entsprechend
substituiert mit der Frontend-Erweiterung der Auswertungen.
- Matrix-Zellen sind jetzt klickbar (`cell-with-data`-Klasse +
hover-outline mit Blue-Border)
- Klick öffnet ein Modal, das `/api/auswertungen/zeitreihe?
bundesland=...&partei=...` aufruft und die Score-Entwicklung dieser
(BL, Partei)-Kombination über alle bekannten WPs als Tabelle rendert
- ESC-Taste oder Backdrop-Klick schließt das Modal
- Schließt damit den Frontend-Loop für die in Phase C gebauten
Backend-Endpoints
(CLAUDE.md-Sync separat — die Datei liegt im Projekt-Root außerhalb
des Webapp-Git-Repos.)
Refs: #59 (Phase E substituted)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schließt #16 (UI: zwei klar getrennte Suchen) und ergänzt den Header
um den Link auf das neue Auswertungen-Dashboard aus Phase C.
- Search-Row in `index.html` aufgespalten in zwei untereinanderliegende
Inputs: oben "Suche in geprüften Anträgen (DB)" mit Live-Debouncing
(wie bisher), unten "Im Landtag suchen (live)" mit Enter-Trigger und
expliziter Such-Button. Beide Felder schreiben in dieselbe Liste,
sind aber visuell und semantisch klar getrennt.
- `searchLandtag()` zieht jetzt aus `landtag-search-input` statt aus
dem DB-Suchfeld
- `changeBundesland()` resettet zusätzlich das Landtag-Feld
- Header: neuer `📈 Auswertungen`-Link neben `📚 Quellen`
Refs: #16, #59 (Phase D)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.
Backend (`app/auswertungen.py`):
- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
(n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
(deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
NICHT mit dem RP-FW zusammengezählt
Wahlperioden-Helper (`app/wahlperioden.py`):
- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns
Endpoints in `app/main.py`:
- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download
Frontend (`app/templates/auswertungen.html`):
- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling
Tests (`tests/test_auswertungen.py`):
- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)
Refs: #58, #45, #59 (Phase C)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves#8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Body becomes a flex column so the header takes its natural height and the
main container fills the rest via flex:1 — replaces the brittle
calc(100vh - 70px) that assumed a 70px header and broke as soon as the
header wrapped on mobile. Adds 100dvh fallback for iOS Safari address
bar quirks.
Mobile breakpoint (≤900px) reworked: list scrolls internally via
list-content max-height:50vh, detail-panel uses overflow:visible so the
whole document scrolls naturally instead of nesting scrollers. Tapping
an item auto-scrolls to the detail panel and a new "← Zur Liste" button
(mobile-only) jumps back. Adds a tighter ≤600px breakpoint that hides
the subtitle, collapses the matrix grid to one column and shrinks the
matrix table for phone screens.
Resolves issue #6.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>