Relaunch Phase 1: Hero mit Deutschland-Karte, Kundenportal-Bühne, Leistungen, Preise, Unterseiten im neuen Layout

- Hero: wechselnde Überschrift (ein Durchlauf, Pause-Knopf), Karte zeichnet
  sich, roter Punkt auf Falkenstein
- Kundenportal: Bühne mit Browserfenster und Handy-Rahmen (Desktop),
  Wisch-Reihe (Handy), Schrittzeile statt „So arbeiten wir“
- Leistungen als nummerierte Zeilen, Preise aus src/data/abos.ts
- Unterseiten ohne eigenen Kopfbereich, Wortlaut der Rechtstexte unverändert
- Tests für Hero, Kundenportal, Leistungen/Preise und Layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 15:28:38 +00:00
1 parent 69d006334f
commit 1c8cfa5c1c
54 files changed
+4761 -1166

No files matched your search

+135 -8
View File
@@ -1,10 +1,137 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const HeroKarte = () => (
<section className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">HeroKarte</h2>
</div>
</section>
);
import { useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
import { Link } from "react-router-dom";
import { Pause } from "lucide-react";
import DeutschlandKarte from "@/components/DeutschlandKarte";
import "./hero.css";
/** Wechselnder Teil der Überschrift (Spezifikation 7.1). Es wechselt der ganze Teil nach „Wir bauen“ (Ihre/Ihren). */
const VARIANTEN = ["Ihre Website.", "Ihren Online-Shop.", "Ihre Web-App.", "Ihre Automatisierung."];
/** Ein Durchlauf: Website → Online-Shop → Web-App → Automatisierung → zurück zu Website, dann Stillstand. */
const ABLAUF = [0, 1, 2, 3, 0];
/** Alle 2,4 s ein Wechsel (die Überblendung selbst dauert 400 ms, siehe hero.css) */
const TAKT_MS = 2400;
const FESTER_SATZ = "Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.";
const REDUZIERT = "(prefers-reduced-motion: reduce)";
/** Reagiert auch auf eine Änderung der Systemeinstellung, solange die Seite offen ist. */
const useReduzierteBewegung = () =>
useSyncExternalStore(
(melde) => {
const abfrage = window.matchMedia?.(REDUZIERT);
abfrage?.addEventListener?.("change", melde);
return () => abfrage?.removeEventListener?.("change", melde);
},
() => window.matchMedia?.(REDUZIERT).matches ?? false,
() => false,
);
/** Ein zweiter Klick auf denselben Anker ändert die URL nicht; dann selbst hinscrollen (wie in der Kopfleiste). */
const zumKundenportal = (e: MouseEvent<HTMLAnchorElement>) => {
if (window.location.pathname === "/" && window.location.hash === "#kundenportal") {
e.preventDefault();
document.getElementById("kundenportal")?.scrollIntoView();
}
};
const HeroKarte = () => {
const reduziert = useReduzierteBewegung();
const [schritt, setSchritt] = useState(0);
const [knopfGedrueckt, setKnopfGedrueckt] = useState(false);
const [hover, setHover] = useState(false);
const [fokus, setFokus] = useState(false);
const [verborgen, setVerborgen] = useState(
() => typeof document !== "undefined" && document.visibilityState === "hidden",
);
const ueberschrift = useRef<HTMLHeadingElement>(null);
// Solange der Durchlauf nicht zu Ende ist und niemand angehalten hat, ist die Überschrift „in Bewegung“
const inBewegung = !reduziert && !knopfGedrueckt && schritt < ABLAUF.length - 1;
// Hover oder Fokus auf der Überschrift und ein ausgeblendeter Tab halten den Wechsel nur an
const pausiert = hover || fokus || verborgen;
const aktiv = reduziert ? 0 : ABLAUF[schritt];
useEffect(() => {
const aendern = () => setVerborgen(document.visibilityState === "hidden");
document.addEventListener("visibilitychange", aendern);
return () => document.removeEventListener("visibilitychange", aendern);
}, []);
useEffect(() => {
if (!inBewegung || pausiert) return;
const timer = window.setTimeout(() => setSchritt((s) => s + 1), TAKT_MS);
return () => window.clearTimeout(timer);
}, [inBewegung, pausiert, schritt]);
const anhalten = () => {
setKnopfGedrueckt(true);
// Der Knopf verschwindet: Fokus auf die Überschrift, damit er nicht ins Leere fällt
ueberschrift.current?.focus({ preventScroll: true });
};
// Ohne Bewegung steht nur die erste Variante da; sonst liegen alle übereinander (die breiteste bestimmt die Breite)
const sichtbareVarianten = reduziert ? VARIANTEN.slice(0, 1) : VARIANTEN;
return (
<section className="wk-hero">
<div className="wk-container wk-hero__raster">
<div className="wk-hero__text">
<p className="wk-eyebrow wk-hero__zeile">Websites, Shops und Apps für kleine Betriebe</p>
<div
className="wk-hero__kopf"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocus={() => setFokus(true)}
onBlur={() => setFokus(false)}
>
<h1 ref={ueberschrift} className="wk-h1 wk-hero__h1" tabIndex={-1}>
<span className="wk-visually-hidden">{FESTER_SATZ}</span>
<span className="wk-hero__sichtbar" aria-hidden="true">
<span className="wk-hero__fest">Wir bauen</span>
<span className="wk-hero__wechsel">
{sichtbareVarianten.map((text, i) => (
<span key={text} className="wk-hero__variante" data-aktiv={i === aktiv}>
{text}
</span>
))}
</span>
</span>
</h1>
{inBewegung && (
<button
type="button"
className="wk-hero__pause"
aria-label="Wechselnde Überschrift anhalten"
onClick={anhalten}
>
<span className="wk-hero__pause-symbol" aria-hidden="true">
<Pause size={14} strokeWidth={2.4} />
</span>
</button>
)}
</div>
<p className="wk-lead wk-hero__lead">
In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.
</p>
<div className="wk-hero__aktionen">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
<Link to="/#kundenportal" className="wk-btn wk-btn--secondary" onClick={zumKundenportal}>
Kundenportal ansehen
</Link>
</div>
</div>
<div className="wk-hero__karte">
<DeutschlandKarte />
</div>
</div>
</section>
);
};
export default HeroKarte;
+400 -6
View File
@@ -1,10 +1,404 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Kundenportal = () => (
<section id="kundenportal" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Kundenportal</h2>
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import WischReihe from "@/components/WischReihe";
import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen";
import "./kundenportal.css";
/**
* Abschnitt Kundenportal (Spezifikation 7.2).
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
*/
const EINLEITUNG =
"Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.";
// ── Bilder (erzeugt von scripts/relaunch-bilder.py nach public/relaunch/portal/) ──────────────────────────
const BILDER = "/relaunch/portal";
const DESKTOP_BREITEN = [800, 1200, 1600];
const HANDY_BREITEN = [390, 780];
const srcSet = (name: string, breiten: number[]) =>
breiten.map((b) => `${BILDER}/${name}-${b}.webp ${b}w`).join(", ");
/** Desktop-Bild: Rohformat 2880 x 1800, ausgeliefert in 800 / 1200 / 1600 px. */
const desktopBild = (name: string, alt: string): RahmenBildDaten => ({
src: `${BILDER}/${name}-1200.webp`,
srcSet: srcSet(name, DESKTOP_BREITEN),
sizes: "(min-width: 1200px) 692px, 50vw",
width: 1600,
height: 1000,
alt,
});
/** Handy-Bild: Rohformat 780 x 1688 (das lange Bild 780 x 5428), ausgeliefert in 390 / 780 px. */
const handyBild = (name: string, alt: string, sizes: string, hoehe = 1688): RahmenBildDaten => ({
src: `${BILDER}/${name}-780.webp`,
srcSet: srcSet(name, HANDY_BREITEN),
sizes,
width: 780,
height: hoehe,
alt,
});
type Funktion = {
id: string;
titel: string;
unterschrift: string;
/** Desktop-Bild; Schritt 6 hat keines, dort blendet das Fenster aus. */
desktop?: { name: string; alt: string };
handy: { name: string; alt: string };
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
lang?: boolean;
};
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
const FUNKTIONEN: Funktion[] = [
{
id: "live-vorschau",
titel: "Live-Vorschau",
unterschrift: "Ihre Website, noch während wir daran bauen.",
desktop: { name: "desktop-1-projekte", alt: "Kundenportal: Projektübersicht mit Live-Vorschau der Website" },
handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" },
},
{
id: "fortschritt",
titel: "Fortschritt",
unterschrift: "Was wir zuletzt erledigt haben – in einfachen Sätzen.",
desktop: {
name: "desktop-2-fortschritt",
alt: "Kundenportal: zuletzt erledigte Arbeiten am Auftrag, in einfachen Sätzen beschrieben",
},
handy: {
name: "phone-3-projektdetails",
alt: "Kundenportal auf dem Handy: Projektdetails mit den letzten Änderungen",
},
},
{
id: "chat",
titel: "Chat",
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" },
handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" },
},
{
id: "rechnungen",
titel: "Rechnungen",
unterschrift: "Ansehen, bezahlen, herunterladen.",
desktop: { name: "desktop-4-rechnungen", alt: "Kundenportal: Rechnungen mit Betrag und Status" },
handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" },
},
{
id: "funktionen",
titel: "Funktionen",
unterschrift: "Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.",
desktop: {
name: "desktop-7-news-pinnwand",
alt: "Kundenportal: News-Pinnwand mit Vorschau, wie die Meldung auf der Website aussieht",
},
handy: {
name: "phone-8-news-vorschau",
alt: "Kundenportal auf dem Handy: Vorschau der News-Pinnwand für die Website",
},
},
{
id: "als-app",
titel: "Als App",
unterschrift: "Aufs Handy installieren, fertig.",
handy: {
name: "phone-2-projekte-lang",
alt: "Kundenportal als App auf dem Handy: lange Projektseite mit Vorschau, Status und letzten Änderungen",
},
lang: true,
},
];
const LETZTER = FUNKTIONEN.length - 1;
const SCHRITTE = [
{ nr: 1, text: "Kennenlernen" },
{ nr: 2, text: "Angebot mit Preis" },
{ nr: 3, text: "Bauen mit Vorschau und Ihrer Freigabe" },
{ nr: 4, text: "Online – Hosting und Pflege auf Wunsch im Abo" },
];
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
/** Medienabfrage als Wert. Ohne matchMedia (SSR) gilt `standard`. */
const useMedia = (abfrage: string, standard = false) =>
useSyncExternalStore(
(melden) => {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(abfrage);
mq.addEventListener?.("change", melden);
return () => mq.removeEventListener?.("change", melden);
},
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard),
() => standard,
);
const begrenze = (wert: number, min: number, max: number) => Math.min(max, Math.max(min, wert));
// ── Desktop: festgepinnte Bühne ─────────────────────────────────────────────
/**
* Die Hülle ist hoch (Scrollstrecke), darin klebt die Bühne (position: sticky).
* Hinter der Bühne liegt eine unsichtbare Spur aus Markern, eine Marke je Funktion. Der IntersectionObserver
* meldet, welche Marke gerade die Bildschirmmitte kreuzt: das ist die aktive Funktion, das Bild blendet über.
* Beim letzten Schritt wird der Scrollfortschritt als CSS-Variable --kp-q (0 bis 1) an die Bühne geschrieben,
* und das CSS schiebt den langen Screenshot per translateY nach oben (nur transform).
*/
const Buehne = () => {
const [aktiv, setAktiv] = useState(0);
// Bilder werden erst eingehängt, wenn ihr Schritt (oder der davor) erreicht ist: spart Daten beim ersten Erreichen
const [hoechster, setHoechster] = useState(0);
const huelle = useRef<HTMLDivElement>(null);
const buehne = useRef<HTMLDivElement>(null);
const marker = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(
(eintraege) => {
for (const e of eintraege) {
if (!e.isIntersecting) continue;
const i = Number((e.target as HTMLElement).dataset.index);
setAktiv(i);
setHoechster((h) => Math.max(h, i));
}
},
// Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 },
);
marker.current.forEach((m) => m && beobachter.observe(m));
return () => beobachter.disconnect();
}, []);
// Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot
useEffect(() => {
const el = buehne.current;
const huellenEl = huelle.current;
const letzterMarker = marker.current[LETZTER];
if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return;
const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)");
let raf = 0;
const berechne = () => {
raf = 0;
if (!ruhig.matches) {
el.style.setProperty("--kp-q", "0");
return;
}
const vh = window.innerHeight;
const huelleBox = huellenEl.getBoundingClientRect();
if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return;
const oben = parseFloat(getComputedStyle(el).top) || 0;
const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften
const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke
const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top;
const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts
const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
el.style.setProperty("--kp-q", q.toFixed(4));
};
const planen = () => {
if (!raf) raf = requestAnimationFrame(berechne);
};
window.addEventListener("scroll", planen, { passive: true });
window.addEventListener("resize", planen);
ruhig.addEventListener?.("change", planen);
berechne();
return () => {
window.removeEventListener("scroll", planen);
window.removeEventListener("resize", planen);
ruhig.removeEventListener?.("change", planen);
if (raf) cancelAnimationFrame(raf);
};
}, []);
/** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */
const springe = (i: number) => {
const huellenEl = huelle.current;
const buehnenEl = buehne.current;
const m = marker.current[i];
if (!huellenEl || !buehnenEl || !m) return;
const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0;
const huelleOben = huellenEl.getBoundingClientRect().top;
// Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist
const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1);
const zurBuehne = huelleOben - oben;
window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) });
};
const app = aktiv === LETZTER;
const eingehaengt = (i: number) => i <= hoechster + 1;
return (
<div className="kp-huelle" ref={huelle}>
<div className="kp-spur" aria-hidden="true">
{FUNKTIONEN.map((f, i) => (
<div key={f.id} className="kp-marker" data-index={i} ref={(el) => (marker.current[i] = el)} />
))}
</div>
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"}>
<div className="wk-container kp-buehne__innen">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
{FUNKTIONEN.map((f, i) => (
<li key={f.id}>
<button
type="button"
className="kp-eintrag"
aria-current={i === aktiv ? "step" : undefined}
onClick={() => springe(i)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-eintrag__titel">{f.titel}</span>
<span className="kp-eintrag__text">{f.unterschrift}</span>
</button>
</li>
))}
</ol>
<div className="kp-geraete">
<DesktopFenster className="kp-fenster">
<div className="kp-ebenen" aria-hidden={app || undefined}>
{FUNKTIONEN.map((f, i) =>
f.desktop && eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
</div>
) : null,
)}
</div>
</DesktopFenster>
<HandyRahmen className="kp-handy">
{FUNKTIONEN.map((f, i) =>
eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
{f.lang ? (
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 340px, 30vw", 5428)} />
</div>
) : (
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
)}
</div>
) : null,
)}
</HandyRahmen>
</div>
</div>
</div>
</div>
</section>
);
};
// ── Handy: Wisch-Reihe ───────────────────────────────────────────────────────
/** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */
const LangeKarte = ({ f }: { f: Funktion }) => {
const ref = useRef<HTMLDivElement>(null);
const [sichtbar, setSichtbar] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 });
beobachter.observe(el);
return () => beobachter.disconnect();
}, []);
return (
<div className="kp-karte" ref={ref} data-sichtbar={sichtbar ? "ja" : "nein"}>
<HandyRahmen className="kp-karte__handy">
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px", 5428)} />
</div>
</HandyRahmen>
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
</div>
);
};
const HandyReihe = () => (
<div className="kp-reihe">
<WischReihe ariaLabel="Sechs Funktionen des Kundenportals, seitlich wischen">
{FUNKTIONEN.map((f) =>
f.lang ? (
<LangeKarte key={f.id} f={f} />
) : (
<div key={f.id} className="kp-karte">
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
</div>
),
)}
</WischReihe>
</div>
);
// ── Schrittzeile ──────────────────────────────────────────────────────────────
const Schrittzeile = ({ desktop }: { desktop: boolean }) =>
desktop ? (
<div className="wk-container">
<ol className="kp-schritte wk-reveal" aria-label="So arbeiten wir">
{SCHRITTE.map((s) => (
<li key={s.nr} className="kp-schritt">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</li>
))}
</ol>
</div>
) : (
<div className="kp-schritte-handy">
<WischReihe ariaLabel="So arbeiten wir, vier Schritte, seitlich wischen" className="kp-schritte-reihe">
{SCHRITTE.map((s) => (
<div key={s.nr} className="kp-schritt-karte">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</div>
))}
</WischReihe>
</div>
);
// ── Abschnitt ─────────────────────────────────────────────────────────────────
const Kundenportal = () => {
const desktop = useMedia("(min-width: 900px)");
return (
<section id="kundenportal" className="wk-section wk-section--alt kp" aria-labelledby="kundenportal-titel">
<div className="wk-container kp__kopf wk-reveal">
<h2 id="kundenportal-titel" className="wk-h2">
{/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */}
<span className="kp-satz">Ihr Projekt.</span> <span className="kp-satz">Live im Kundenportal.</span>
</h2>
<p className="wk-lead">{EINLEITUNG}</p>
</div>
{desktop ? <Buehne /> : <HandyReihe />}
<Schrittzeile desktop={desktop} />
</section>
);
};
export default Kundenportal;
+122 -7
View File
@@ -1,10 +1,125 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Leistungen = () => (
<section id="leistungen" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Leistungen</h2>
import { Fragment, useSyncExternalStore, type CSSProperties } from "react";
import WischReihe from "@/components/WischReihe";
import "./leistungen.css";
/** Spezifikation 7.3 (Standardtext; die Variante für O2 = ja ist noch nicht freigegeben). */
const EINLEITUNG = "Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr.";
const LEISTUNGEN = [
{
titel: "Website",
text: "Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online.",
},
{
titel: "Online-Shop",
text: "Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft.",
},
{
titel: "Web-App",
text: "Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.",
},
{
titel: "Automatisierung",
text: "Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.",
},
] as const;
/** Breakpoint für die Desktop-Darstellung (Spezifikation 9.4). */
const DESKTOP = "(min-width: 900px)";
/** Nur eine Darstellung im DOM: Liste ab 900 px, Wisch-Reihe darunter. Ohne matchMedia gilt die Handy-Ansicht. */
const useDesktop = (): boolean =>
useSyncExternalStore(
(melden) => {
if (typeof window.matchMedia !== "function") return () => {};
const abfrage = window.matchMedia(DESKTOP);
abfrage.addEventListener("change", melden);
return () => abfrage.removeEventListener("change", melden);
},
() => typeof window.matchMedia === "function" && window.matchMedia(DESKTOP).matches,
() => false,
);
/** „01“, „02“ … in Tabellenziffern. */
const nummer = (index: number) => String(index + 1).padStart(2, "0");
/**
* Einleitung, die sich beim Scrollen Wort für Wort aufhellt (Spezifikation 7.3, 8.4).
* Für Screenreader steht der ganze Satz einmal unsichtbar im Text, die Wort-Spans sind aria-hidden.
* Ohne `animation-timeline` oder mit reduzierter Bewegung (siehe leistungen.css) steht der Satz einfach da.
*/
const Einleitung = () => {
const woerter = EINLEITUNG.split(" ");
return (
<p className="wk-leistungen__einleitung">
<span className="wk-visually-hidden">{EINLEITUNG}</span>
<span aria-hidden="true">
{woerter.map((wort, i) => (
<Fragment key={i}>
<span className="wk-leistungen__wort" style={{ "--i": i, "--n": woerter.length } as CSSProperties}>
{wort}
</span>
{i < woerter.length - 1 ? " " : null}
</Fragment>
))}
</span>
</p>
);
};
const Eintrag = ({ index }: { index: number }) => {
const { titel, text } = LEISTUNGEN[index];
return (
<div className="wk-leistung">
<span className="wk-leistung__nr wk-num" aria-hidden="true">
{nummer(index)}
</span>
<div className="wk-leistung__inhalt">
<h3 className="wk-h3 wk-leistung__titel">{titel}</h3>
<p className="wk-leistung__text">{text}</p>
</div>
</div>
</section>
);
);
};
/** Spezifikation 7.3: vier nummerierte Textzeilen statt Karten mit Symbolen. */
const Leistungen = () => {
const desktop = useDesktop();
return (
<section id="leistungen" className="wk-section wk-leistungen" aria-labelledby="leistungen-titel">
<div className="wk-container wk-leistungen__raster">
<div className="wk-leistungen__kopf">
<h2 id="leistungen-titel" className="wk-h2 wk-reveal">
Was wir für Sie bauen.
</h2>
<Einleitung />
</div>
{desktop ? (
<ol className="wk-leistungen__liste" role="list">
{LEISTUNGEN.map((l, i) => (
<li key={l.titel} className="wk-leistungen__zeile wk-reveal">
<Eintrag index={i} />
</li>
))}
</ol>
) : (
<div className="wk-leistungen__wisch">
<WischReihe ariaLabel="Vier Leistungen, seitlich wischen">
{LEISTUNGEN.map((l, i) => (
<Eintrag key={l.titel} index={i} />
))}
</WischReihe>
</div>
)}
<p className="wk-leistungen__umzug wk-reveal">
<strong>Sie haben schon eine Website?</strong> Wir ziehen sie um – mit Domain, Inhalten und Postfächern.
</p>
</div>
</section>
);
};
export default Leistungen;
+50 -8
View File
@@ -1,10 +1,52 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Preise = () => (
<section id="preise" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Preise</h2>
</div>
</section>
);
import { Link } from "react-router-dom";
import { ABOS, preisText, weitereArbeitTeile } from "@/data/abos";
import "./preise.css";
/** Spezifikation 7.5 (Standardtext; der Preisanker für O1 ist noch nicht freigegeben). */
const EINLEITUNG =
"Für Ihr Projekt bekommen Sie vorher ein Angebot mit Preis. Für den Betrieb danach gibt es zwei Abos. " +
"Alle Preise sind Endpreise; als Kleinunternehmer (§\u00a019\u00a0UStG) berechnen wir keine Umsatzsteuer.";
/** Spezifikation 7.5: zwei Abos nebeneinander (Handy untereinander). Zahlen kommen aus src/data/abos.ts. */
const Preise = () => {
const [vorher, schritte, nachher] = weitereArbeitTeile();
return (
<section id="preise" className="wk-section wk-section--alt wk-preise" aria-labelledby="preise-titel">
<div className="wk-container">
<h2 id="preise-titel" className="wk-h2 wk-reveal">
Was es kostet. Vorher.
</h2>
<p className="wk-lead wk-reveal">{EINLEITUNG}</p>
<div className="wk-preise__raster">
{ABOS.map((abo) => (
<article key={abo.id} className="wk-abo wk-reveal" aria-labelledby={`abo-${abo.id}`}>
<h3 id={`abo-${abo.id}`} className="wk-h3 wk-abo__name">
{abo.name}
</h3>
<p className="wk-abo__preis">
<span className="wk-abo__betrag wk-num">{preisText(abo.preis)}</span>{" "}
<span className="wk-abo__einheit">im Monat</span>
</p>
<p className="wk-abo__text">{abo.beschreibung}</p>
<p className="wk-abo__aktion">
<Link to={abo.link.to} className="wk-link wk-abo__link">
{abo.link.text}
</Link>
</p>
</article>
))}
</div>
<p className="wk-preise__weitere wk-reveal">
{vorher}
<span className="wk-preise__nicht-trennen">{schritte}</span>
{nachher}
</p>
</div>
</section>
);
};
export default Preise;
+174
View File
@@ -0,0 +1,174 @@
/* Hero mit Deutschland-Karte (Spezifikation 7.1): ruhige Fläche, große Aussage links, Karte rechts */
.wk-hero {
background: var(--bg);
padding-block: clamp(28px, 5svh, 56px) clamp(40px, 7svh, 72px);
/* Sicherheitsnetz: eine unerwartet breite Ersatzschrift darf keine seitliche Rollleiste erzeugen */
overflow-x: clip;
}
.wk-hero__raster {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: clamp(32px, 6svh, 48px);
}
/* ── Text ──────────────────────────────────────────────────────────────── */
/* Die Spalte ist Bezugsgröße (cqi) für die Überschrift: die längste Variante passt immer hinein */
.wk-hero__text {
container-type: inline-size;
min-width: 0;
}
.wk-hero__zeile {
font-size: 1rem;
margin-bottom: 1.25rem;
}
/* Schriftgröße: so groß wie --fs-hero, aber nie breiter als die Spalte. 10,1 = Breite von
„Ihre Automatisierung.“ in Schriftgrößen (Instrument Sans 600: 9,7; dazu etwas Reserve für die Systemschrift).
Ohne Container-Einheiten (ältere Browser) gilt eine Näherung über die Fensterbreite. */
.wk-hero__kopf {
position: relative;
width: fit-content;
max-width: 100%;
font-size: min(var(--fs-hero), 8.8vw, 3.4rem);
}
@supports (width: 1cqi) {
.wk-hero__kopf {
font-size: min(var(--fs-hero), calc(100cqi / 10.1));
}
/* Systemschrift (Variante A im Entwurf) ist breiter als Instrument Sans: 11 statt 10,1 */
html[data-schrift="a"] .wk-hero__kopf {
font-size: min(var(--fs-hero), calc(100cqi / 11));
}
}
.wk-hero .wk-hero__h1 {
font-size: 1em;
line-height: 1.06;
letter-spacing: -0.025em;
}
.wk-hero__h1:focus {
outline: none;
}
.wk-hero__sichtbar {
display: block;
}
/* Betonung ohne Farbspiel: „Wir bauen“ leise (400, gedämpft), der wechselnde Teil kräftig (600, volle Textfarbe) */
.wk-hero__fest {
display: block;
font-weight: 400;
color: var(--fg-muted);
}
/* Alle Varianten liegen in einer Gitterzelle übereinander: Breite und Höhe richten sich nach der längsten,
nichts springt */
.wk-hero__wechsel {
display: grid;
width: max-content;
max-width: 100%;
}
.wk-hero__variante {
grid-area: 1 / 1;
font-weight: 600;
color: var(--fg);
white-space: nowrap;
opacity: 0;
}
.wk-hero__variante[data-aktiv="true"] {
opacity: 1;
}
/* Überblendung 400 ms, nur Deckkraft und ein kleiner Versatz; ohne Bewegung wechselt nichts */
@media (prefers-reduced-motion: no-preference) {
.wk-hero__variante {
transform: translateY(0.12em);
transition:
opacity 400ms var(--ease),
transform 400ms var(--ease);
}
.wk-hero__variante[data-aktiv="true"] {
transform: none;
}
}
/* Pause-Knopf: rechts neben „Wir bauen“, Tippfläche 44 × 44 px, das Symbol selbst bleibt klein.
Die Zeile ist 1,06 em hoch, der Knopf sitzt mittig darin. */
.wk-hero__pause {
position: absolute;
top: calc((1.06em - 44px) / 2);
left: 5.3em;
display: grid;
place-items: center;
width: 44px;
height: 44px;
color: var(--fg-muted);
-webkit-tap-highlight-color: transparent;
transition: color var(--dur-state) var(--ease);
}
.wk-hero__pause-symbol {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--line-strong);
}
@media (hover: hover) and (pointer: fine) {
.wk-hero__pause:hover {
color: var(--fg);
}
.wk-hero__pause:hover .wk-hero__pause-symbol {
box-shadow: inset 0 0 0 1px var(--fg);
}
}
.wk-hero__lead {
margin-top: 1.5rem;
max-width: 30em;
font-size: clamp(1.1875rem, 1.1rem + 0.35vw, 1.3125rem);
}
.wk-hero__aktionen {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2rem;
}
.wk-hero__aktionen .wk-btn {
flex: 1 1 auto;
}
/* ── Karte ─────────────────────────────────────────────────────────────── */
/* Handy: unter dem Text, mittig, 40–46 svh hoch. Die Untergrenze verhindert eine winzige Karte im Querformat. */
.wk-hero__karte {
--karte-hoehe: clamp(320px, 44svh, 520px);
width: min(100%, calc(var(--karte-hoehe) * 663 / 892));
margin-inline: auto;
}
/* ── Desktop ───────────────────────────────────────────────────────────── */
@media (min-width: 900px) {
/* Der ganze Hero passt auf einen Bildschirm (unter der 60 px hohen, mitlaufenden Kopfleiste) */
.wk-hero {
display: flex;
align-items: center;
min-height: calc(100svh - var(--header-h) - 1px); /* 1 px = Trennlinie der Kopfleiste */
padding-block: clamp(24px, 4svh, 48px);
}
.wk-hero__raster {
grid-template-columns: minmax(0, min(560px, 58%)) minmax(0, 1fr);
align-items: center;
column-gap: clamp(24px, 3vw, 44px);
}
.wk-hero__aktionen .wk-btn {
flex: 0 0 auto;
}
/* Karte etwa 80 svh hoch, soweit die Spalte breit genug ist */
.wk-hero__karte {
--karte-hoehe: 80svh;
}
}
+332
View File
@@ -0,0 +1,332 @@
/*
* Abschnitt Kundenportal (Spezifikation 7.2).
* Bewegung: nur transform und opacity, alles hinter prefers-reduced-motion: no-preference,
* der Grundzustand ist statisch und vollständig sichtbar. Hover nur mit Maus.
* Vorfahren der sticky-Bühne nutzen kein overflow: hidden (8.4).
*/
.kp {
/* Länge eines Funktionsschritts auf der Bühne; fünf davon plus der letzte Schritt ergeben die Scrollstrecke */
--kp-s: 45svh;
--kp-ende: 120svh;
--kp-oben: calc(var(--header-h) + 24px);
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
}
@media (prefers-reduced-motion: reduce) {
.kp {
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
--kp-ende: 50svh;
}
}
.kp__kopf {
margin-bottom: clamp(40px, 5vw, 72px);
}
.kp__kopf .wk-h2 {
max-width: none;
}
.kp-satz {
display: block;
}
/* ── Desktop-Bühne (ab 900 px) ────────────────────────────────────────── */
.kp-huelle {
position: relative;
height: calc(var(--kp-buehne-h) + 5 * var(--kp-s) + var(--kp-ende));
}
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
.kp-spur {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
pointer-events: none;
}
.kp-marker {
flex: 0 0 var(--kp-s);
}
/* Die erste Marke reicht bis zur Mitte des Bildschirms, wenn die Bühne anklebt: Schritt 1 ist beim Ankommen aktiv */
.kp-marker:first-child {
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-s));
}
.kp-marker:last-child {
flex: 1 1 auto;
}
.kp-buehne {
position: sticky;
top: var(--kp-oben);
height: var(--kp-buehne-h);
display: flex;
align-items: center;
}
.kp-buehne__innen {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
gap: clamp(32px, 5vw, 64px);
align-items: center;
}
/* Liste der Funktionen */
.kp-liste {
display: flex;
flex-direction: column;
gap: 6px;
}
.kp-eintrag {
position: relative;
display: block;
width: 100%;
min-height: 44px;
padding: 10px 0;
text-align: left;
color: var(--fg-muted);
-webkit-tap-highlight-color: transparent;
}
.kp-eintrag__titel {
display: block;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
transition: color var(--dur-state) var(--ease);
}
.kp-eintrag__text {
display: block;
margin-top: 4px;
font-size: 0.9375rem;
line-height: 1.4;
text-wrap: pretty;
}
.kp-eintrag[aria-current="step"] .kp-eintrag__titel {
color: var(--fg);
}
/* kleiner roter Punkt als Marker der aktiven Funktion (hängt im Rand links vom Text) */
.kp-eintrag__punkt {
position: absolute;
left: -20px;
top: 19px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
transform: scale(0.4);
}
.kp-eintrag[aria-current="step"] .kp-eintrag__punkt {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: no-preference) {
.kp-eintrag__punkt {
transition:
opacity var(--dur-state) var(--ease),
transform var(--dur-state) var(--ease);
}
}
@media (hover: hover) and (pointer: fine) {
.kp-eintrag:hover .kp-eintrag__titel {
color: var(--fg);
}
}
/* niedrige Fenster: die Liste wird enger, damit sie in die Bühne passt (Text bleibt mindestens 14 px) */
@media (max-height: 800px) {
.kp-liste {
gap: 2px;
}
.kp-eintrag {
padding: 6px 0;
}
.kp-eintrag__titel {
font-size: 1.25rem;
}
.kp-eintrag__text {
margin-top: 2px;
font-size: var(--fs-small);
}
.kp-eintrag__punkt {
top: 15px;
}
}
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift */
@media (max-height: 620px) {
.kp-eintrag:not([aria-current="step"]) .kp-eintrag__text {
display: none;
}
}
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
.kp-geraete {
/* Breite so, dass die Geräte nie höher werden als die Bühne (Seitenverhältnis 100 : 95) */
width: min(100%, calc((var(--kp-buehne-h) - 8px) / 0.95));
aspect-ratio: 100 / 95;
justify-self: end;
position: relative;
/* Bezugsgröße (cqw) für die Verschiebung des Handys im letzten Schritt */
container-type: inline-size;
}
.kp-fenster {
position: absolute;
left: 0;
top: 0;
width: 94%;
}
.kp-handy {
position: absolute;
right: 0;
bottom: 0;
width: 30%;
}
/* Ebenen, die per Überblendung wechseln (nur die aktive ist sichtbar) */
.kp-ebenen {
position: absolute;
inset: 0;
}
.kp-lage {
position: absolute;
inset: 0;
opacity: 0;
}
.kp-lage.is-aktiv {
opacity: 1;
}
.kp-lage img {
display: block;
width: 100%;
height: auto;
}
/* Schritt 6 "Als App": Fenster blendet aus, das Handy rückt in die Mitte und wird größer.
Ohne reduzierte Bewegung geschieht das weich, sonst wird einfach getauscht. */
.kp-buehne[data-app="ja"] .kp-fenster {
opacity: 0;
pointer-events: none;
}
.kp-buehne[data-app="ja"] .kp-handy {
transform: translate(-35cqw, -16.75cqw) scale(1.5);
}
/* Der lange Screenshot: gleiche Breite wie der Schirm, 68,9 % seiner Höhe wandern aus dem Bild
((5428 - 1688) / 5428). Die Verschiebung hängt am Scrollfortschritt (--kp-q), kein eigener Scrollbereich. */
.kp-lang {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.kp-lang img {
display: block;
width: 100%;
height: auto;
}
@media (prefers-reduced-motion: no-preference) {
.kp-lage,
.kp-fenster {
transition: opacity var(--dur-enter) var(--ease);
}
.kp-handy {
transition: transform var(--dur-enter) var(--ease);
}
.kp-buehne .kp-lang {
transform: translateY(calc(var(--kp-q, 0) * -68.9%));
}
/* die große Ebene nur im letzten Schritt auf die Grafikkarte legen */
.kp-buehne[data-app="ja"] .kp-lang {
will-change: transform;
}
}
/* ── Handy: Wisch-Reihe (unter 900 px) ─────────────────────────────────── */
.kp-karte {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.kp-karte__handy {
width: min(100%, 250px);
margin-bottom: 1.5rem;
}
.kp-karte__titel {
margin-bottom: 0.35rem;
}
.kp-karte__text {
max-width: 26ch;
}
/* "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte zu sehen ist */
/* (.kp-lang liegt absolut im Schirm; was unten herausragt, schneidet der Rahmen ab) */
@media (prefers-reduced-motion: no-preference) {
.kp-karte .kp-lang {
animation: kp-hoch 18s linear both paused;
}
.kp-karte[data-sichtbar="ja"] .kp-lang {
animation-play-state: running;
}
}
@keyframes kp-hoch {
0%,
6% {
transform: translateY(0);
}
100% {
transform: translateY(-68.9%);
}
}
/* ── Schrittzeile ──────────────────────────────────────────────────────── */
.kp-schritte {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 clamp(24px, 3vw, 40px);
margin-top: clamp(24px, 3vw, 40px);
}
.kp-schritt {
padding-top: 1.1rem;
border-top: 1px solid var(--line-strong);
}
.kp-schritt__nr {
display: block;
margin-bottom: 0.4rem;
font-size: var(--fs-small);
font-weight: 600;
line-height: 1.2;
color: var(--fg-muted);
}
.kp-schritt__text {
display: block;
font-size: 1.1875rem;
line-height: 1.3;
color: var(--fg);
text-wrap: balance;
}
/* Handy: vier kleine Karten in einer Wisch-Reihe */
.kp-schritte-handy {
margin-top: clamp(40px, 10vw, 64px);
}
.kp-schritte-reihe .wk-wisch__karte {
flex-basis: 62vw;
max-width: 250px;
}
.kp-schritt-karte {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1.5rem;
height: 100%;
min-height: 132px;
padding: 1.25rem;
border-radius: 18px;
background: var(--bg);
}
.kp-schritt-karte .kp-schritt__nr {
margin-bottom: 0;
}
+127
View File
@@ -0,0 +1,127 @@
/* Was wir bauen (Spezifikation 7.3): Überschrift links, nummerierte Textzeilen rechts, ab 900 px.
Darunter eine Wisch-Reihe. Keine Karten mit Symbolen, nur dünne Linien und viel Weißraum. */
.wk-leistungen__raster {
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 2.5rem;
}
.wk-leistungen__kopf .wk-h2 {
max-width: 10ch;
}
/* Einleitung: Endzustand ist voller Text; die Wörter dimmen nur, solange die Scroll-Animation läuft */
.wk-leistungen__einleitung {
margin-top: 1.2em;
max-width: 24rem;
font-size: var(--fs-lead);
line-height: 1.38;
color: var(--fg);
text-wrap: pretty;
}
/* ── Einträge ──────────────────────────────────────────────────────────── */
.wk-leistung__nr {
display: block;
font-size: var(--fs-small);
font-weight: 600;
line-height: 1.43;
color: var(--fg-muted);
}
.wk-leistung__text {
margin-top: 0.55rem;
color: var(--fg-muted);
}
.wk-leistungen__umzug {
max-width: 34rem;
color: var(--fg-muted);
}
.wk-leistungen__umzug strong {
font-weight: 600;
color: var(--fg);
}
/* Handy: Wisch-Reihe über die volle Breite, Karten mit dünner Linie oben */
.wk-leistungen__wisch {
margin-inline: calc(-1 * var(--gutter));
}
.wk-leistungen__wisch .wk-leistung {
height: 100%;
padding-top: 1.25rem;
padding-right: 0.5rem;
border-top: 1px solid var(--line-strong);
}
.wk-leistungen__wisch .wk-leistung__nr {
margin-bottom: 2rem;
}
/* Desktop: zwei Spalten, Liste rechts */
@media (min-width: 900px) {
.wk-leistungen__raster {
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
column-gap: clamp(3rem, 7vw, 7rem);
row-gap: 0;
}
.wk-leistungen__kopf {
grid-column: 1;
grid-row: 1 / span 2;
}
.wk-leistungen__liste {
grid-column: 2;
grid-row: 1;
list-style: none;
margin: 0;
padding: 0;
/* die erste Linie steht auf Höhe der Überschrift */
margin-top: 0.6rem;
}
.wk-leistungen__zeile {
border-top: 1px solid var(--line);
}
.wk-leistungen__zeile:last-child {
border-bottom: 1px solid var(--line);
}
.wk-leistungen__zeile .wk-leistung {
display: grid;
grid-template-columns: 3.5rem minmax(0, 1fr);
column-gap: 1rem;
align-items: baseline;
padding-block: 2.1rem 2.35rem;
}
.wk-leistungen__zeile .wk-leistung__text {
max-width: 32rem;
}
.wk-leistungen__umzug {
grid-column: 2;
grid-row: 2;
padding-top: 2.25rem;
padding-left: 4.5rem;
}
}
/* ── Wort für Wort aufhellen: nur mit Scroll-Animationen und ohne reduzierte Bewegung ───────────
Alle Wörter hängen an der Ansichts-Zeitleiste des Absatzes, jedes Wort bekommt einen etwas
späteren Abschnitt. Gedimmt 0,18 → voll; nur opacity, wie in Spezifikation 8.4 vorgesehen. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.wk-leistungen__einleitung {
view-timeline: --wk-leistungen-einleitung block;
}
.wk-leistungen__wort {
animation: wk-leistungen-wort linear both;
animation-timeline: --wk-leistungen-einleitung;
animation-range: cover calc(12% + var(--i) * 24% / var(--n)) cover calc(24% + var(--i) * 24% / var(--n));
}
}
}
@keyframes wk-leistungen-wort {
from {
opacity: 0.18;
}
to {
opacity: 1;
}
}
+84
View File
@@ -0,0 +1,84 @@
/* Preise (Spezifikation 7.5): zwei ruhige Spalten mit dünner Linie oben, große Preiszahlen in
Tabellenziffern, nichts zählt hoch. Der Abschnitt steht auf --bg-alt, die Blöcke brauchen keine Kästen. */
.wk-preise__raster {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 3.5rem;
margin-top: clamp(3rem, 6vw, 5.5rem);
}
.wk-abo {
display: flex;
flex-direction: column;
padding-top: 1.5rem;
border-top: 1px solid var(--line-strong);
}
.wk-abo__name {
color: var(--fg-muted);
}
.wk-abo__preis {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.7rem;
margin-top: 1.1rem;
}
.wk-abo__betrag {
font-size: clamp(3.5rem, 2.2rem + 4.8vw, 6rem);
font-weight: 600;
line-height: 1;
letter-spacing: -0.035em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
/* optischer Randausgleich: die große Ziffer steht sonst etwas eingerückt */
margin-left: -0.04em;
}
.wk-abo__einheit {
font-size: var(--fs-lead);
line-height: 1.2;
color: var(--fg-muted);
}
.wk-abo__text {
margin-top: 1.75rem;
max-width: 30rem;
}
.wk-abo__aktion {
margin-top: auto;
padding-top: 1.25rem;
}
/* Tippfläche mindestens 44 px hoch */
.wk-abo__link {
display: inline-flex;
align-items: center;
min-height: 44px;
}
.wk-preise__weitere {
max-width: var(--measure-text);
margin-top: clamp(3rem, 5vw, 4.5rem);
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-size: var(--fs-lead);
line-height: 1.38;
text-wrap: pretty;
}
/* „15-Minuten-Schritten“ nicht am Bindestrich umbrechen */
.wk-preise__nicht-trennen {
white-space: nowrap;
}
@media (min-width: 900px) {
.wk-preise__raster {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: clamp(3rem, 6vw, 6rem);
}
.wk-preise__weitere {
max-width: none;
}
}