feat: Scorecard Multi-Format (Cloud-Design ZIP-2) — 4 Layouts mit Switcher

User hat die zweite ZIP geliefert: 'Scorecard Formate.html' mit
Spec fuer drei zusaetzliche Formate. Plus Anmerkung: 'doppelte Borders'
und 'Export viel zu gross'.

Vier Formate jetzt im selben Template scorecard_portrait.html:
- format=portrait (DEFAULT) → 1080×1350 · 4:5 · IG-Feed
- format=square           → 1080×1080 · 1:1 · IG/LinkedIn
- format=story            → 1080×1920 · 9:16 · Story/Reels
- format=wide             → 1920×1080 · 16:9 · OG/Slide/Twitter

Wide hat 2-spaltigen Body-Aufbau (Story-Spalte links, Daten-Spalte
rechts, Header+Footer ueber volle Breite), die anderen drei nutzen
das gemeinsame 1-spaltige Body-Markup. Aller Formate teilen sich die
Daten-Aggregation (Chips, Fraktions-Bars, Beschluss).

Bug-Fixes aus dem User-Feedback:

1. 'Doppelte Borders um die Partei und Field-Chips' — die 1.5-px-
   Borders im Cloud-Design wurden von WeasyPrint als zwei einzelne
   1-px-Linien gerendert (Subpixel-Bug bei fractional border-widths).
   Alle 1.5px → 2px (integer).

2. 'Export viel zu gross' — der Download-Button hatte scale=2 als
   Default → 2160×2700 PNG-Pixel. Fuer IG-Upload reicht 1080×1350
   exakt (Instagram skaliert hochgeladene Bilder ohnehin). Default
   jetzt scale=1, der ?scale=2-Param bleibt verfuegbar fuer Retina.

3. Statusleiste mit Format-Switcher: vier Pills (4:5 Feed / 1:1 Square
   / 9:16 Story / 16:9 Wide), aktuelles Format hervorgehoben. Klick
   wechselt URL-format-Param. Plus PNG- und PDF-Download-Buttons,
   die das aktuelle Format mitfuehren.

main.py: dimensions-Mapping um story+wide erweitert in
scorecard_template UND _render_scorecard_pdf. Format-Validation
ebenfalls erweitert. format-Variable an's Template durchgeschleift
(damit der Template-Switch fuer card-portrait/square/story/wide
funktioniert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 14:29:24 +02:00
parent 443c9b0874
commit 4569f3335f
2 changed files with 423 additions and 220 deletions

View File

@ -3537,8 +3537,10 @@ async def scorecard_template(
"og": (1200, 630),
"square": (1080, 1080),
"portrait": (1080, 1350),
"story": (1080, 1920),
"wide": (1920, 1080),
}
width, height = dimensions.get(format, dimensions["og"])
width, height = dimensions.get(format, dimensions["portrait"])
# ─── Cloud-Design (portrait) braucht aggregierte Hilfsdaten ──────
# Chips: Top-3 positiv bewerteter Felder mit Code + Wert-Kurzname + Symbol
@ -3636,12 +3638,13 @@ async def scorecard_template(
score_color_band = "good" if score >= 7 else "mid" if score >= 4 else "low"
# Template-Wahl: portrait nutzt das Cloud-Design (eigene Datei),
# square/og bleiben beim alten Layout.
# Template-Wahl: portrait/square/story/wide nutzen das Cloud-Design
# (eine Template-Datei, format-Variable schaltet Card-Klasse).
# Nur og bleibt beim alten Layout (fuer OG-Meta-Tag-Crawler).
template_name = (
"v2/screens/scorecard_portrait.html"
if format == "portrait"
else "v2/screens/scorecard.html"
"v2/screens/scorecard.html"
if format == "og"
else "v2/screens/scorecard_portrait.html"
)
response = templates.TemplateResponse(template_name, {
@ -3659,6 +3662,7 @@ async def scorecard_template(
"beschluss": beschluss,
"antrag_typ": (row.get("typ") or "Antrag"),
"wahlperiode": wahlperiode,
"format": format,
"width": width,
"height": height,
})
@ -3678,9 +3682,15 @@ async def _render_scorecard_pdf(
from weasyprint import HTML, CSS
from .models import Assessment
if format not in ("og", "square", "portrait"):
raise HTTPException(status_code=400, detail="format muss 'og', 'square' oder 'portrait' sein")
dimensions = {"og": (1200, 630), "square": (1080, 1080), "portrait": (1080, 1350)}
if format not in ("og", "square", "portrait", "story", "wide"):
raise HTTPException(status_code=400, detail="format muss 'og', 'square', 'portrait', 'story' oder 'wide' sein")
dimensions = {
"og": (1200, 630),
"square": (1080, 1080),
"portrait": (1080, 1350),
"story": (1080, 1920),
"wide": (1920, 1080),
}
width, height = dimensions[format]
drucksache = validate_drucksache(drucksache)
@ -3703,10 +3713,10 @@ async def _render_scorecard_pdf(
elif score >= 5: score_color = "#bf6c10"
else: score_color = "#9a2a2a"
# Portrait nutzt das Cloud-Design-Template; square/og das alte.
# Fuer Portrait brauchen wir die gleichen Aggregat-Daten wie der
# HTML-Render (Chips, Fraktions-Bars, Beschluss).
if format == "portrait":
# Cloud-Design-Template fuer portrait/square/story/wide; og bleibt
# beim alten Layout. Cloud-Design braucht die gleichen Aggregat-
# Daten wie der HTML-Render (Chips, Fraktions-Bars, Beschluss).
if format in ("portrait", "square", "story", "wide"):
werte_kurz = {
"1": "Würde", "2": "Solidarität", "3": "Nachhaltigkeit",
"4": "Soz. Gerechtigkeit", "5": "Transparenz",
@ -3785,6 +3795,7 @@ async def _render_scorecard_pdf(
antrag_typ=(row.get("typ") or "Antrag"),
wahlperiode=wahlperiode,
score_color_band=score_color_band,
format=format, # damit das Template den Card-Klassen-Switch macht
width=width, height=height,
)
else:

View File

@ -1,14 +1,19 @@
{# scorecard_portrait.html — 1080×1350 Hochkant-Scorecard (Instagram 4:5)
{# scorecard_portrait.html — Cloud-Design Multi-Format-Scorecard.
Designvorgabe: Claude Design, ZIP "GWÖ Antrag Score Card.zip" vom 2026-05-07.
Übernommen 1:1 in HTML/CSS, Jinja-Variablen ersetzen die Beispiel-Inhalte.
Vier Formate mit eigenen Klassen + Größen, alle aus der Spec
"GWÖ Antrag Score Card-2.zip" (Claude Design, 2026-05-07):
Komprimierung gegenüber der Profi-Detail-Ansicht (siehe Spec-Sheet):
- 5×5-Matrix → Top-3 Schwerpunkt-Chips
- Zitate, Verbesserungen, Kommentare, News alle raus
- Stärken/Schwächen-Fließtext → 1-Satz-Zusammenfassung
- Fraktions-Bewertung als Balken-Grid (5 Spalten) statt langer Liste
- Beschluss als invertierte schwarze Bar am Ende
format=portrait (Default) → card-portrait → 1080×1350 · 4:5 · IG-Feed
format=square → card-square → 1080×1080 · 1:1 · IG/LinkedIn-Feed
format=story → card-story → 1080×1920 · 9:16 · Story/Reels
format=wide → card-wide → 1920×1080 · 16:9 · OG/Slide
Wide hat 2-spaltigen Body (Story-Spalte links, Daten-Spalte rechts);
die anderen drei haben 1-spaltigen Body (linear gestapelt).
WeasyPrint rendert nur was der format-Param sagt. Browser-Vorschau
skaliert per JS auf 90 % der Viewport-Hoehe (mit Width-Fallback) und
zeigt eine Statusleiste mit Format-Switcher und Download-Button.
#}
<!DOCTYPE html>
<html lang="de">
@ -24,30 +29,268 @@
--muted:#6b6660;
--accent:#0a5d3f;
--accent-warn:#b04a2f;
--grid:#d9d2c5;
--score-bg: {% if score_color_band == 'good' %}#0a5d3f{% elif score_color_band == 'mid' %}#6b6660{% else %}#b04a2f{% endif %};
--score-bg:#0a5d3f;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;}
/* Print/PDF-Defaults — WeasyPrint nutzt media=print und sieht NUR
diese Werte. Body == Card-Groesse, ohne Wrapper. */
body{
width:{{ width }}px;height:{{ height }}px;overflow:hidden;
}
.card-viewport { width: 100%; height: 100%; }
body{ width:{{ width }}px;height:{{ height }}px;overflow:hidden; }
/* ===== Generische Card-Bauelemente — Größen kommen je Format-Klasse ===== */
.card{
width:1080px;height:1350px;
background:var(--paper);color:var(--ink);
position:relative;overflow:hidden;
display:grid;grid-template-rows: 88px 1fr 96px;
}
.head{
border-bottom:2px solid var(--rule);
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;
font-weight:500;text-transform:uppercase;
}
.head .brand{display:flex;align-items:center;gap:12px}
.head .dot{background:var(--accent);border-radius:50%}
.head .meta{display:flex;color:var(--muted)}
.ids{font-family:'JetBrains Mono',monospace;color:var(--muted);letter-spacing:.06em}
.ids strong{color:var(--ink);font-weight:600}
.party-pill{
display:inline-flex;align-items:center;gap:10px;
border:2px solid var(--rule);border-radius:999px;
font-weight:700;letter-spacing:.04em;
}
.party-pill::before{content:"";border-radius:50%;background:#d44}
h1.title{font-weight:800;letter-spacing:-.025em;text-wrap:balance}
.lede{color:var(--ink);text-wrap:pretty}
.scorewrap{
display:grid;align-items:stretch;
border-top:2px solid var(--rule);border-bottom:2px solid var(--rule);
}
.score{
background:var(--score-bg);color:var(--paper);
display:flex;flex-direction:column;justify-content:center;align-items:center;
border-radius:8px;
}
.score .num{font-weight:800;line-height:1;letter-spacing:-.04em;font-variant-numeric:tabular-nums}
.score .num small{opacity:.7;font-weight:600}
.score .label{font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:.1em;opacity:.85}
.verdict{display:flex;flex-direction:column;justify-content:center}
.verdict .kicker{font-family:'JetBrains Mono',monospace;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
.verdict .vtext{font-weight:700;line-height:1.1;letter-spacing:-.01em}
.verdict .fields{display:flex;flex-wrap:wrap}
.field-chip{
font-family:'JetBrains Mono',monospace;font-weight:600;
border:2px solid var(--rule);border-radius:4px;background:transparent;
}
.fractions{display:grid;grid-template-columns:repeat({{ fraktionen_count }},1fr);border:2px solid var(--rule)}
.frac{
border-right:2px solid var(--rule);
display:flex;flex-direction:column;align-items:center;text-align:center;
background:var(--paper);
}
.frac:last-child{border-right:none}
.frac .name{font-family:'JetBrains Mono',monospace;font-weight:700;letter-spacing:.05em}
.frac .bar{width:100%;background:#e6dfd1;border-radius:2px;overflow:hidden}
.frac .bar > span{display:block;height:100%;background:var(--accent)}
.frac.weak .bar > span{background:var(--accent-warn)}
.frac .val{font-family:'JetBrains Mono',monospace;font-weight:600}
.frac .vote{font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.vote.ja{color:var(--accent)}
.vote.nein{color:var(--accent-warn)}
.vote.enth{color:var(--muted)}
.vote.unbekannt{color:var(--muted);opacity:.6}
.decision{
display:flex;align-items:center;justify-content:space-between;
background:var(--ink);color:var(--paper);border-radius:6px;
}
.decision .left{font-family:'JetBrains Mono',monospace;letter-spacing:.1em;text-transform:uppercase;opacity:.7}
.decision .right{font-weight:700}
.decision .right .x{color:#ff6a4a;margin-right:10px}
.decision .right .ok{color:#7fd9a8;margin-right:10px}
.foot{
border-top:2px solid var(--rule);
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;letter-spacing:.04em;color:var(--muted);
}
.foot .url{color:var(--ink);font-weight:600}
.foot .qr{
background:repeating-conic-gradient(var(--ink) 0 25%, var(--paper) 0 50%) 50%/16px 16px;
border:2px solid var(--ink);
}
/* Browser-Preview — skaliert die Card auf Viewport-Breite, scrollbar
wenn die Hoehe nicht reicht. WeasyPrint ignoriert @media screen.
Die eigentliche Skalierung wird per JS gesetzt (CSS calc/scale
mit Unit-Mismatch geht nicht), CSS-Defaults sorgen nur fuer das
Drumherum. */
/* ===== A · 4:5 Portrait — 1080×1350 (DEFAULT, IG-Feed) ===== */
.card-portrait{
width:1080px;height:1350px;
display:grid;grid-template-rows: 88px 1fr 96px;
}
.card-portrait .head{padding:0 56px;font-size:18px;letter-spacing:.04em}
.card-portrait .head .dot{width:14px;height:14px}
.card-portrait .head .meta{gap:24px}
.card-portrait .body{padding:40px 56px 32px;display:flex;flex-direction:column;gap:28px;min-height:0}
.card-portrait .topline{display:flex;justify-content:space-between;align-items:flex-start;gap:32px}
.card-portrait .ids{font-size:20px}
.card-portrait .party-pill{padding:8px 18px;font-size:20px}
.card-portrait .party-pill::before{width:12px;height:12px}
.card-portrait h1.title{font-size:78px;line-height:.95}
.card-portrait .lede{font-size:26px;line-height:1.35;max-width:880px}
.card-portrait .scorewrap{grid-template-columns:320px 1fr;gap:32px;padding:24px 0}
.card-portrait .score{padding:18px}
.card-portrait .score .num{font-size:120px}
.card-portrait .score .num small{font-size:42px}
.card-portrait .score .label{font-size:15px;margin-top:6px}
.card-portrait .verdict{gap:14px}
.card-portrait .verdict .kicker{font-size:15px}
.card-portrait .verdict .vtext{font-size:34px}
.card-portrait .verdict .fields{gap:8px;margin-top:4px}
.card-portrait .field-chip{font-size:16px;padding:4px 10px}
.card-portrait .frac{padding:16px 14px;gap:8px}
.card-portrait .frac .name{font-size:17px}
.card-portrait .frac .bar{height:8px}
.card-portrait .frac .val{font-size:18px}
.card-portrait .frac .vote{font-size:14px}
.card-portrait .decision{padding:18px 22px}
.card-portrait .decision .left{font-size:16px}
.card-portrait .decision .right{font-size:24px}
.card-portrait .foot{padding:0 56px;font-size:16px}
.card-portrait .foot .qr{width:64px;height:64px}
/* ===== B · 1:1 Square — 1080×1080 (IG/LinkedIn-Feed) ===== */
.card-square{
width:1080px;height:1080px;
display:grid;grid-template-rows:80px 1fr 80px;
}
.card-square .head{padding:0 48px;font-size:16px;letter-spacing:.04em}
.card-square .head .dot{width:13px;height:13px}
.card-square .head .meta{gap:20px}
.card-square .body{padding:32px 48px 24px;display:flex;flex-direction:column;gap:20px;min-height:0}
.card-square .topline{display:flex;justify-content:space-between;align-items:center;gap:24px}
.card-square .ids{font-size:17px}
.card-square .party-pill{padding:6px 16px;font-size:18px}
.card-square .party-pill::before{width:11px;height:11px}
.card-square h1.title{font-size:64px;line-height:.95}
.card-square .lede{font-size:21px;line-height:1.35;max-width:880px}
.card-square .scorewrap{grid-template-columns:280px 1fr;gap:24px;padding:18px 0}
.card-square .score{padding:14px}
.card-square .score .num{font-size:96px}
.card-square .score .num small{font-size:36px}
.card-square .score .label{font-size:13px;margin-top:4px}
.card-square .verdict{gap:10px}
.card-square .verdict .kicker{font-size:13px}
.card-square .verdict .vtext{font-size:28px}
.card-square .verdict .fields{gap:6px;margin-top:2px}
.card-square .field-chip{font-size:14px;padding:3px 9px}
.card-square .frac{padding:12px 10px;gap:6px}
.card-square .frac .name{font-size:15px}
.card-square .frac .bar{height:7px}
.card-square .frac .val{font-size:16px}
.card-square .frac .vote{font-size:12px}
.card-square .decision{padding:14px 18px}
.card-square .decision .left{font-size:14px}
.card-square .decision .right{font-size:21px}
.card-square .foot{padding:0 48px;font-size:14px}
.card-square .foot .qr{width:54px;height:54px}
/* ===== C · 9:16 Story — 1080×1920 (Story/Reels) ===== */
.card-story{
width:1080px;height:1920px;
display:grid;grid-template-rows:104px 1fr 112px;
}
.card-story .head{padding:0 64px;font-size:20px}
.card-story .head .dot{width:16px;height:16px}
.card-story .head .meta{gap:28px}
.card-story .body{padding:80px 64px 56px;display:flex;flex-direction:column;gap:44px;min-height:0}
.card-story .topline{display:flex;justify-content:space-between;align-items:flex-start;gap:32px}
.card-story .ids{font-size:24px}
.card-story .party-pill{padding:10px 22px;font-size:24px}
.card-story .party-pill::before{width:14px;height:14px}
.card-story h1.title{font-size:108px;line-height:.92}
.card-story .lede{font-size:34px;line-height:1.32;max-width:920px}
/* Story: Score-Block VERTIKAL gestapelt */
.card-story .scorewrap{grid-template-columns:1fr;gap:0;padding:32px 0}
.card-story .score{padding:36px 24px;border-radius:10px;margin-bottom:28px}
.card-story .score .num{font-size:200px}
.card-story .score .num small{font-size:64px}
.card-story .score .label{font-size:20px;margin-top:8px}
.card-story .verdict{gap:18px;text-align:center;align-items:center}
.card-story .verdict .kicker{font-size:20px}
.card-story .verdict .vtext{font-size:48px;text-align:center}
.card-story .verdict .fields{gap:10px;justify-content:center}
.card-story .field-chip{font-size:20px;padding:6px 14px}
.card-story .frac{padding:24px 14px;gap:12px}
.card-story .frac .name{font-size:22px}
.card-story .frac .bar{height:10px}
.card-story .frac .val{font-size:24px}
.card-story .frac .vote{font-size:18px}
.card-story .decision{padding:24px 28px}
.card-story .decision .left{font-size:20px}
.card-story .decision .right{font-size:30px}
.card-story .foot{padding:0 64px;font-size:18px}
.card-story .foot .qr{width:76px;height:76px}
/* ===== D · 16:9 Wide — 1920×1080 (OG/Slide/Twitter) ===== */
.card-wide{
width:1920px;height:1080px;
display:grid;
grid-template-columns:760px 1fr;
grid-template-rows:80px 1fr 80px;
grid-template-areas:
"head head"
"left right"
"foot foot";
}
.card-wide .head{
grid-area:head;
padding:0 56px;font-size:18px;letter-spacing:.04em;
}
.card-wide .head .dot{width:14px;height:14px}
.card-wide .head .meta{gap:24px}
.card-wide .left{
grid-area:left;
padding:48px 40px 40px 56px;
display:flex;flex-direction:column;gap:24px;
border-right:2px solid var(--rule);
}
.card-wide .topline{display:flex;justify-content:space-between;align-items:center;gap:24px}
.card-wide .ids{font-size:18px}
.card-wide .party-pill{padding:7px 16px;font-size:18px}
.card-wide .party-pill::before{width:11px;height:11px}
.card-wide h1.title{font-size:72px;line-height:.94}
.card-wide .lede{font-size:22px;line-height:1.35;max-width:660px}
.card-wide .scorewrap{
grid-template-columns:240px 1fr;gap:24px;padding:20px 0;
margin-top:auto;
}
.card-wide .score{padding:14px}
.card-wide .score .num{font-size:96px}
.card-wide .score .num small{font-size:36px}
.card-wide .score .label{font-size:13px;margin-top:4px}
.card-wide .verdict{gap:10px}
.card-wide .verdict .kicker{font-size:13px}
.card-wide .verdict .vtext{font-size:28px}
.card-wide .verdict .fields{gap:6px;margin-top:2px}
.card-wide .field-chip{font-size:14px;padding:3px 9px}
.card-wide .right{
grid-area:right;
padding:48px 56px 40px 48px;
display:flex;flex-direction:column;gap:28px;justify-content:space-between;
}
.card-wide .right .right-head{
font-family:'JetBrains Mono',monospace;
font-size:14px;letter-spacing:.12em;text-transform:uppercase;
color:var(--muted);
}
.card-wide .frac{padding:18px 14px;gap:10px}
.card-wide .frac .name{font-size:18px}
.card-wide .frac .bar{height:9px}
.card-wide .frac .val{font-size:20px}
.card-wide .frac .vote{font-size:14px}
.card-wide .decision{padding:18px 24px}
.card-wide .decision .left{font-size:15px}
.card-wide .decision .right{font-size:24px;font-weight:700}
.card-wide .foot{
grid-area:foot;
padding:0 56px;font-size:14px;
}
.card-wide .foot .qr{width:56px;height:56px}
/* ===== Browser-Preview — Skalierung + Toolbar ===== */
@media screen {
html, body {
background: #2a2724 !important;
@ -56,7 +299,7 @@
min-height: 100vh;
overflow: auto !important;
margin: 0;
padding: 20px 20px 80px; /* bottom-padding fuer die fixed Toolbar */
padding: 20px 20px 80px;
}
body {
display: flex;
@ -64,190 +307,133 @@
align-items: flex-start;
}
.card-viewport {
width: 1080px;
height: 1350px;
width: {{ width }}px;
height: {{ height }}px;
overflow: hidden;
position: relative;
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
background: var(--paper);
}
.card {
transform-origin: top left;
position: absolute;
top: 0; left: 0;
}
.card { transform-origin: top left; position: absolute; top: 0; left: 0; }
/* Statusleiste am unteren Viewport-Rand mit Download-Button.
Nur im Browser sichtbar; PDF bekommt sie nicht. */
.screen-toolbar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: var(--ink);
color: var(--paper);
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
z-index: 1000;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
flex-wrap: wrap;
background: var(--ink); color: var(--paper);
padding: 10px 20px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px; z-index: 1000; flex-wrap: wrap;
font-family: 'JetBrains Mono', monospace; font-size: 12px;
box-shadow: 0 -8px 24px rgba(0,0,0,0.3);
}
.screen-toolbar .label {
opacity: 0.75;
letter-spacing: 0.04em;
}
.screen-toolbar .label strong {
color: var(--paper);
font-weight: 600;
opacity: 1;
}
.screen-toolbar .actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.screen-download {
background: var(--accent);
color: var(--paper);
padding: 8px 18px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 700;
text-decoration: none;
border: none;
cursor: pointer;
letter-spacing: 0.04em;
.screen-toolbar .label { opacity: 0.75; letter-spacing: 0.04em; }
.screen-toolbar .label strong { color: var(--paper); font-weight: 600; opacity: 1; }
.screen-toolbar .group { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.format-pill {
padding: 6px 12px;
border-radius: 3px;
font-family: 'JetBrains Mono', monospace; font-size: 11px;
font-weight: 700; letter-spacing: 0.04em;
text-decoration: none; cursor: pointer;
background: transparent; color: var(--paper);
border: 2px solid rgba(245, 241, 234, 0.3);
text-transform: uppercase;
}
.screen-download:hover {
filter: brightness(1.1);
.format-pill:hover { background: rgba(245, 241, 234, 0.1); border-color: var(--paper); }
.format-pill.active { background: var(--paper); color: var(--ink); border-color: var(--paper); }
.screen-download {
background: var(--accent); color: var(--paper);
padding: 8px 18px; border-radius: 4px;
font-family: 'JetBrains Mono', monospace; font-size: 13px;
font-weight: 700; text-decoration: none; border: none; cursor: pointer;
letter-spacing: 0.04em; text-transform: uppercase;
}
.screen-download:hover { filter: brightness(1.1); }
.screen-download.secondary {
background: transparent;
color: var(--paper);
border: 1.5px solid rgba(245, 241, 234, 0.3);
background: transparent; color: var(--paper);
border: 2px solid rgba(245, 241, 234, 0.3);
}
.screen-download.secondary:hover {
background: rgba(245, 241, 234, 0.1);
border-color: var(--paper);
background: rgba(245, 241, 234, 0.1); border-color: var(--paper);
}
}
@media print { .screen-toolbar { display: none !important; } }
/* Kopfleiste */
.head{
border-bottom:2px solid var(--rule);
padding:0 56px;
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;
font-size:18px;font-weight:500;letter-spacing:.04em;
text-transform:uppercase;
}
.head .brand{display:flex;align-items:center;gap:14px}
.head .dot{width:14px;height:14px;background:var(--accent);border-radius:50%}
.head .meta{display:flex;gap:24px;color:var(--muted)}
/* Body */
.body{padding:40px 56px 32px;display:flex;flex-direction:column;gap:28px;min-height:0}
.topline{display:flex;justify-content:space-between;align-items:flex-start;gap:32px}
.ids{font-family:'JetBrains Mono',monospace;font-size:20px;color:var(--muted);letter-spacing:.06em}
.ids strong{color:var(--ink);font-weight:600}
.party-pill{
display:inline-flex;align-items:center;gap:10px;
padding:8px 18px;border:2px solid var(--rule);border-radius:999px;
font-weight:700;font-size:20px;letter-spacing:.04em;
}
.party-pill::before{content:"";width:12px;height:12px;border-radius:50%;background:#d44}
h1.title{
font-family:'Inter',sans-serif;
font-weight:800;font-size:78px;line-height:.95;
letter-spacing:-.025em;text-wrap:balance;
}
.lede{
font-size:26px;line-height:1.35;color:var(--ink);
max-width:880px;text-wrap:pretty;
}
.scorewrap{
display:grid;grid-template-columns: 320px 1fr;gap:32px;align-items:stretch;
border-top:2px solid var(--rule);border-bottom:2px solid var(--rule);
padding:24px 0;
}
.score{
background:var(--score-bg);color:var(--paper);
display:flex;flex-direction:column;justify-content:center;align-items:center;
padding:18px;border-radius:8px;
}
.score .num{font-size:120px;font-weight:800;line-height:1;letter-spacing:-.04em;font-variant-numeric:tabular-nums}
.score .num small{font-size:42px;opacity:.7;font-weight:600}
.score .label{font-family:'JetBrains Mono',monospace;font-size:15px;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;opacity:.85}
.verdict{display:flex;flex-direction:column;justify-content:center;gap:14px}
.verdict .kicker{font-family:'JetBrains Mono',monospace;font-size:15px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
.verdict .vtext{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.01em}
.verdict .fields{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.field-chip{
font-family:'JetBrains Mono',monospace;font-size:16px;font-weight:600;
border:1.5px solid var(--rule);padding:4px 10px;border-radius:4px;background:transparent;
}
/* Fraktionen Grid */
.fractions{display:grid;grid-template-columns:repeat({{ fraktionen_count }},1fr);gap:0;border:1.5px solid var(--rule)}
.frac{
padding:16px 14px;border-right:1.5px solid var(--rule);
display:flex;flex-direction:column;gap:8px;align-items:center;text-align:center;
background:var(--paper);
}
.frac:last-child{border-right:none}
.frac .name{font-family:'JetBrains Mono',monospace;font-size:17px;font-weight:700;letter-spacing:.05em}
.frac .bar{height:8px;width:100%;background:#e6dfd1;border-radius:2px;overflow:hidden}
.frac .bar > span{display:block;height:100%;background:var(--accent)}
.frac.weak .bar > span{background:var(--accent-warn)}
.frac .val{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:600}
.frac .vote{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.vote.ja{color:var(--accent)}
.vote.nein{color:var(--accent-warn)}
.vote.enth{color:var(--muted)}
.vote.unbekannt{color:var(--muted);opacity:.6}
/* Beschluss */
.decision{
display:flex;align-items:center;justify-content:space-between;
padding:18px 22px;background:var(--ink);color:var(--paper);
border-radius:6px;
}
.decision .left{font-family:'JetBrains Mono',monospace;font-size:16px;letter-spacing:.1em;text-transform:uppercase;opacity:.7}
.decision .right{font-size:24px;font-weight:700}
.decision .right .x{color:#ff6a4a;margin-right:10px}
.decision .right .ok{color:#7fd9a8;margin-right:10px}
/* Footer */
.foot{
border-top:2px solid var(--rule);
padding:0 56px;
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;font-size:16px;letter-spacing:.04em;
color:var(--muted);
}
.foot .url{color:var(--ink);font-weight:600}
.foot .qr{width:64px;height:64px;background:
repeating-conic-gradient(var(--ink) 0 25%, var(--paper) 0 50%) 50%/16px 16px;
border:2px solid var(--ink);
}
</style>
</head>
<body>
<div class="card-viewport">
<div class="card">
{# ===== Wide-Variante hat eigene Body-Struktur (2-spaltig) ===== #}
{% if format == 'wide' %}
<div class="card card-wide">
<header class="head">
<div class="brand"><span class="dot"></span>GWÖ-Antragsprüfer · Matrix 2.0</div>
<div class="meta">
<span>{{ bundesland }}{% if wahlperiode %} · {{ wahlperiode.split('-')[-1] if '-' in wahlperiode else 'WP' ~ wahlperiode }}{% endif %}</span>
<span>Drs. {{ assessment.drucksache }}</span>
{% if assessment.datum %}<span>{{ assessment.datum }}</span>{% endif %}
</div>
</header>
<section class="left">
<div class="topline">
<div class="ids"><strong>{{ antrag_typ or 'Antrag' }}</strong>{% if assessment.datum %} · eingebracht {{ assessment.datum }}{% endif %}</div>
{% if assessment.fraktionen %}<div class="party-pill">{{ assessment.fraktionen[0] }}</div>{% endif %}
</div>
<h1 class="title">{{ assessment.title|truncate(80, end="…") }}</h1>
{% if assessment.antrag_zusammenfassung %}
<p class="lede">{{ assessment.antrag_zusammenfassung|truncate(180, end="…") }}</p>
{% endif %}
<div class="scorewrap">
<div class="score">
<div class="num">{{ "%.1f"|format(assessment.gwoe_score) }}<small>/10</small></div>
<div class="label">GWÖ-Score</div>
</div>
<div class="verdict">
<div class="kicker">Empfehlung</div>
<div class="vtext">{{ assessment.empfehlung.value }}</div>
{% if matrix_chips %}
<div class="fields">
{% for chip in matrix_chips %}<span class="field-chip">{{ chip.code }} {{ chip.symbol }}</span>{% endfor %}
</div>
{% endif %}
</div>
</div>
</section>
<section class="right">
<div class="right-head">Programm-Treue · Fraktionen · Abstimmung</div>
{% if fraktionen_bars %}
<div class="fractions">
{% for f in fraktionen_bars %}
<div class="frac{% if f.weak %} weak{% endif %}">
<div class="name">{{ f.name }}</div>
<div class="bar"><span style="width:{{ f.bar_pct }}%"></span></div>
<div class="val">{{ f.score_text }}</div>
<div class="vote {{ f.vote_class }}">{{ f.vote_label }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if beschluss %}
<div class="decision">
<div class="left">Beschluss Plenum</div>
<div class="right">
<span class="{{ 'ok' if beschluss.is_positive else 'x' }}">{{ '✓' if beschluss.is_positive else '✗' }}</span>
{{ beschluss.text }}
</div>
</div>
{% endif %}
</section>
<footer class="foot">
<div><span class="url">gwoe.toppyr.de</span> · /antrag/{{ assessment.drucksache }}</div>
<div>CC BY 4.0 · Matrix 2.0</div>
<div class="qr" aria-hidden="true"></div>
</footer>
</div>
{% else %}
{# ===== Portrait / Square / Story — gemeinsame 1-spaltige Body-Struktur ===== #}
{% set card_cls = 'card-' + (format if format in ['portrait','square','story'] else 'portrait') %}
<div class="card {{ card_cls }}">
<header class="head">
<div class="brand"><span class="dot"></span>GWÖ-Antragsprüfer</div>
<div class="meta">
@ -255,14 +441,12 @@
<span>{{ bundesland }}{% if wahlperiode %} · {{ wahlperiode.split('-')[-1] if '-' in wahlperiode else 'WP' ~ wahlperiode }}{% endif %}</span>
</div>
</header>
<main class="body">
<div class="topline">
<div class="ids">
<strong>Drs. {{ assessment.drucksache }}</strong>
{% if antrag_typ %} · {{ antrag_typ }}{% endif %}
{% if assessment.datum %} · eingebracht {{ assessment.datum }}{% endif %}
{% if assessment.datum %} · {{ assessment.datum }}{% endif %}
</div>
{% if assessment.fraktionen %}
<div class="party-pill">{{ assessment.fraktionen[0] }}</div>
@ -315,58 +499,66 @@
</div>
</div>
{% endif %}
</main>
<footer class="foot">
<div><span class="url">gwoe.toppyr.de</span> · /antrag/{{ assessment.drucksache }}</div>
<div>CC BY 4.0</div>
<div class="qr" aria-hidden="true"></div>
</footer>
</div>
{% endif %}
</div>{# .card-viewport #}
{# Statusleiste mit Download-Button (nur im Browser, nicht im PDF). #}
{# ===== Statusleiste — Format-Switcher + Download-Buttons ===== #}
{% set _drs_safe = assessment.drucksache | replace('/', '-') %}
{% set _query_base = '?drucksache=' + (assessment.drucksache | urlencode) + '&bundesland=' + (bundesland | urlencode) %}
<div class="screen-toolbar">
<div class="label">
<strong>Scorecard</strong> · {{ bundesland }} · {{ assessment.drucksache }}
· 1080×1350 (Instagram 4:5)
· {{ width }}×{{ height }}
</div>
<div class="actions">
<div class="group">
{% set _formats = [
('portrait', '4:5 Feed'),
('square', '1:1 Square'),
('story', '9:16 Story'),
('wide', '16:9 Wide'),
] %}
{% for fkey, flabel in _formats %}
<a class="format-pill {% if format == fkey %}active{% endif %}"
href="/v2/scorecard{{ _query_base }}&format={{ fkey }}">{{ flabel }}</a>
{% endfor %}
</div>
<div class="group">
<a class="screen-download"
href="/api/assessment/scorecard.png?drucksache={{ assessment.drucksache | urlencode }}&bundesland={{ bundesland | urlencode }}&format=portrait&scale=2"
download="gwoe-scorecard-{{ _drs_safe }}.png">
⬇ PNG herunterladen
href="/api/assessment/scorecard.png{{ _query_base }}&format={{ format }}&scale=1"
download="gwoe-scorecard-{{ _drs_safe }}-{{ format }}.png">
⬇ PNG
</a>
<a class="screen-download secondary"
href="/api/assessment/scorecard.pdf?drucksache={{ assessment.drucksache | urlencode }}&bundesland={{ bundesland | urlencode }}&format=portrait"
download="gwoe-scorecard-{{ _drs_safe }}.pdf">
href="/api/assessment/scorecard.pdf{{ _query_base }}&format={{ format }}"
download="gwoe-scorecard-{{ _drs_safe }}-{{ format }}.pdf">
PDF
</a>
</div>
</div>
<script>
/* Browser-Preview-Skalierung: Card soll 90 % der Viewport-Hoehe einnehmen.
Falls die daraus resultierende Breite ueber den Viewport hinausginge
(schmale Mobile-Viewports), greift die Breiten-Begrenzung. WeasyPrint
fuehrt kein JS aus → PDF bleibt bei skalierungsfreier Originalgroesse. */
/* Browser-Preview-Skalierung — 90% Viewport-Hoehe, mit Width-Fallback. */
(function () {
var W = {{ width }}, H = {{ height }};
function adjustScale() {
var card = document.querySelector('.card');
var viewport = document.querySelector('.card-viewport');
if (!card || !viewport) return;
var pad = 40;
var availW = window.innerWidth - pad;
var availH = window.innerHeight - pad;
var scaleByHeight = (window.innerHeight * 0.9) / 1350;
var scaleByWidth = availW / 1080;
var scale = Math.min(scaleByHeight, scaleByWidth);
if (scale > 1) scale = 1; // nie ueber 100% hochskalieren
var scaleByHeight = (window.innerHeight * 0.9) / H;
var scaleByWidth = availW / W;
var scale = Math.min(scaleByHeight, scaleByWidth, 1);
card.style.transform = 'scale(' + scale + ')';
viewport.style.width = (1080 * scale) + 'px';
viewport.style.height = (1350 * scale) + 'px';
viewport.style.width = (W * scale) + 'px';
viewport.style.height = (H * scale) + 'px';
}
adjustScale();
window.addEventListener('resize', adjustScale);