Bühne: „Funktionen“ als aufklappender Überpunkt, Heatmap mit Klicks auch außerhalb der Tageskarte

- Liste: Überpunkt „Funktionen“ (Bausteine für Ihre Website). Er klappt auf, sobald
  Terminplaner, Website-Analyse oder News-Pinnwand erreicht ist, und danach wieder zu.
  Die drei Unterpunkte zeigen nur ihren Titel (keine Stichpunkte); die Liste steht oben
  in der Bühne, damit beim Aufklappen nichts darüber springt.
- Heatmap der Boulderhalle: jede Zone bekommt eine Mindestzahl Klickpunkte (Telefon,
  Menü, Routenbild, Karten sind sichtbar), die Tageskarte bleibt klar am heißesten.
  Telefonnummer 96 statt 61 Klicks (getauscht mit Öffnungszeiten, Summe bleibt 1.284).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-04 15:15:47 +00:00
1 parent f0bced164b
commit 9914002e74
5 files changed
+164 -57

No files matched your search

+51 -21
View File
@@ -174,7 +174,10 @@ const LETZTER = FUNKTIONEN.length - 1;
/** Stationen vor "Als App": normale Schritte und längere Demo-Schritte (bestimmen die Scrollstrecke der Hülle). */
const ANZAHL_DEMO = FUNKTIONEN.filter((f) => f.demo).length;
const ANZAHL_NORMAL = LETZTER - ANZAHL_DEMO;
const GRUPPE_TITEL = "Weitere Funktionen";
/** Überpunkt der drei Funktionen in der Liste der Bühne (Wunsch des Inhabers 2026-10-04): klappt auf, sobald eine
* der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */
const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." };
const DEMO_INDIZES = FUNKTIONEN.flatMap((f, i) => (f.demo ? [i] : []));
// ── Demos (eigener Chunk, erst geladen, wenn die Bühne die Station fast erreicht) ──────────────────────
@@ -424,27 +427,54 @@ const Buehne = () => {
<div className="wk-container kp-buehne__innen">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
{FUNKTIONEN.map((f, i) => {
const eintrag = (
<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">
<Unterschrift f={f} />
</span>
</button>
);
if (!f.demo) return <li key={f.id}>{eintrag}</li>;
// die drei Funktionen stehen in einer Gruppe mit eigener Überschrift
const erste = !FUNKTIONEN[i - 1]?.demo;
if (!f.demo) {
return (
<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">
<Unterschrift f={f} />
</span>
</button>
</li>
);
}
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
if (i !== DEMO_INDIZES[0]) return null;
const offen = aktivDemo !== undefined;
return (
<li key={f.id} className="kp-gruppe" data-erste={erste ? "ja" : undefined}>
{erste ? <p className="kp-gruppe__titel">{GRUPPE_TITEL}</p> : null}
{eintrag}
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"}>
<button
type="button"
className="kp-eintrag kp-eintrag--gruppe"
aria-expanded={offen}
aria-controls="kp-unterliste"
onClick={() => springe(DEMO_INDIZES[0])}
>
<span className="kp-eintrag__titel">{GRUPPE.titel}</span>
<span className="kp-eintrag__text">{GRUPPE.text}</span>
</button>
<ol id="kp-unterliste" className="kp-unterliste" aria-label={GRUPPE.titel} hidden={!offen}>
{DEMO_INDIZES.map((d) => (
<li key={FUNKTIONEN[d].id}>
<button
type="button"
className="kp-untereintrag"
aria-current={d === aktiv ? "step" : undefined}
onClick={() => springe(d)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-untereintrag__titel">{FUNKTIONEN[d].titel}</span>
</button>
</li>
))}
</ol>
</li>
);
})}
@@ -318,9 +318,9 @@ const useGemessen = (ref: RefObject<HTMLElement | null>, variante: DemoVariante)
/**
* Wie viele Punkte eine Zone bekommt. Die Skala der Heatmap sättigt schon bei zwei, drei überlagerten Flecken (rot);
* damit die Farbe die Rangfolge zeigt, steigt die Dichte mit dem Quadrat des Klickanteils (Tageskarte = 1):
* nur die meistgeklickte Stelle wird durchgehend rot, Menü und Telefon bleiben gelb bis grün.
* Große Zonen bekommen mehr Punkte, damit ihre Fläche nicht leer wirkt.
* die Dichte steigt darum steil mit dem Klickanteil (Tageskarte = 1, nur sie wird durchgehend rot).
* Jede Zone bekommt aber mindestens so viele Punkte, dass ihre Klicks sichtbar sind (Wunsch des Inhabers 2026-10-04:
* nicht nur Punkte auf „Tageskarte kaufen“, auch z. B. die Telefonnummer). Große Zonen bekommen mehr Punkte.
*/
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const anteil = zone.klicks / TOP_ZONE.klicks;
@@ -328,7 +328,8 @@ const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const fleck = Math.PI * (2 * LEINWAND[variante].radius) ** 2;
const flecke = Math.max(r.b * r.h, 2 * fleck) / (2 * fleck);
const obergrenze = Math.max(1, Math.round(zone.klicks / KLICKS_JE_PUNKT[variante]));
return Math.min(obergrenze, Math.max(1, Math.round(anteil ** 2 * 8 * flecke)));
const minimum = Math.round(zone.klicks / MINDEST_KLICKS_JE_PUNKT[variante]);
return Math.min(obergrenze, Math.max(minimum, Math.round(anteil ** 1.6 * 8 * flecke)));
};
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
@@ -367,6 +368,8 @@ const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<str
};
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
/** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 7 Punkte auf dem MacBook). */
const MINDEST_KLICKS_JE_PUNKT = { desktop: 14, handy: 24 } as const;
const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
+2 -2
View File
@@ -48,7 +48,7 @@ const ROH: Rohzone[] = [
{
id: "telefon",
name: "Telefonnummer",
klicks: 61,
klicks: 96,
satz: (p) => `${p}\u00a0% der Klicks gehen auf die Telefonnummer im Kopf der Seite.`,
},
{
@@ -86,7 +86,7 @@ const ROH: Rohzone[] = [
{
id: "oeffnungszeiten",
name: "Öffnungszeiten",
klicks: 96,
klicks: 61,
satz: (p) => `Die Öffnungszeiten bekommen ${p}\u00a0% der Klicks.`,
},
];
+72 -18
View File
@@ -176,11 +176,8 @@
.kp-eintrag__punkt {
top: 15px;
}
.kp-gruppe[data-erste="ja"] {
margin-top: 6px;
}
.kp-gruppe__titel {
padding-top: 6px;
.kp-untereintrag {
padding: 4px 0;
}
}
/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift (acht Stationen brauchen sonst mehr Platz) */
@@ -194,22 +191,79 @@
}
}
/* Gruppe "Weitere Funktionen": die drei Demo-Stationen mit kleiner Überschrift darüber */
.kp-gruppe[data-erste="ja"] {
margin-top: 10px;
/* Überpunkt "Funktionen": die drei Demo-Stationen klappen darunter auf, sobald eine von ihnen aktiv ist.
Die Liste steht oben in der Bühne (nicht mittig), damit beim Aufklappen nur das folgt, was darunter liegt. */
.kp-liste {
align-self: start;
padding-top: clamp(0px, 4svh, 40px);
}
.kp-gruppe__titel {
margin: 0 0 2px;
padding-top: 10px;
border-top: 1px solid var(--line-strong);
font-size: var(--fs-small);
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.01em;
.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
color: var(--fg);
}
.kp-unterliste {
display: flex;
flex-direction: column;
margin: 2px 0 4px;
padding-left: 14px;
border-left: 1px solid var(--line-strong);
}
.kp-unterliste[hidden] {
display: none;
}
.kp-untereintrag {
position: relative;
display: block;
width: 100%;
min-height: 36px;
padding: 6px 0;
text-align: left;
color: var(--fg-muted);
-webkit-tap-highlight-color: transparent;
}
.kp-gruppe .kp-eintrag__titel {
font-size: 1.1875rem;
.kp-untereintrag__titel {
display: block;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.2;
transition: color var(--dur-state) var(--ease);
}
.kp-untereintrag[aria-current="step"] .kp-untereintrag__titel {
color: var(--fg);
}
/* derselbe rote Punkt wie bei den Hauptpunkten, in derselben Spalte (14 px Einzug + 1 px Linie weiter links) */
.kp-untereintrag .kp-eintrag__punkt {
left: -35px;
top: 12px;
}
.kp-untereintrag[aria-current="step"] .kp-eintrag__punkt {
opacity: 1;
transform: none;
}
@media (hover: hover) and (pointer: fine) {
.kp-untereintrag:hover .kp-untereintrag__titel {
color: var(--fg);
}
}
@media (prefers-reduced-motion: no-preference) {
.kp-unterliste:not([hidden]) > li {
animation: kp-aufklappen var(--dur-enter) var(--ease) both;
}
.kp-unterliste:not([hidden]) > li:nth-child(2) {
animation-delay: 60ms;
}
.kp-unterliste:not([hidden]) > li:nth-child(3) {
animation-delay: 120ms;
}
}
@keyframes kp-aufklappen {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
+32 -12
View File
@@ -101,21 +101,25 @@ describe("Kundenportal: Text und Reihenfolge", () => {
expect(screen.queryByText(/mit der Maus/)).toBeNull();
});
it("Desktop: die Liste der Bühne hat dieselben acht Funktionen in derselben Reihenfolge, die drei Demos als Gruppe", () => {
it("Desktop: die Liste hat die Portal-Stationen und den Überpunkt „Funktionen“, der zugeklappt beginnt", () => {
zeigeDesktop();
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const eintraege = within(liste).getAllByRole("button");
expect(eintraege).toHaveLength(8);
TITEL.forEach((titel, i) => {
expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`));
for (const text of [UNTERSCHRIFTEN[i]].flat()) expect(eintraege[i]).toHaveTextContent(text);
});
// Gruppenüberschrift steht über dem ersten der drei Funktions-Einträge, die Gruppe umfasst genau diese drei
const gruppe = screen.getByText("Weitere Funktionen");
expect(gruppe.closest("li")).toContainElement(eintraege[4]);
expect(Array.from(liste.querySelectorAll("li.kp-gruppe"))).toHaveLength(3);
expect(eintraege[3].closest("li")).not.toHaveClass("kp-gruppe");
expect(eintraege[7].closest("li")).not.toHaveClass("kp-gruppe");
const oben = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Funktionen", "Als App"];
expect(eintraege).toHaveLength(oben.length);
oben.forEach((titel, i) => expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`)));
// Portal-Stationen behalten ihren Satz
[0, 1, 2, 3].forEach((i) => expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i] as string));
expect(eintraege[5]).toHaveTextContent(UNTERSCHRIFTEN[7] as string);
// Überpunkt: zugeklappt, die drei Unterpunkte (nur Titel, keine Stichpunkte) sind versteckt
const gruppe = eintraege[4];
expect(gruppe).toHaveAttribute("aria-expanded", "false");
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
expect(unterliste).toHaveAttribute("aria-label", "Funktionen");
expect(unterliste).not.toBeVisible();
const unter = within(unterliste).getAllByRole("button", { hidden: true });
expect(unter.map((b) => b.textContent)).toEqual(["Terminplaner", "Website-Analyse", "News-Pinnwand"]);
expect(liste).not.toHaveTextContent("Heatmap: wohin Besucher klicken");
});
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
@@ -308,6 +312,22 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
.find((b) => b.getAttribute("aria-current") === "step")
?.textContent;
it("der Überpunkt „Funktionen“ klappt auf, sobald eine der drei Funktionen erreicht ist, und danach wieder zu", () => {
beobachterAttrappe();
zeigeDesktop();
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
expect(gruppe).toHaveAttribute("aria-expanded", "false");
meldeMarke(4);
expect(gruppe).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("list", { name: "Funktionen" })).toBeVisible();
expect(aktiverEintrag()).toBe("Terminplaner");
meldeMarke(5);
expect(aktiverEintrag()).toBe("Website-Analyse");
meldeMarke(7);
expect(gruppe).toHaveAttribute("aria-expanded", "false");
expect(aktiverEintrag()).toMatch(/^Als App/);
});
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 8 blendet das Fenster aus", () => {
beobachterAttrappe();
const { container } = zeigeDesktop();