Vogelalbum 1.0.0

Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
This commit is contained in:
christian committed 2026-10-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

+781
View File
@@ -0,0 +1,781 @@
/* Vogelalbum — eigenständiges Stylesheet, kein Build-Schritt.
*
* Zwei Modi über Farbtokens: jede Farbe existiert einmal hell und einmal
* dunkel, umgeschaltet wird nur die Zuordnung. Deshalb steht unten kein
* einziger Farbwert mehr direkt in einer Regel.
*
* Die Akzentfarbe kommt vom Typ der Art, nicht global — dadurch fühlt sich
* jede Artseite anders an. Die typabhängigen Töne werden in
* dex/species_meta.py vorberechnet und als Inline-Variablen mitgegeben, in
* beiden Modi (Endung -l und -d). Die Indirektion weiter unten wählt aus.
*
* Keine modernen Funktionen ohne Not: kein color-mix (Safari ab 16.2), kein
* aspect-ratio (Safari ab 15). Beides fällt auf älteren Geräten ersatzlos weg
* und reißt das Layout auseinander — Bildverhältnisse laufen über padding-top.
*/
/* Die Palette kommt aus der Marke: tiefes Grün und Creme. Vorher war das
* Grün der Oberfläche ein anderes als das des Zeichens — nebeneinander sah
* man den Unterschied sofort, und die Seite wirkte kühl und beliebig.
* Papierweiß statt Blauweiß, damit Fotos von Vögeln warm wirken. */
:root {
--bg: #fbf8ef;
--bg-grad: #f4eddd;
--surface: #ffffff;
--surface-2: #f3ece0;
--line: #e7dfcc;
--text: #14281c;
--text-2: #5c6b60;
--muted: #7a8a7f;
--accent: #0f5f3c;
--accent-2: #0a3f28;
--accent-wash: #e6f0e9;
--accent-on: #fcf2d3;
/* Der Kopf ist dunkel, damit die Seite nicht als weißes Blatt beginnt. */
--kopf-bg: #0a3f28;
--kopf-text: #fcf2d3;
--kopf-text-2: #a8ceb8;
--kopf-an: rgba(255,255,255,.15);
--kopf-linie: rgba(255,255,255,.22);
--gold: #cf9a1c;
--gold-wash: #fbf3dc;
--gold-line: #ecd9a0;
--shadow: 0 1px 2px rgba(22,33,26,.05), 0 8px 20px -12px rgba(22,33,26,.18);
--shadow-lift: 0 2px 4px rgba(22,33,26,.07), 0 16px 32px -16px rgba(22,33,26,.26);
--nr-bg: rgba(255,255,255,.92);
--head-bg: rgba(255,255,255,.86);
--locked-opacity: .30;
--r-sm: 10px;
--r: 16px;
--r-lg: 24px;
--font: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--typ: #7f9b6a;
--typ-ink: #ffffff;
}
/* Typabhängige Töne werden je Modus direkt am Verwendungsort gewählt.
Der naheliegende Umweg über eine Variable auf :root funktioniert nicht:
var() in einer Custom-Property-Deklaration wird auf dem deklarierenden
Element ersetzt, und dort gibt es die -l/-d-Werte der Karte nicht. */
[data-theme="dark"] {
/* Kühles Blaugrau statt Dunkelgrün: neutral genug, dass 16 Typfarben
darauf nebeneinander funktionieren, ohne sich mit dem Grund zu beißen. */
--bg: #10151b;
--bg-grad: #141b23;
--surface: #1a2129;
--surface-2: #222b35;
--line: #2e3945;
--text: #e8eef4;
--text-2: #b3c1cd;
--muted: #7e8f9e;
--accent: #45c98a;
--accent-2: #2f9d68;
--accent-wash: #163024;
--accent-on: #08150d;
--gold: #e8be5a;
--gold-wash: #2a2415;
--gold-line: #4a4026;
/* Auf dunklem Grund trägt nicht der Schatten, sondern die Kante. */
--shadow: 0 1px 2px rgba(0,0,0,.4);
--shadow-lift: 0 10px 30px -14px rgba(0,0,0,.75);
--nr-bg: rgba(10,14,19,.82);
--head-bg: rgba(16,21,27,.86);
--locked-opacity: .26;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: linear-gradient(180deg, var(--bg-grad) 0%, var(--bg) 420px);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; }
/* ⚠ padding-inline, nicht die Kurzform ``padding``. Der Kopf traegt
``class="wrap top-inner"``, und ``.top-inner`` setzt den senkrechten
Abstand (12px). Eine Kurzform hier setzt den auf 0 zurueck — genau das
ist auf Telefonen passiert: Marke und Navigation klebten oben und unten
an der Kante der gruenen Leiste. */
.wrap { max-width: 1120px; margin: 0 auto; padding-inline: 20px; }
/* ---------- Kopf ---------- */
/* 🔑 Der Kopf ist dunkel, nicht durchscheinend hell. Die Seite begann vorher
als weisses Blatt und wirkte dadurch unfertig; eine dunkle Leiste gibt ihr
oben einen Halt und laesst die Fotos darunter waermer wirken. */
header.top {
position: sticky; top: 0; z-index: 30;
background: var(--kopf-bg);
color: var(--kopf-text);
}
.top-inner { display: flex; align-items: center; gap: 14px; padding: 12px 0; flex-wrap: wrap; }
.brand { font-weight: 800; font-size: 21px; letter-spacing: -0.03em; display: flex; align-items: center; gap: 10px; color: var(--kopf-text); }
/* 🔑 Die Wortmarke ist EIN Flex-Kind, nicht zwei.
Vorher standen "Vogel" und "album" getrennt im Flex-Container und bekamen
dessen gap von 10 px zwischen sich — bei "Bird dex" war das gewollt, bei
einem deutschen Kompositum liest es sich wie ein Tippfehler. Jetzt trennt
der Abstand nur noch Zeichen und Wort; die Farbe wechselt mitten im Wort. */
.brand .wort { color: var(--kopf-text); }
.brand .wort span { color: #7fd3a4; }
/* ⚠ Das Zeichen traegt dunkelgruene Flaechen. Direkt auf der dunklen Leiste
verschwinden die, und uebrig bleibt ein cremefarbener Klecks — die Marke
war an der wichtigsten Stelle nicht mehr zu erkennen. Deshalb eine helle
Scheibe darunter: das Zeichen selbst bleibt unveraendert, es bekommt nur
einen Traeger. Nebenbei nimmt die runde Form die Abzeichen-Pins auf. */
.brand .scheibe {
width: 40px; height: 40px; flex: 0 0 40px;
border-radius: 50%; background: var(--kopf-text);
display: grid; place-items: center;
}
.brand img { display: block; width: 31px; height: 31px; }
nav.main { display: flex; gap: 3px; margin-left: auto; flex-wrap: wrap; }
nav.main a {
padding: 8px 15px; border-radius: 999px;
font-size: 14px; font-weight: 600; color: var(--text-2);
transition: background .15s, color .15s;
}
nav.main a:hover { background: var(--surface-2); color: var(--text); }
nav.main a.active { background: var(--accent); color: var(--accent-on); }
/* ---------- Bedienelemente auf der dunklen Leiste ----------
Alles im Kopf steht jetzt auf Dunkelgruen. Die Regeln davor rechneten mit
hellem Grund; ohne diesen Block waere die Navigation dunkelgrau auf
dunkelgruen, also praktisch unsichtbar. Gesammelt an einer Stelle, damit
beim naechsten Farbwechsel klar ist, was zusammengehoert. */
header.top nav.main a { color: var(--kopf-text-2); }
header.top nav.main a:hover { background: rgba(255,255,255,.10); color: var(--kopf-text); }
header.top nav.main a.active { background: var(--kopf-an); color: var(--kopf-text); }
header.top .live-knopf {
background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2);
}
header.top .live-knopf:hover { color: var(--kopf-text); border-color: var(--kopf-text); }
header.top .lang-switch { background: transparent; border-color: var(--kopf-linie); }
header.top .lang-switch button { color: var(--kopf-text-2); }
header.top .lang-switch button:hover { color: var(--kopf-text); }
header.top .lang-switch button.on { background: var(--kopf-text); color: var(--kopf-bg); }
header.top .theme-toggle, header.top .gear {
background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2);
}
header.top .theme-toggle:hover, header.top .gear:hover {
border-color: var(--kopf-text); color: var(--kopf-text);
}
/* Der Live-Knopf. Kein Navigationspunkt, sondern ein Hinweis, dass gerade
etwas zu sehen ist — der Punkt pulst, solange die Seite offen ist. Rot ist
hier die richtige Farbe: sie heisst "laeuft jetzt", nicht "Fehler". */
.live-knopf {
flex: none; display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 700; text-decoration: none;
color: var(--text-2); background: var(--surface);
border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px 6px 10px;
transition: background .15s, color .15s, border-color .15s;
}
.live-knopf:hover { color: var(--text); border-color: var(--text-2); }
.live-knopf.on { background: #d6303a; border-color: #d6303a; color: #fff; }
.live-knopf .punkt {
width: 8px; height: 8px; border-radius: 50%; background: #d6303a;
animation: live-puls 2s ease-in-out infinite;
}
.live-knopf.on .punkt { background: #fff; }
@keyframes live-puls {
0%, 100% { opacity: 1; }
50% { opacity: .35; }
}
.theme-toggle {
width: 38px; height: 38px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 50%;
background: var(--surface); color: var(--text-2);
font-size: 17px; line-height: 1; cursor: pointer;
display: grid; place-items: center;
transition: border-color .15s, color .15s;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle .icon-dark { display: none; }
[data-theme="dark"] .theme-toggle .icon-dark { display: block; }
[data-theme="dark"] .theme-toggle .icon-light { display: none; }
/* ---------- Fortschritt ---------- */
.progress-bar {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 18px 22px; margin: 22px 0;
border-radius: var(--r-lg);
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.level-badge {
width: 60px; height: 60px; border-radius: 50%;
display: grid; place-items: center; flex-shrink: 0;
font-weight: 800; font-size: 24px; color: var(--accent-on);
background: linear-gradient(145deg, var(--accent), var(--accent-2));
box-shadow: 0 0 0 5px var(--accent-wash);
}
.xp-track { height: 11px; border-radius: 999px; overflow: hidden; background: var(--surface-2); }
.xp-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
transition: width .6s cubic-bezier(.2,.8,.3,1);
}
.rank { font-weight: 750; font-size: 18px; letter-spacing: -0.015em; }
.sub { color: var(--muted); font-size: 13px; }
/* ---------- Kennzahlen ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px,1fr)); gap: 12px; }
.stat {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 17px 19px; box-shadow: var(--shadow);
}
.stat .n { font-size: 30px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; color: var(--accent-2); }
.stat .l { color: var(--muted); font-size: 13px; margin-top: 6px; }
h2 {
font-size: 21px; margin: 36px 0 15px; letter-spacing: -0.025em;
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
}
h2 .count { color: var(--muted); font-weight: 500; font-size: 14px; letter-spacing: 0; }
/* ---------- Sammelkarten ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 16px; }
.card {
position: relative;
border-radius: var(--r); overflow: hidden;
display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .18s, border-color .18s;
}
.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lift); border-color: var(--typ-line-l, var(--line)); }
[data-theme="dark"] .card:hover { border-color: var(--typ-line-d, var(--line)); }
/* Farbband oben — die Karte bekennt ihren Typ, bevor man den Namen liest. */
.card::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px;
background: var(--typ); z-index: 3;
}
.card .nr {
position: absolute; top: 12px; left: 11px; z-index: 2;
font-size: 11px; font-weight: 800; letter-spacing: .05em;
padding: 3px 8px; border-radius: 999px;
background: var(--nr-bg); color: var(--text-2);
}
.card .thumb {
position: relative; width: 100%;
padding-top: 75%; /* 4:3, funktioniert ohne aspect-ratio */
background: var(--typ-wash-l, var(--surface-2));
overflow: hidden;
}
[data-theme="dark"] .card .thumb { background: var(--typ-wash-d, var(--surface-2)); }
.card .thumb img {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover; display: block;
}
/* Nicht gesammelt: entfärbt und blass. Die Form ist zu ahnen, die
Federzeichnung nicht — der Reiz des Freischaltens bleibt, ohne dass die
Karte tot wirkt. */
/* ⚠ Hier stand der Typton der Art als Grund. Gemeint war ein Hauch Farbe,
wirksam war ein Flickenteppich: nebeneinander lagen graue, sepiafarbene,
rosa und violette Kacheln, und das sah nach Zufall aus statt nach System.
Verschlossen ist ein Zustand, kein Thema — also fuer alle derselbe
neutrale Grund. Die Typfarbe bleibt sichtbar, wo sie etwas bedeutet:
im Band oben und in der Pille unter dem Namen. */
.card.locked .thumb { background: var(--surface-2); }
[data-theme="dark"] .card.locked .thumb { background: var(--surface-2); }
.card.locked .thumb img { filter: grayscale(1) contrast(.85); opacity: var(--locked-opacity); }
.card.locked .thumb::after {
content: "?"; position: absolute;
right: 12px; bottom: 6px;
font-size: 34px; font-weight: 800; line-height: 1;
color: var(--typ); opacity: .6;
}
.card.locked .name { color: var(--text-2); }
.card .body { padding: 12px 14px 14px; }
.card .name { font-weight: 700; font-size: 15.5px; line-height: 1.25; letter-spacing: -0.015em; }
.card .meta { display: flex; align-items: center; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
.card .times { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 700; }
/* ---------- Badges ---------- */
.pill {
font-size: 11px; font-weight: 800; letter-spacing: .03em;
padding: 4px 10px; border-radius: 999px;
color: var(--typ-ink); background: var(--typ);
white-space: nowrap;
}
.pill.ghost { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--line); }
.pill.rar { color: #fff; }
.tag-new {
background: var(--gold); color: #fff;
font-size: 10px; font-weight: 800; letter-spacing: .06em;
padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
}
/* ---------- Artseite ---------- */
.species-hero {
position: relative; overflow: hidden;
border-radius: var(--r-lg); margin: 18px 0 8px;
background: var(--surface); border: 1px solid var(--line);
box-shadow: var(--shadow);
}
/* Kräftiges Typband am oberen Rand statt einer flächigen Einfärbung — das
gibt der Seite ihre Identität, ohne den Text zu bedrängen. */
.species-hero::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px;
background: var(--typ);
}
.hero-grid {
display: grid; grid-template-columns: minmax(0,1.05fr) minmax(0,1fr);
gap: 26px; padding: 28px 26px 26px;
}
@media (max-width: 950px) { .hero-grid { grid-template-columns: 1fr; padding: 22px 18px 18px; } }
.hero-photo {
position: relative; width: 100%; padding-top: 75%;
border-radius: var(--r); overflow: hidden;
background: var(--typ-wash-l, var(--surface-2)); border: 1px solid var(--typ-line-l, var(--line));
}
[data-theme="dark"] .hero-photo { background: var(--typ-wash-d, var(--surface-2)); border-color: var(--typ-line-d, var(--line)); }
.hero-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.dexnr { font-size: 13px; font-weight: 800; letter-spacing: .12em; color: var(--typ-text-l, var(--muted)); }
[data-theme="dark"] .dexnr { color: var(--typ-text-d, var(--muted)); }
.species-hero h1 { margin: 4px 0 2px; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.04em; line-height: 1.02; }
.kategorie { font-size: 16px; font-weight: 700; color: var(--typ-text-l, var(--text-2)); margin-bottom: 5px; }
[data-theme="dark"] .kategorie { color: var(--typ-text-d, var(--text-2)); }
.sci { color: var(--muted); font-style: italic; font-size: 14px; }
.badge-row { display: flex; gap: 7px; flex-wrap: wrap; margin: 16px 0; }
/* Statuswerte als Balken — die Geste jeder Sammelkarte. */
.werte { display: grid; gap: 9px; margin-top: 18px; }
.wert { display: grid; grid-template-columns: 84px 1fr auto; align-items: center; gap: 11px; }
.wert .k { font-size: 12px; font-weight: 700; color: var(--muted); }
.wert .track { height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.wert .fill { height: 100%; border-radius: 999px; background: var(--typ); }
.wert .v { font-size: 12.5px; font-weight: 750; font-variant-numeric: tabular-nums; min-width: 62px; text-align: right; }
/* ---------- Fließtext und Steckbrief ---------- */
.prose { max-width: 68ch; }
.prose p { line-height: 1.7; margin: 0 0 16px; font-size: 16px; color: var(--text-2); }
.prose p:first-of-type::first-letter {
font-size: 3.1em; float: left; line-height: .82;
padding: 5px 10px 0 0; font-weight: 800; color: var(--typ-text-l, var(--text-2));
}
[data-theme="dark"] .prose p:first-of-type::first-letter { color: var(--typ-text-d, var(--text-2)); }
.fakten { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 12px; margin: 18px 0; }
.fakt {
background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--typ);
border-radius: var(--r); padding: 14px 16px; box-shadow: var(--shadow);
}
.fakt .k { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.fakt .v { margin-top: 5px; line-height: 1.5; font-size: 14.5px; color: var(--text-2); }
.verwechslung { display: flex; gap: 12px; flex-wrap: wrap; }
.vw {
display: flex; align-items: center; gap: 11px;
background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 6px 18px 6px 6px;
box-shadow: var(--shadow);
transition: transform .15s, box-shadow .15s;
}
.vw:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); }
.vw img { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; background: var(--surface-2); }
.vw .n { font-weight: 650; font-size: 14.5px; }
/* ---------- Sichtungen ---------- */
.rows { display: flex; flex-direction: column; gap: 9px; }
.row {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 10px 16px 10px 10px;
box-shadow: var(--shadow); transition: box-shadow .15s;
}
.row:hover { box-shadow: var(--shadow-lift); }
.row img.shot, .row .shot {
width: 74px; height: 56px; object-fit: cover;
border-radius: var(--r-sm); background: var(--surface-2); flex-shrink: 0;
}
.row .who { font-weight: 680; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.row .when { color: var(--muted); font-size: 13px; margin-top: 2px; }
.row .right { margin-left: auto; text-align: right; flex-shrink: 0; }
.row .conf { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 15px; }
.row .xp { color: var(--accent-2); font-size: 13px; font-weight: 800; }
/* ---------- Abzeichen ---------- */
.badges { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: 13px; }
.badge {
display: flex; gap: 14px; align-items: flex-start;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); padding: 16px 18px; box-shadow: var(--shadow);
}
.badge.locked { opacity: .55; box-shadow: none; background: var(--surface-2); }
/* Der Pin ist ein Bild, das Emoji ein Zeichen — beide landen in .em, deshalb
gilt die Graustufe fuer beides. Fehlt der Pin, faellt die Vorlage aufs
Emoji zurueck und die Karte sieht trotzdem richtig aus. */
.badge .em { font-size: 30px; line-height: 1; filter: grayscale(1); }
.badge img.em {
width: 64px; height: 64px; flex: 0 0 64px; object-fit: contain;
/* blass, solange es nicht verdient ist: man soll sehen, was es zu holen
gibt, ohne dass es wie eingeloest aussieht. */
opacity: .45;
}
.badge:not(.locked) .em { filter: none; }
.badge:not(.locked) img.em {
opacity: 1;
filter: drop-shadow(0 3px 6px rgba(20,40,28,.28));
}
.badge:not(.locked) { border-color: var(--gold-line); background: var(--gold-wash); }
.badge .n { font-weight: 750; font-size: 15.5px; }
.badge .d { color: var(--muted); font-size: 13.5px; margin-top: 4px; line-height: 1.45; }
/* ---------- Aktivität über den Tag ---------- */
.hours { display: flex; align-items: flex-end; gap: 3px; height: 74px; margin-top: 12px; }
.hours div { flex: 1; min-height: 3px; border-radius: 3px 3px 0 0; background: var(--typ); }
.hours-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; margin-top: 6px; }
/* ---------- Kleinteile ---------- */
.empty {
border: 1px dashed var(--line); border-radius: var(--r-lg);
padding: 42px 24px; text-align: center; color: var(--muted);
line-height: 1.6; background: var(--surface);
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.filters a {
padding: 7px 14px; border-radius: 999px; font-size: 13px; font-weight: 650;
border: 1px solid var(--line); background: var(--surface); color: var(--text-2);
transition: background .15s, color .15s, border-color .15s;
}
.filters a:hover { border-color: var(--muted); }
.filters a.on { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
.pager { display: flex; gap: 10px; justify-content: center; margin: 28px 0; }
.pager a {
padding: 9px 20px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); font-size: 14px; font-weight: 650; color: var(--text-2);
}
.pager a:hover { border-color: var(--accent); color: var(--accent); }
.blaettern { display: flex; justify-content: space-between; gap: 12px; margin: 20px 0 4px; }
.blaettern a { color: var(--muted); font-size: 14px; font-weight: 650; }
.blaettern a:hover { color: var(--text); }
footer.bottom {
border-top: 1px solid var(--line); margin-top: 60px; padding: 24px 0 32px;
color: var(--muted); font-size: 13px; line-height: 1.6;
}
/* Der Werbeblock im Fuss.
Hier unten ist der richtige Ort: Wer so weit gescrollt hat, hat fremde
Vogelfotos gesehen und verstanden, was das Ding tut. Erst dann traegt der
Satz "kannst du auch haben". Deshalb ein Angebot mit Knopf statt einer
Fussnote — die leise Fassung stand hier einen Commit lang und war genau
das Gegenteil dessen, was die Seite leisten soll. */
footer.bottom .werbung {
display: flex; align-items: center; justify-content: space-between;
gap: 28px; flex-wrap: wrap;
background: var(--accent-wash); border: 1px solid var(--line);
border-radius: 16px; padding: 22px 26px;
/* Kein margin-bottom mehr: Unter dem Block kommt nichts, seit die zweite
Fusszeile weg ist. Der Abstand nach unten gehoert dem footer selbst. */
}
footer.bottom .werbung-text { flex: 1 1 340px; }
footer.bottom .werbung-haken {
margin: 0 0 4px; font-size: 19px; font-weight: 800;
letter-spacing: -0.02em; color: var(--text);
}
footer.bottom .werbung-satz { margin: 0; font-size: 14px; color: var(--text-2); max-width: 54ch; }
footer.bottom .werbung-knopf {
flex: none; display: inline-flex; align-items: center; gap: 8px;
background: var(--accent); color: var(--accent-on);
font-size: 15px; font-weight: 700; text-decoration: none;
border-radius: 999px; padding: 12px 20px;
transition: background .15s, transform .15s;
}
footer.bottom .werbung-knopf:hover { background: var(--accent-2); transform: translateY(-1px); }
footer.bottom .werbung-knopf svg { transition: transform .15s; }
footer.bottom .werbung-knopf:hover svg { transform: translateX(3px); }
/* Auf dem Telefon untereinander, der Knopf ueber die volle Breite — ein
Knopf, den man mit dem Daumen trifft, ist mehr wert als einer, der
huebsch rechts sitzt. */
@media (max-width: 560px) {
footer.bottom .werbung { padding: 20px; gap: 18px; }
footer.bottom .werbung-knopf { width: 100%; justify-content: center; }
}
/* Das Zahnrad zum Admin — gleiche Form wie der Modus-Umschalter, weil es
dieselbe Sorte Knopf ist: eine Einstellung, kein Navigationspunkt. Oben bei
den Bedienelementen versteht man es ohne Beschriftung; im Footer neben
einem Fliesstext nicht, das war der erste Versuch. */
.gear {
width: 38px; height: 38px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 50%;
background: var(--surface); color: var(--text-2);
font-size: 17px; line-height: 1;
display: grid; place-items: center;
transition: border-color .15s, color .15s;
}
.gear:hover { border-color: var(--accent); color: var(--accent); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
/* ---------- Schmale Geräte ---------- */
/* Der Kopf bricht um, sobald Marke, Live-Knopf, Navigation, Sprache und
Umschalter nicht mehr in eine Zeile passen. Gemessen, nicht geschätzt: bei
810 px (iPad hochkant) rutschte sonst der Umschalter allein in Zeile zwei —
einer der Gründe, warum Layoutarbeit in vier Breiten angesehen wird.
Marke und die kleinen Knöpfe bleiben oben, die Navigation nimmt die Zeile
darunter. */
@media (max-width: 1000px) {
.brand { order: 0; flex: 1 1 auto; }
.live-knopf, .lang-switch, .theme-toggle, .gear { order: 1; }
nav.main { order: 2; flex: 1 0 100%; margin-left: 0; }
}
@media (max-width: 560px) {
/* Fünf Punkte passen auf einem Telefon nicht mehr in eine Zeile, und einer
allein in der zweiten sieht nach Versehen aus. Also seitlich wischen
statt umbrechen — die Zeile bleibt eine Zeile, egal wie viele Punkte
dazukommen oder wie lang die Sprache sie macht ("Sightings" gegen
"Sichtungen"). Die Leiste wird bündig am Rand gescrollt, damit man ihr
ansieht, dass da noch etwas kommt. */
nav.main {
order: 2; flex: 1 0 100%; margin-left: 0;
flex-wrap: nowrap; justify-content: flex-start;
overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
/* Der Wisch-Rand gehört in die Leiste, nicht neben sie. */
margin-inline: -14px; padding-inline: 14px;
}
nav.main::-webkit-scrollbar { display: none; }
nav.main { gap: 1px; }
nav.main a { padding: 7px 8px; font-size: 12.5px; white-space: nowrap; flex: none; }
.wrap { padding-inline: 14px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 12px; }
/* Auf 360-px-Geräten ergeben Marke, Live, Sprache und Umschalter zusammen
380 px und der Umschalter faellt in eine dritte Zeile. Gemessen, nicht
geraten: 154 + 68 + 90 + 38 plus Abstaende gegen 332 px Platz, und das
Zahnrad kommt noch dazu. Also alles eine Stufe kompakter — und vom
Sprachumschalter bleibt nur die Sprache, in die man wechseln kann: welche
gerade aktiv ist, sieht man am Text der Seite. */
.top-inner { gap: 8px; }
.brand { font-size: 19px; }
.live-knopf { font-size: 12px; padding: 5px 10px 5px 8px; gap: 5px; }
.theme-toggle, .gear { width: 34px; height: 34px; font-size: 15px; }
.lang-switch button.on { display: none; }
.progress-bar { padding: 16px; gap: 14px; }
.species-hero h1 { font-size: 30px; }
.wert { grid-template-columns: 74px 1fr auto; gap: 8px; }
.fakten { grid-template-columns: 1fr; }
}
/* ---------- Sprachumschalter ---------- */
.lang-switch {
display: flex; gap: 2px; flex-shrink: 0;
border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); padding: 2px;
}
.lang-switch button {
border: 0; background: transparent; cursor: pointer;
padding: 5px 11px; border-radius: 999px;
font: inherit; font-size: 12px; font-weight: 800; letter-spacing: .04em;
color: var(--muted);
transition: background .15s, color .15s;
}
.lang-switch button:hover { color: var(--text); }
.lang-switch button.on { background: var(--accent); color: var(--accent-on); }
/* ---------- Filterleiste ---------- */
.filterleiste {
display: flex; flex-direction: column; gap: 12px;
padding: 14px 16px; margin-bottom: 18px;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); box-shadow: var(--shadow);
}
.filtergruppe { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.filterlabel {
font-size: 11px; font-weight: 800; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted);
flex-shrink: 0;
}
.filterleiste .pills { display: flex; gap: 7px; flex-wrap: wrap; }
.filterleiste .pills a {
padding: 6px 13px; border-radius: 999px;
font-size: 13px; font-weight: 650;
border: 1px solid var(--line); background: var(--surface); color: var(--text-2);
transition: border-color .15s, background .15s, color .15s;
}
.filterleiste .pills a b { font-weight: 800; opacity: .55; margin-left: 2px; }
.filterleiste .pills a:hover { border-color: var(--muted); }
.filterleiste .pills a.on {
background: var(--accent); color: var(--accent-on); border-color: var(--accent);
}
.filterleiste .pills a.on b { opacity: .8; }
/* Die Verfeinerung sitzt optisch unter der Hauptachse, nicht daneben. */
.verfeinern { border-top: 1px solid var(--line-soft, var(--line)); padding-top: 12px; }
.verfeinern label { display: flex; align-items: center; gap: 8px; }
.verfeinern select {
font: inherit; font-size: 13.5px; font-weight: 600;
padding: 7px 30px 7px 12px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
cursor: pointer;
/* Eigener Pfeil: das native Dreieck sieht in jedem Browser anders aus. */
-webkit-appearance: none; -moz-appearance: none; appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 16px) 52%, calc(100% - 11px) 52%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.verfeinern select:hover { border-color: var(--muted); }
.verfeinern select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.verfeinern button {
font: inherit; font-size: 13.5px; font-weight: 650;
padding: 7px 16px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--accent-on);
}
.zuruecksetzen {
font-size: 13px; font-weight: 650; color: var(--muted);
text-decoration: underline; text-underline-offset: 3px;
}
.zuruecksetzen:hover { color: var(--text); }
@media (max-width: 560px) {
.filterleiste { padding: 12px; gap: 10px; }
.filtergruppe { gap: 8px; }
/* Label über die Zeile, damit die Pillen die volle Breite bekommen. */
.filtergruppe > .filterlabel { flex: 1 0 100%; }
.filterleiste .pills a { padding: 6px 11px; font-size: 12.5px; }
.verfeinern { flex-wrap: wrap; }
.verfeinern label { flex: 1 1 46%; }
.verfeinern label .filterlabel { display: none; }
.verfeinern select { width: 100%; }
}
/* ---------- Blickfang der Startseite ----------
Die Seite begann mit vier Kacheln, von denen an einem ruhigen Tag zwei auf
null standen. Jetzt steht der letzte Gast gross oben; die Zahlen sind
dorthin gewandert, wo sie etwas erklaeren. */
.held {
display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
align-items: stretch; margin: 22px 0 8px;
}
.held-text h1 {
font-size: 38px; line-height: 1.08; letter-spacing: -0.03em;
font-weight: 800; margin: 0 0 10px;
}
.held-text p { margin: 0 0 20px; color: var(--text-2); font-size: 16px; max-width: 44ch; }
.held .progress-bar { margin: 0; }
.held-bild {
position: relative; border-radius: var(--r-lg); overflow: hidden;
box-shadow: var(--shadow); min-height: 280px; background: var(--surface-2);
display: block;
}
.held-bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
.held-bild .schild {
position: absolute; left: 0; right: 0; bottom: 0; padding: 40px 20px 16px;
color: #fff;
/* Fester Farbwert statt Token: der Verlauf liegt ueber einem Foto, nicht
ueber dem Seitenhintergrund — er muss in beiden Modi gleich dunkeln. */
background: linear-gradient(transparent, rgba(8,24,16,.88));
}
.held-bild .schild b { display: block; font-size: 22px; font-weight: 800; }
.held-bild .schild small { opacity: .9; font-size: 13px; }
.frisch {
position: absolute; top: 14px; left: 14px;
background: var(--kopf-text); color: var(--kopf-bg);
padding: 5px 11px; border-radius: 999px;
font-size: 11px; font-weight: 800; letter-spacing: .07em;
}
.held-leer {
display: grid; place-items: center; text-align: center; padding: 30px;
border: 1px dashed var(--line); border-radius: var(--r-lg);
color: var(--muted); background: var(--surface);
}
/* ---------- Besuche als Bildkarten ---------- */
.besuche { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
.besuch {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r); overflow: hidden; box-shadow: var(--shadow);
transition: transform .15s, box-shadow .15s;
}
.besuch:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); }
/* ⚠ Feste Hoehe statt aspect-ratio: das gibt es erst ab Safari 15 und faellt
davor ersatzlos weg, womit die Karten unterschiedlich hoch waeren. */
.besuch img, .besuch .leer { width: 100%; height: 118px; object-fit: cover; display: block; }
.besuch .leer { background: var(--surface-2); }
.besuch .f { padding: 10px 12px; }
.besuch b { font-weight: 750; font-size: 14.5px; }
.besuch small { display: block; color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.besuch .xp { float: right; color: var(--accent); font-weight: 800; font-size: 12.5px; }
@media (max-width: 900px) {
.held { grid-template-columns: 1fr; }
.held-bild { min-height: 230px; order: -1; }
.held-text h1 { font-size: 30px; }
.besuche { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 420px) {
.held-text h1 { font-size: 26px; }
.besuche { grid-template-columns: 1fr 1fr; gap: 10px; }
}