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:
140
app/helpers.php
140
app/helpers.php
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user