+ $f['subject']]); ?>
+ $f['message']]); ?>
+ $f['privacy']]); ?>
diff --git a/app/components/form-field.php b/app/components/form-field.php
new file mode 100644
index 0000000..adaa5d1
--- /dev/null
+++ b/app/components/form-field.php
@@ -0,0 +1,66 @@
+
+ * required bool
+ * autocomplete, maxlength, rows (textarea)
+ * options[] + placeholder (select)
+ * hint optionaler Hilfetext (aria-describedby)
+ * label_html NUR für vertrauenswürdiges Markup aus Komponenten-Code
+ * (z. B. Datenschutz-Link im Checkbox-Label) — nie Nutzereingaben!
+ *
+ * Jedes Feld bekommt ein leeres Fehler-Element (#
-error), das form.js bei
+ * ungültiger Eingabe mit der Browser-Validierungsmeldung füllt (aria-describedby).
+ */
+$type = $field['type'] ?? 'text';
+$name = $field['name'];
+$id = $field['id'] ?? 'field-' . $name;
+$required = !empty($field['required']);
+$errorId = $id . '-error';
+$hintId = $id . '-hint';
+$describedBy = (!empty($field['hint']) ? $hintId . ' ' : '') . $errorId;
+
+$requiredMark = $required
+ ? ' *'
+ : '';
+
+$commonAttrs = 'name="' . e($name) . '" id="' . e($id) . '"'
+ . ($required ? ' required' : '')
+ . ' aria-describedby="' . e($describedBy) . '"'
+ . (!empty($field['autocomplete']) ? ' autocomplete="' . e($field['autocomplete']) . '"' : '')
+ . (!empty($field['maxlength']) ? ' maxlength="' . e($field['maxlength']) . '"' : '');
+?>
+
+
+
+
+
diff --git a/public/assets/css/components.css b/public/assets/css/components.css
index 8370f06..ffc393b 100644
--- a/public/assets/css/components.css
+++ b/public/assets/css/components.css
@@ -334,6 +334,44 @@
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-btn);
color: var(--clr-text);
+ transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
+}
+
+.form__field input:hover,
+.form__field select:hover,
+.form__field textarea:hover {
+ border-color: rgb(255 255 255 / 0.4);
+}
+
+/* Eigener, deutlich sichtbarer Fokus-Ring statt des globalen Outlines */
+.form__field input:focus-visible,
+.form__field select:focus-visible,
+.form__field textarea:focus-visible {
+ outline: none;
+ border-color: rgb(255 255 255 / 0.75);
+ box-shadow: 0 0 0 3px rgb(255 255 255 / 0.18);
+}
+
+.form__field ::placeholder {
+ color: rgb(255 255 255 / 0.45);
+}
+
+/* Fehlerzustand: per JS (aria-invalid) und nativ nach Nutzereingabe (:user-invalid) */
+.form__field input[aria-invalid="true"],
+.form__field select[aria-invalid="true"],
+.form__field textarea[aria-invalid="true"],
+.form__field input:user-invalid,
+.form__field select:user-invalid,
+.form__field textarea:user-invalid {
+ border-color: var(--clr-error);
+}
+
+.form__field select {
+ appearance: none;
+ padding-right: 2.5rem;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='9' fill='none'%3E%3Cpath d='M1 1l6 6 6-6' stroke='%23ffffff' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.9rem center;
}
.form__field select option {
@@ -341,6 +379,31 @@
color: var(--clr-text);
}
+.form__note {
+ margin: 0;
+ font-size: var(--fs-400);
+ color: var(--clr-text-muted);
+}
+
+.form__required {
+ color: var(--clr-accent);
+}
+
+.form__hint {
+ font-size: var(--fs-400);
+ color: var(--clr-text-muted);
+}
+
+.form__error-msg {
+ font-size: var(--fs-400);
+ color: var(--clr-error);
+}
+
+.form button[disabled] {
+ opacity: 0.65;
+ cursor: progress;
+}
+
.form__field textarea {
resize: vertical;
min-height: 8rem;
@@ -385,3 +448,234 @@
.form__submit {
margin: 0;
}
+
+/* === Werbe-/Sponsoren-Banner (Slider) === */
+.ad-banner {
+ --banner-interval: 6s;
+}
+
+.ad-banner__subline {
+ margin-top: 0.75rem;
+ max-width: 70ch;
+}
+
+.ad-banner__track {
+ list-style: none;
+ margin: 2rem 0 0;
+ padding: 0;
+}
+
+.ad-banner__slide[hidden] {
+ display: none;
+}
+
+.ad-banner__slide {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 2.5rem;
+ align-items: center;
+ padding: clamp(1.75rem, 1rem + 2vw, 3rem);
+ background: var(--clr-glass-bg);
+ border: 1px solid var(--clr-glass-border);
+ border-radius: var(--radius-card);
+ box-shadow: var(--shadow-card);
+}
+
+/* Optionaler Hintergrund pro Banner (data: "background").
+ Dunkler Verlauf über dem Bild hält Text & CTA links lesbar. */
+.ad-banner__slide--bg {
+ background-image:
+ linear-gradient(to right, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.45) 60%, rgb(0 0 0 / 0.3)),
+ var(--banner-bg);
+ background-size: cover;
+ background-position: center;
+ border-color: transparent;
+}
+
+.ad-banner__slide--bg .ad-banner__text {
+ color: var(--clr-text);
+}
+
+.ad-banner__slide--bg .ad-banner__logo {
+ background: rgb(0 0 0 / 0.4);
+ border-color: rgb(255 255 255 / 0.3);
+ backdrop-filter: blur(2px);
+}
+
+@media (max-width: 768px) {
+ /* Auf Mobil läuft der Verlauf nach unten, damit der gestapelte Text lesbar bleibt. */
+ .ad-banner__slide--bg {
+ background-image:
+ linear-gradient(to bottom, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.5)),
+ var(--banner-bg);
+ }
+}
+
+.ad-banner__tag {
+ display: inline-block;
+ margin-bottom: 0.85rem;
+ padding: 0.2em 0.7em;
+ font-size: var(--fs-300);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--clr-text-muted);
+ background: rgb(255 255 255 / 0.08);
+ border: 1px solid var(--clr-glass-border);
+ border-radius: 999px;
+}
+
+.ad-banner__title {
+ font-family: var(--ff-heading);
+ font-size: var(--fs-700);
+ line-height: var(--lh-heading);
+ margin-bottom: 0.6rem;
+}
+
+.ad-banner__text {
+ max-width: 52ch;
+ margin-bottom: 1.5rem;
+ color: var(--clr-text-muted);
+}
+
+.ad-banner__logo {
+ display: grid;
+ place-items: center;
+ min-height: 130px;
+ padding: 1.5rem;
+ background: rgb(0 0 0 / 0.18);
+ border: 1px solid var(--clr-glass-border);
+ border-radius: var(--radius-card);
+}
+
+.ad-banner__logo img {
+ width: auto;
+ max-width: 100%;
+ max-height: 96px;
+ object-fit: contain;
+}
+
+@media (max-width: 768px) {
+ .ad-banner__slide {
+ grid-template-columns: 1fr;
+ gap: 1.75rem;
+ }
+}
+
+/* Steuerung: Pause/Play-Taste + Indikatorleiste. */
+.ad-banner__controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ margin-top: 1.75rem;
+}
+
+.ad-banner__controls[hidden] {
+ display: none;
+}
+
+.ad-banner__toggle {
+ display: inline-grid;
+ place-items: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ color: var(--clr-text);
+ background: var(--clr-glass-bg);
+ border: 1px solid var(--clr-glass-border);
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background var(--transition);
+}
+
+.ad-banner__toggle[hidden] {
+ display: none;
+}
+
+.ad-banner__toggle:hover {
+ background: rgb(255 255 255 / 0.2);
+}
+
+.ad-banner__toggle:focus-visible {
+ outline: 2px solid var(--clr-text);
+ outline-offset: 3px;
+}
+
+/* Pause-Symbol (zwei Balken) im Abspiel-Zustand … */
+.ad-banner__toggle::before {
+ content: "";
+ display: block;
+ width: 4px;
+ height: 13px;
+ border-left: 4px solid currentColor;
+ border-right: 4px solid currentColor;
+}
+
+/* … Play-Dreieck, wenn pausiert. */
+.ad-banner__toggle[aria-pressed="true"]::before {
+ width: 0;
+ height: 0;
+ margin-left: 3px;
+ border: 0;
+ border-left: 13px solid currentColor;
+ border-top: 8px solid transparent;
+ border-bottom: 8px solid transparent;
+}
+
+/* Indikatorleiste — die aktive Leiste füllt sich über --banner-interval. */
+.ad-banner__indicators {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.ad-banner__dot {
+ position: relative;
+ width: 48px;
+ height: 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: var(--clr-glass-border);
+ cursor: pointer;
+ overflow: hidden;
+ transition: background var(--transition);
+}
+
+.ad-banner__dot:hover {
+ background: rgb(255 255 255 / 0.35);
+}
+
+.ad-banner__dot:focus-visible {
+ outline: 2px solid var(--clr-text);
+ outline-offset: 3px;
+}
+
+.ad-banner__progress {
+ position: absolute;
+ inset: 0;
+ background: var(--clr-accent);
+ transform: scaleX(0);
+ transform-origin: left;
+}
+
+.ad-banner__dot.is-active .ad-banner__progress {
+ animation: ad-progress var(--banner-interval) linear forwards;
+}
+
+.ad-banner.is-paused .ad-banner__dot.is-active .ad-banner__progress {
+ animation-play-state: paused;
+}
+
+@keyframes ad-progress {
+ from { transform: scaleX(0); }
+ to { transform: scaleX(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ad-banner__dot.is-active .ad-banner__progress {
+ animation: none;
+ transform: scaleX(1);
+ }
+}
diff --git a/public/assets/js/form.js b/public/assets/js/form.js
index 46dc6d3..0b2535e 100644
--- a/public/assets/js/form.js
+++ b/public/assets/js/form.js
@@ -1,5 +1,6 @@
-/* Kontaktformular: Progressive Enhancement — ohne JS normales POST + Redirect.
- Mit JS: fetch-Submit, Status in aria-live-Region, Button-Sperre während des Sendens. */
+/* Formulare: Progressive Enhancement — ohne JS normales POST + Redirect.
+ Mit JS: Inline-Feldfehler (aria-describedby, Fokus aufs erste ungültige Feld),
+ fetch-Submit, Status in aria-live-Region, Button-Sperre während des Sendens. */
(function () {
'use strict';
@@ -17,19 +18,50 @@
mail: 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut.'
};
+ function errorEl(field) {
+ return field.id ? document.getElementById(field.id + '-error') : null;
+ }
+
+ function showFieldError(field) {
+ var msg = errorEl(field);
+ field.setAttribute('aria-invalid', 'true');
+ if (msg) {
+ msg.textContent = field.validationMessage;
+ msg.hidden = false;
+ }
+ }
+
+ function clearFieldError(field) {
+ var msg = errorEl(field);
+ field.removeAttribute('aria-invalid');
+ if (msg) {
+ msg.textContent = '';
+ msg.hidden = true;
+ }
+ }
+
+ Array.prototype.forEach.call(form.elements, function (field) {
+ field.addEventListener('input', function () { clearFieldError(field); });
+ field.addEventListener('change', function () { clearFieldError(field); });
+ });
+
function show(type, text) {
var p = document.createElement('p');
p.className = type === 'success' ? 'form__success' : 'form__error';
p.textContent = text;
status.replaceChildren(p);
+ status.focus({ preventScroll: true });
status.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
form.addEventListener('submit', function (event) {
- // Native Validierung zuerst (novalidate ist gesetzt, daher manuell).
if (!form.checkValidity()) {
event.preventDefault();
- form.reportValidity();
+ var invalid = form.querySelectorAll(':invalid');
+ Array.prototype.forEach.call(invalid, showFieldError);
+ if (invalid.length) {
+ invalid[0].focus();
+ }
return;
}