Letzter Teil. base.css hatte längst eine globale :focus-visible-Regel, trotzdem existierten 15 Fokus-Regeln — die meisten davon Ballast. Fünf Overrides wiederholten die Basisregel praktisch wortgleich (.back-to-top, .ad-banner__dot, .faq__q, .table-scroll, .btn) und sind ersatzlos entfallen; sie unterschieden sich höchstens um 1px Offset. Die Basisregel selbst läuft jetzt über --focus-ring/--focus-offset, .position-card__head wechselt von Akzentrot auf Weiß. Übrig bleiben zehn Regeln, davon deklarieren nur noch vier überhaupt eine outline — jede mit Grund (inset-Offset bei .tab und .position-card__head, geschichteter Ring beim Formularfeld). Die anderen sechs sind reine Zusatz- effekte (transform, Farbe, Fläche) und lassen den Basis-Ring durch. Der eigentliche Fund steckte im Formularfeld: es setzte outline: none und ersetzte den Ring durch einen roten Rahmen mit 30%-Glow. Akzentrot erreicht auf der Feldfläche aber nur 2,8:1 und verfehlt damit WCAG 1.4.11 (>=3:1) für Fokus-Indikatoren — Tastaturnutzer hatten dort also den schwächsten Fokus der ganzen Seite. Roter Rahmen und Glow bleiben als Marken-Signal, der weiße Standard-Ring kommt zurück; outline-offset entspricht der Glow-Breite, beide Ringe liegen bündig nebeneinander statt übereinander. CLAUDE.md dokumentiert die neuen Regeln: Token-Disziplin mit Tabelle (Spacing, Typo, Icons, Sperrung, Fokus, Trefferfläche) samt der bewussten em-Ausnahme, das Button- und Tab-System inklusive der (0,2,0)-Falle durch die CSS-Ladereihenfolge, und die Fokus-Regel. Dazu ein Punkt in der Checkliste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
19 KiB
TSV 08 Kulmbach — Website
Neubau von tsv08kulmbach.de. Vereinsseite (Fußball + Turnen, gegründet 1908) als sauberer, selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
Stack & Umgebung
- Frontend: Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
- Backend: Vanilla PHP ≥ 8.1, Composer nur für
phpmailer/phpmailer. Keine Template-Engine. - Hosting-Ziel: Apache Shared Hosting, PHP 8.x,
.htaccess, Cronjobs verfügbar. - Lokales Dev:
php -S localhost:8000 -t public public/index.php(index.php fungiert als Router-Script, weilphp -Skein .htaccess kennt). - Docroot:
public/. Falls der Hoster nur das Repo-Root serviert, greift die Root-.htaccess(Rewrite →public/, Deny für alles andere).
Harte Regeln (nicht verhandelbar)
- Keine externen Einbindungen. Keine CDNs, keine externen Fonts/Scripts/iframes/Embeds, keine
Tracking-/Analytics-Dienste, kein reCAPTCHA. Alles wird self-hosted. Einzige erlaubte ausgehende
Verbindungen: All-Inkl-SMTP (Mailversand, serverseitig) und der Instagram-Scraper (CLI/Cron, nie im Request-Pfad).
Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (
default-src 'self') erzwingt das — nicht aufweichen. .context/und.context_db/sind reine Referenz (alte, unsichere CMS-Seite). Von dort werden nur Assets (Bilder/Fonts/Videos), Texte und Styling-Werte extrahiert. Niemals Code übernehmen, niemals Dateien von dort direkt verlinken. Assets immer nachpublic/assets/kopieren/optimieren.- Kein Admin-/Pflege-Backend bauen. Inhalte werden per Chat gepflegt: Claude editiert
data/*.jsonbzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints. - Sensible Altdaten nie übernehmen: Aus dem DB-Dump keine Mail-Logs, Formulareinträge, Passwort-Hashes, Tokens oder personenbezogene Daten migrieren.
- Secrets nur in
config/config.php(gitignored, außerhalb des Webroots, per .htaccess denied). Niemals hardcoden, loggen oder ausgeben.config/config.example.phpdokumentiert alle Keys.
Single Source of Truth (vor JEDER Änderung prüfen)
| Was | Einzige Quelle | Niemals |
|---|---|---|
| Farben, Fonts, Spacing, Radii, Schatten | public/assets/css/tokens.css |
Hex-Werte/Magic Numbers in anderen CSS-Dateien |
| Seiten-Slugs & Routing | app/routes.php |
URLs woanders hart verdrahten |
| Navigation (Labels/Reihenfolge) | data/navigation.json |
Menüpunkte in Templates |
| Vereinsdaten (Name, Adresse, Kontakt, Social, Geo) | data/club.json |
Adresse/E-Mail irgendwo als Text duplizieren |
| Startseiten-Inhalte | data/home.json |
Texte in pages/home.php |
| Mannschaften (Kader, Trainer, Hero, FAQ) | data/teams.json |
Spieler-/Trainernamen in Team-Templates |
| Seiteninhalte (Texte, Hero, FAQ) | data/<slug>.json (z. B. fussball, jugend, turnen, verein, mitmachen, historie, partner-werden, sportheimbuchung) |
Texte/Hero/FAQ hart in pages/*.php |
| Ansprechpartner/Personen (geteilte Quelle) | data/ansprechpartner.json (per Abteilung auf Team-/Jugend-/Verein-Seiten gezogen) |
Namen/Funktionen/Kontakte in Templates duplizieren |
| Banner-Slider (Startseite) | data/banners.json |
Slides in pages/home.php oder der Komponente |
| Partner/Sponsoren | data/partners.json |
— |
| Instagram-Cache | data/instagram.json + public/assets/img/instagram/ |
maschinenverwaltet von bin/instagram-sync.php — nie von Hand editieren |
| Matchcenter (Spiele/Ergebnisse/Tabellen) | data/matchcenter.json + public/assets/img/crests/ |
maschinenverwaltet von bin/matchcenter-sync.php — nie von Hand editieren. Team-/Wettbewerbs-IDs in config.php (matchcenter.teams) |
| Icons | public/assets/icons/ (Bootstrap Icons, lokal) via icon()-Helper |
Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (app/pages/*.php) im $meta-Array.
Strukturierte Daten (JSON-LD): Seiten setzen $meta['schema'] = Array von schema.org-Knoten;
app/components/jsonld.php hängt sie an den globalen @graph (Organization #club) an — nie ein
<script type="application/ld+json"> von Hand ins Markup. Knoten nur über die Helper in
helpers.php bauen: breadcrumb_schema(), faq_schema(), page_schema() (Breadcrumb + optional FAQ),
team_schema() (SportsTeam + Breadcrumb + FAQ, verweist via #club auf den Club),
sportsevent_nodes() (SportsEvent-Knoten für kommende Spiele, Matchcenter) und
job_posting_schema() (JobPosting VOLUNTEER für Ehrenamtsstellen, /mitmachen, verweist via #club).
Sichtbare FAQ + FAQPage-Schema aus derselben data/*.json-Quelle, damit Inhalt und Markup nie auseinanderlaufen.
Architektur-Muster
- Front Controller:
public/index.php→ Slug-Lookup inapp/routes.php→ Page-Datei setzt$metaund emittiert Body viacomponent()→app/layout.phprendert die Shell. - Komponenten:
app/components/*.phpsind dumme Includes, bekommen Props viacomponent('name', ['key' => $val]). Component-first-Regel: Bevor neues Markup/CSS entsteht, prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen Seiten → als Komponente extrahieren. - Helpers (
app/helpers.php):e()(Escaping — IMMER für dynamische Ausgaben),url()(interne Pfade),abs_url()(absolute URLs für Canonical/OG),asset()(Versionierung via filemtime),json_load('name')(liestdata/name.json, static cache),component(),config('key')(Werte ausconfig/config.php),icon('name', 'klasse', 'label?')(Inline-SVG auspublic/assets/icons/, siehe Abschnitt Icons),form_token()/form_token_valid()(Time-Trap-Token, siehe Formulare),img_intrinsic_attrs('pfad')(width/height-Attribute aus der Bilddatei für CLS-freie<img>, wenn die Maße nicht schon statisch bekannt sind),match_when('2026-08-01T14:00')(Anstoß ausmatchcenter.json→isofür<time datetime>/JS-Ticker + deutscher Countdown-Text, gelesen als Europe/Berlin, weil PHP global auf UTC läuft). - CSS: 6 Dateien als
<link>in fester Reihenfolge: tokens → reset → base → layout → components → utilities. Kein@import, kein Inline-Style. Komponenten-Styles incomponents.cssmit Banner-Kommentaren (/* === hero === */). - JS: Vanilla,
defer, progressive enhancement — alles muss ohne JS funktionieren (Formulare = normales POST + Redirect). Ein Feature = eine Datei = eine selbst-initialisierende IIFE. - Hero-Regel: Die Startseite hat einen großen Hero (
display: true, mitkicker/tagline/Video). Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser" — nurtitle+text, dazucompact: trueundalign: 'bottom-left'. Keinkicker, keinetaglineauf Unterseiten (hält es klar und einfach).ctasnur als begründete Ausnahme. Gilt für JSON- wie Inline-Heros. - Neue Seite anlegen = genau 3 Schritte:
app/pages/<slug>.php+ Eintrag inapp/routes.php(+ optionaldata/navigation.json). Sitemap & Canonical folgen automatisch.
Formulare
- Felder ausschließlich über
component('form-field', ['field' => …])(app/components/form-field.php) — nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext, das Fehler-Element#<id>-error(peraria-describedbyverknüpft) und schreibt nach einem abgewiesenen POST die alten Werte zurück (form_old(), inkl.selected/checked). - Jedes neue Formular braucht: (1) Status-Region
role="status" aria-live="polite" tabindex="-1" data-form-statusals erstes Form-Kind — mitautofocus, wennform_has_errors(), (2) Honeypot-Feldcompany_url(visually-hidden) + Hidden-Fieldftmitform_token(), (3) serverseitige Action unterapp/actions/mit Whitelist-Validierung, (4) Fehlermeldungs-Map mit den Schlüsselnvalidation/ratelimit/busy/mail(identisch inform.js). - Antwortmuster der Actions (in dieser Reihenfolge, siehe
contact-submit.phpals Referenz): Erfolg und Volumen-Abweisungen → PRG-Redirect (?sent=1/?error=…), fetch-Requests → JSON. Feldfehler ohne JS →render_form_invalid($slug, $old, $errors): rendert die Seite mit HTTP 422 samt Eingaben und Feldfehlern neu, statt per Redirect alle Eingaben zu verlieren.$errorsist Feld-ID → Meldung (passt auf#<id>-error),$oldist Feld-Name → Wert. form.jszeigt Inline-Feldfehler (Browser-Validierung vor dem Absenden, Server-Feldfehler ausfieldsin der JSON-Antwort) — Konvention dafür ist die#<id>-error-ID aus der Komponente.- A11y-Vorgaben: Feldränder mit
--clr-glass-border-strong(≥3:1 Kontrast, WCAG 1.4.11), Checkbox min. 24px (WCAG 2.5.8), Status-Region nie perdisplay:noneverstecken (fliegt aus dem Accessibility-Tree). - Versand ausschließlich über
send_mail()(helpers.php) — die einzige Versandstelle der Seite, PHPMailer über All-Inkl-SMTP (w01ca75d.kasserver.com:587, STARTTLS, Postfachnoreply@tsv08kulmbach.de), Timeout 10 s (der Versand hängt im Request-Pfad!), Fehler nachstorage/logs/mail.log. Nie einen eigenen PHPMailer in einer Action aufbauen. Verbindung/Login ohne Mailversand testen:php bin/smtp-test.php. - Spam-Schutz ohne externe Dienste — mehrschichtig in jeder
app/actions/*-Action: (1)config_problems()als Not-Aus (ohne echteconfig.phpliefe die Seite auf der Vorlage, derenapp_secretöffentlich im Repo steht), (2) Honeypot-Feldcompany_url, (3) HMAC-signierter Timestamp / Time-Trap (ft-Token viaform_token()/form_token_valid(),app_secret), (4) serverseitige Whitelist-Validierung, (5) Link-Count-Check im Freitext, (6) Rate-Limiting viarate_limit_ok()mit Zählern unterstorage/ratelimit/: pro IP+Route (5/10 min), Token-Replay-Sperre (3× pro Token+IP) und globaler Tages-Cap (200/Tag über alle Formulare). Abgewiesene Versuche →log_spam()nachstorage/logs/spam.log. - Reihenfolge und Ton der Abweisungen sind Absicht: Bot-Signale (Honeypot, Token, Link-Flut)
antworten mit einem stillen „OK“ (kein Feedback-Kanal für Bots). Volumen-Grenzen antworten
ehrlich (
ratelimit/busy) — ein stilles „Danke“ ohne Versand verschluckt sonst echte Anfragen. Die Volumen-Grenzen stehen nach der Validierung, damit Tippfehler kein Kontingent verbrauchen. - Logs unter
storage/logs/:mail.log(Mailversand-Fehler),spam.log(abgewiesene Form-Versuche),instagram.log/matchcenter.log(Sync-Läufe),php-errors.log. Fehler nie an Besucher leaken. Rotation per Cron:bin/log-rotate.php(>2 MB →.log.1, max. zwei Generationen). - Vor jedem Deploy
php bin/preflight.php— prüft Config/Secrets,env, Schreibrechte,vendor/, PHP-Version und die Cron-Datenstände. Exit 1 = noch nicht live gehen. Datenschutzerklärung (app/pages/datenschutz.php) mitziehen, wenn ein Formular neue Felder bekommt — sie beschreibt die drei Formulare einzeln.
Design (aus der alten Seite extrahiert, verifiziert)
- Dunkles sportliches Theme: BG
#222mit Textur, weiße Schrift, Akzent-Rot#e20612(das ist der echte Markenwert aus der alten DB — nicht #ff6532). - Headings: Coolvetica, uppercase, line-height ~0.85. Body: Abel. Beide self-hosted als woff2.
- Coolvetica nie kleiner als
--fs-650(28px) — der schmale Condensed-Schnitt wird in Uppercase darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen Abel verwenden, ggf. mitletter-spacing+ uppercase.base.csssetzt das durch:h1–h3tragen Coolvetica,h4–h6laufen über--ff-body+ uppercase +--ls-caps. - Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
- Alle Werte als CSS Custom Properties in
tokens.css; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
Token-Disziplin (gilt für jede CSS-Änderung)
Keine rohen Werte in base/layout/components/utilities.css — es gibt für alles eine Stufe:
| Was | Token | Anmerkung |
|---|---|---|
padding / margin / gap |
--space-4xs … --space-7xl (14 Stufen, 4px-Raster) |
--space-4xs (2px) nur für optische Korrekturen (Icon-/Baseline-Nudges), nie fürs Layout |
font-size (Text) |
--fs-300 … --fs-1000 |
--fs-550/--fs-690 sind die fluiden Zwischenstufen (Lead, Karten-Überschrift) |
font-size (Icons) |
--icon-s/m/l/xl (rem), --icon-inline-sm/--icon-inline/--icon-inline-lg (em) |
em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen |
letter-spacing |
--ls-tight/caps/wide/wider |
|
| Fokus | --focus-ring / --focus-offset |
|
| Trefferfläche | --tap-min (44px) |
Ausnahmen, bewusst roh: em-Werte, die mit der font-size skalieren sollen (Button-Padding
--btn-pad-*, Badge-Innenabstände) — die gehören nicht auf ein festes px-Raster.
Buttons & Trefferflächen
- Varianten in
utilities.css:.btn(primary) ·.btn--outline·.btn--ghost(tertiär, ohne Fläche) ·.btn--icon(quadratisch, nur Icon +aria-label) ·.btn--sm/.btn--lg..btnistinline-flexmitgap— Icon + Label brauchen kein Zusatz-Markup und keinen eigenen Abstand. - Tabs (Filterleiste wie Panel-Tabs) laufen über die gemeinsame
.tab-Basis incomponents.css; aktiv wird überaria-pressedoderaria-selectederkannt. Nie eine dritte Tab-Optik bauen. - Jedes Control ≥
--tap-min(44px, WCAG 2.5.5). Soll es optisch kleiner bleiben, streckt.tap-targetdie Trefferfläche unsichtbar per::after(bei.btn--smautomatisch). Voraussetzung: keinoverflow: hidden,::afterfrei. In Reihen (Nav, Footer) stattdessenmin-heightverwenden — überlappende Pseudo-Flächen würden sonst den Nachbarn verdecken. - Ausnahme: die Formular-Checkbox bleibt bei 24px (WCAG 2.5.8).
<input>ist ein Replaced Element,::aftergreift dort nicht zuverlässig; das klickbare<label>liefert die große Fläche. - Komponenten-Deltas zu
.btnbrauchen (0,2,0) — z. B..btn.ad-banner__nav-btn.utilities.csslädt nachcomponents.css, bei gleicher Spezifität gewinnt sonst die.btn-Basis.
Fokus
base.css setzt eine globale :focus-visible-Regel mit --focus-ring (2px weiß, 15,9:1 auf
--clr-bg). Komponenten überschreiben sie nur mit Begründung — zulässig sind ein negativer
outline-offset (Ring soll inset liegen, z. B. .tab) und zusätzliche Effekte ohne eigene outline.
Nie outline: none und nie Akzentrot als alleiniger Indikator: Rot erreicht auf der Feldfläche nur
2,8:1 und verfehlt WCAG 1.4.11 (≥3:1).
Icons
- Bootstrap Icons (MIT), lokal & self-hosted unter
public/assets/icons/<name>.svg— die erlaubte Icon-Quelle. Kein Icon-Font, kein CDN, keine gezeichneten CSS-/Unicode-Icons. - Ausgabe nur über den Helper:
icon('pause-fill')(dekorativ,aria-hidden),icon('pause-fill', 'meine-klasse')(mit Klasse),icon('envelope', '', 'E-Mail')(semantisch,role="img"+aria-label). Inline-SVG, currentColor erbt die Schriftfarbe. - Größe =
font-sizedes Elements (Icon ist1em), Farbe =currentColor. Basisklasse.iconinutilities.css. - Nur tatsächlich genutzte Icons werden abgelegt (kein Komplett-Set). Neues Icon:
php bin/icons-add.php <name>(Name = Dateiname auf icons.getbootstrap.com ohne.svg).
Checkliste für jede neue Seite/Komponente
- Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge)
- Alt-Texte für Bilder; dekorative Bilder
alt="" - Tastatur-bedienbar,
:focus-visiblesichtbar, sinnvolle Tab-Reihenfolge - Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI)
- Keine rohen Werte: Spacing,
font-size,letter-spacing, Fokus und Trefferflächen über die Token austokens.css(siehe „Token-Disziplin"). Interaktive Elemente ≥--tap-min prefers-reduced-motionrespektiert (globaler Kill-Switch in reset.css)$metagesetzt: title, description (+ og_image falls abweichend)- Bilder:
srcset/sizes,width/height,loading="lazy"(above-the-fold: eager + fetchpriority). Responsivewidths-Varianten mitphp bin/img-resize.phperzeugen (nicht von Hand skalieren) - Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
php -lsauber; Seite lokal geprüft
Instagram-Feed (Juicer-Ersatz)
bin/instagram-sync.php (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt
data/instagram.json atomar und lädt Bilder nach public/assets/img/instagram/. Bei Fehlern bleibt
der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente
eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.
Matchcenter (BFV-Widget-Ersatz)
bin/matchcenter-sync.php (nur CLI) holt Spielplan, Ergebnisse und Tabelle der drei Aktivenmannschaften
aus der öffentlichen BFV-Widget-JSON-API (widget-prod.bfv.de, reverse-engineert aus dem alten
<BFVWidget.HTML5…>-Embed — kein HTML-Scraping), lädt die Vereinswappen lokal nach
public/assets/img/crests/ und schreibt data/matchcenter.json atomar. Endpunkte: …/api/service/widget/v1/team/{teamId}/matches,
…/api/service/widget/v1/competition/{compoundId}/table, Wappen app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}.
Browser-User-Agent + Referer nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über permanentId.
Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert
matchcenter-empty). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe config.example.php).
Das „Nächste Spiele"-Band (next-matches) zeigt links den Aufmacher (match-feature: das zeitlich
nächste Spiel aller Mannschaften) und rechts die folgenden Termine als verlinkte match-row-Zeilen
(match-list--plain, ohne Kartenfläche). Ein Bauprinzip fürs ganze Band: der Aufmacher ist die
große Variante der Terminzeile — Heim über Gast, linksbündig, dieselbe Struktur, nur größer.
Genau drei Schriftrollen, nicht mehr: Meta-Zeile „Mannschaft · Liga · Heim/Auswärts" (Abel,
uppercase, gesperrt) · Vereinsnamen (Coolvetica, einziges Heading-Element) · Anstoßzeile mit Countdown
via match_when() (Abel). Keine Badges, Icons oder Buttons — die Wirkung kommt aus der Größe, Rot nur
als Oberkante des Bandes. Die eigene Mannschaft wird über Helligkeit markiert, nie über font-weight
(Coolvetica hat nur einen Schnitt → sonst Fake-Bold). Kein Slider —
assets/js/carousel.js treibt nur noch den Banner-Slider der Startseite.
Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.