Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets

Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.

Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.

Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.

--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.

Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
This commit is contained in:
2026-07-27 10:07:31 +02:00
parent 96a698ebdc
commit 8304095dc3
6 changed files with 4736 additions and 140 deletions

View File

@@ -20,6 +20,39 @@ function config(?string $key = null, mixed $default = null): mixed
return $value;
}
/**
* Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
* unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
* als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
* auf config.example.php weiter — deren app_secret steht öffentlich im Repo, die
* Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
*/
function config_problems(): array
{
$problems = [];
if (!is_file(ROOT_PATH . '/config/config.php')) {
$problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
}
$secret = (string) config('app_secret');
if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
$problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
}
$smtp = (array) config('smtp', []);
foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
if (empty($smtp[$key])) {
$problems[] = "smtp.{$key} ist nicht gesetzt.";
}
}
if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
$problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
}
return $problems;
}
/**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/
@@ -298,6 +331,113 @@ function log_spam(string $route, string $reason): void
error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log');
}
/**
* Formular-Mail über All-Inkl-SMTP verschicken — EINZIGE Versandstelle der Seite.
* Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
* an genau einem Ort stehen.
*
* $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
* reply_to ['email'=>…, 'name'=>…] · context (Präfix im Log).
*
* Gibt true/false zurück und wirft nie — Versandfehler landen in
* storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
* Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
* SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
*/
function send_mail(array $opts): bool
{
$smtp = (array) config('smtp', []);
$context = (string) ($opts['context'] ?? 'Mailversand');
try {
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = (string) $smtp['username'];
$mail->Password = (string) $smtp['password'];
$mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
$mail->Timeout = 10;
$mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)
$mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
$mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
if (!empty($opts['reply_to']['email'])) {
$mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
}
$mail->Subject = (string) $opts['subject'];
$mail->Body = (string) $opts['body'];
$mail->send();
return true;
} catch (Throwable $e) {
error_log('[' . date('c') . "] {$context} fehlgeschlagen: " . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
return false;
}
}
/**
* Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
* der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
* $old: Feldname → Wert. $errors: Feld-ID (wie im Markup) → Fehlermeldung.
*/
function form_state(?array $old = null, ?array $errors = null): array
{
static $state = ['old' => [], 'errors' => []];
if ($old !== null) {
$state = ['old' => $old, 'errors' => $errors ?? []];
}
return $state;
}
/**
* Zuvor eingegebener Wert eines Feldes ('' wenn keiner) — für value/selected/checked.
*/
function form_old(string $name): string
{
return (string) (form_state()['old'][$name] ?? '');
}
/**
* Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
*/
function form_field_error(string $id): string
{
return (string) (form_state()['errors'][$id] ?? '');
}
/**
* True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
*/
function form_has_errors(): bool
{
return form_state()['errors'] !== [];
}
/**
* Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
* Seite mit HTTP 422 direkt neu rendern — mit erhaltenen Eingaben und Feldfehlern.
* Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
* Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
* Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
*/
function render_form_invalid(string $slug, array $old, array $errors): never
{
form_state($old, $errors);
$routes = require APP_PATH . '/routes.php';
$current = isset($routes[$slug]) ? $slug : '';
http_response_code(422);
header('Cache-Control: no-store');
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';
exit;
}
/**
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
*/