Persönlichkeitscoach-Zertifikat über Greator Speaker

Anelias neues Greator-Zertifikat (Persönlichkeitscoach, 09.10.2026) steht
in „Über Anelia“ über dem Speaker-Abzeichen, im selben Lorbeerkranz mit
freigestelltem Siegel, Hover und Vorschau-Overlay. Das Siegel ist aus dem
PDF freigestellt (Kreis, 150-dpi-Raster), Farbe und Form unverändert.

getCertificate() wird zu getCertificates(): eine Liste, das neueste oben.
Ein einzelnes Abzeichen klebt weiter auf Höhe der „04“. Zwei passen dort
nicht ins Bild, also steht der Stapel still und beginnt auf Höhe der
Eyebrow. Unter lg stehen sie nebeneinander, auf dem Handy untereinander.

Der Abzeichen-Titel darf umbrechen (Zeilenhöhe 1,4, Ränder ausgeglichen;
einzeilig unverändert). Der Dialog-Titel trennt Silben, damit
„Persönlichkeitscoach“ auf dem Handy „Schließen“ nicht aus dem Bild
schiebt. Jeder Dialog bekommt seine eigene Titel-ID (useId).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-21 17:01:12 +00:00
1 parent 30e9151de4
commit 1e7b84b27c
10 files changed
+124 -60

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+27 -12
View File
@@ -17,8 +17,8 @@ export interface AboutProps {
photo: string | null; photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */ /** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["about"]; content?: HomeContent["about"];
/** `getCertificate()` – `null` lässt die Sektion wie vorher rendern. */ /** `getCertificates()` – leer lässt die Sektion wie vorher rendern. */
certificate?: CertificateAsset | null; certificates?: readonly CertificateAsset[];
} }
/* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */ /* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */
@@ -34,12 +34,13 @@ const BODY = "max-w-[36rem] font-sans text-[1.125rem] leading-[1.6] text-fg [hyp
* verifiable facts. No counters, no logos. * verifiable facts. No counters, no logos.
* *
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia and * With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia and
* certificate stacked under the ledger (cols 7–12) – the text already * certificates stacked under the ledger (cols 7–12) – the text already
* owns the outer columns there, so nothing can stick to the right edge. * owns the outer columns there, so nothing can stick to the right edge.
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger · * Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger ·
* certificate sticky in cols 10–12 (no monogram vignette – see below). * certificates in cols 10–12, a single one sticky (no monogram vignette –
* see below).
*/ */
export function About({ photo, content: about = aboutDe, certificate = null }: AboutProps) { export function About({ photo, content: about = aboutDe, certificates = [] }: AboutProps) {
const paragraphs = about.paragraphs.map((p, i) => const paragraphs = about.paragraphs.map((p, i) =>
i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p, i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p,
); );
@@ -104,11 +105,13 @@ export function About({ photo, content: about = aboutDe, certificate = null }: A
<Marginalia items={about.marginalia} /> <Marginalia items={about.marginalia} />
</Reveal> </Reveal>
{/* Das Zertifikat ist das Gegenstück zur klebenden „04“: gleiche Höhe {/* Ein einzelnes Zertifikat ist das Gegenstück zur klebenden „04“: gleiche
(`calc(50vh − .45em)`), gleiche Zeilen, nur am anderen Rand. Mit Foto Höhe (`calc(50vh − .45em)`), gleiche Zeilen, nur am anderen Rand. Zwei
reicht der Text bis Spalte 12 – dort gäbe es keine freie Gutter, also Abzeichen (je rund 24 rem) passen dort nicht mehr ins Bild – dann steht
steht es dann still unter dem Werdegang. */} der Stapel still und beginnt oben auf Höhe der Eyebrow, das neueste
{certificate && ( zuerst. Mit Foto reicht der Text bis Spalte 12 – dort gäbe es keine freie
Gutter, also stehen sie dann still unter dem Werdegang. */}
{certificates.length > 0 && (
<Reveal <Reveal
className={cn( className={cn(
"col-span-12 mt-14", "col-span-12 mt-14",
@@ -117,8 +120,20 @@ export function About({ photo, content: about = aboutDe, certificate = null }: A
: "lg:col-span-3 lg:col-start-10 lg:row-span-3 lg:row-start-1 lg:mt-0", : "lg:col-span-3 lg:col-start-10 lg:row-span-3 lg:row-start-1 lg:mt-0",
)} )}
> >
<div className={cn(!hasPhoto && "lg:sticky lg:top-[calc(50vh_-_0.45em)]")}> <div
<Certificate content={about.certificate} asset={certificate} /> className={cn(
"flex flex-wrap gap-x-12 gap-y-16 lg:flex-col",
!hasPhoto && certificates.length === 1 && "lg:sticky lg:top-[calc(50vh_-_0.45em)]",
)}
>
{certificates.map((asset) => (
<Certificate
key={asset.key}
content={about.certificates[asset.key]}
asset={asset}
className="w-full"
/>
))}
</div> </div>
</Reveal> </Reveal>
)} )}
+2 -2
View File
@@ -7,7 +7,7 @@ import { About } from "@/components/home/About";
import { Audience } from "@/components/home/Audience"; import { Audience } from "@/components/home/Audience";
import { ProcessContact } from "@/components/home/ProcessContact"; import { ProcessContact } from "@/components/home/ProcessContact";
import type { HomeContent } from "@/content/home"; import type { HomeContent } from "@/content/home";
import { getCertificate, getPhotos } from "@/lib/assets"; import { getCertificates, getPhotos } from "@/lib/assets";
import { routesByLang, type Lang } from "@/lib/i18n"; import { routesByLang, type Lang } from "@/lib/i18n";
import { rootCopy } from "@/lib/metadata"; import { rootCopy } from "@/lib/metadata";
import { site } from "@/lib/site"; import { site } from "@/lib/site";
@@ -37,7 +37,7 @@ export function HomeBody({ lang, content }: { lang: Lang; content: HomeContent }
<Positioning content={content.positioning} /> <Positioning content={content.positioning} />
<ServicesIndex content={content.services} /> <ServicesIndex content={content.services} />
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} /> <WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} />
<About photo={aboutPhoto} content={content.about} certificate={getCertificate()} /> <About photo={aboutPhoto} content={content.about} certificates={getCertificates()} />
<Audience content={content.audience} /> <Audience content={content.audience} />
<ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} /> <ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} />
</> </>
+6 -2
View File
@@ -63,11 +63,15 @@ export function Certificate({ content, asset, className }: CertificateProps) {
</span> </span>
</span> </span>
<span className="mt-6 block font-mono text-xs uppercase leading-none tracking-[0.18em] text-accent"> {/* Zeilenhöhe 1,4 statt 1, damit ein umbrechender Titel („Greator
Persönlichkeitscoach“) nicht zusammenklebt. Die Ränder davor und danach
sind um den halben Durchschuss (0,15 rem) kürzer – einzeilig steht alles
wie zuvor mit `leading-none`, `mt-6` und `mt-5`. */}
<span className="mt-[1.35rem] block font-mono text-xs uppercase leading-[1.4] tracking-[0.18em] text-accent">
{content.title} {content.title}
</span> </span>
<span aria-hidden="true" className="rule-gold mt-5 block h-px w-full" /> <span aria-hidden="true" className="rule-gold mt-[1.1rem] block h-px w-full" />
<span className="mt-5 block font-sans text-[0.875rem] leading-[1.5] text-fg [hyphens:auto]"> <span className="mt-5 block font-sans text-[0.875rem] leading-[1.5] text-fg [hyphens:auto]">
{content.description} {content.description}
+10 -8
View File
@@ -1,14 +1,11 @@
"use client"; "use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { CertificateAsset } from "@/lib/assets"; import type { CertificateAsset } from "@/lib/assets";
import type { CertificateCopy } from "@/content/home"; import type { CertificateCopy } from "@/content/home";
import { gsap, DUR, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap"; import { gsap, DUR, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
/** Die Überschrift benennt den Dialog (`aria-labelledby`) – es gibt genau einen. */
const TITLE_ID = "zertifikat-titel";
/** /**
* Kopf, Blatt und Fußzeile teilen sich eine Breite, sonst stehen sie versetzt. * Kopf, Blatt und Fußzeile teilen sich eine Breite, sonst stehen sie versetzt.
* Auf dem Desktop richtet sie sich nach der Höhe: das Blatt soll ganz ins Bild * Auf dem Desktop richtet sie sich nach der Höhe: das Blatt soll ganz ins Bild
@@ -39,6 +36,8 @@ export interface CertificateDialogProps {
* Dialog in seinem eigenen toten Bereich. * Dialog in seinem eigenen toten Bereich.
*/ */
export function CertificateDialog({ open, onClose, content, asset }: CertificateDialogProps) { export function CertificateDialog({ open, onClose, content, asset }: CertificateDialogProps) {
/* Die Überschrift benennt den Dialog (`aria-labelledby`) – je Zertifikat ein Dialog. */
const titleId = useId();
const panel = useRef<HTMLDivElement>(null); const panel = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
@@ -138,7 +137,7 @@ export function CertificateDialog({ open, onClose, content, asset }: Certificate
ref={panel} ref={panel}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={TITLE_ID} aria-labelledby={titleId}
data-theme="dark" data-theme="dark"
data-lenis-prevent="" data-lenis-prevent=""
onClick={(e) => { onClick={(e) => {
@@ -148,12 +147,15 @@ export function CertificateDialog({ open, onClose, content, asset }: Certificate
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto overscroll-contain bg-ink px-5 py-[calc(var(--header-h,60px)/2+16px)] text-fg lg:px-10" className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto overscroll-contain bg-ink px-5 py-[calc(var(--header-h,60px)/2+16px)] text-fg lg:px-10"
> >
<div className={`mb-8 flex items-start justify-between gap-6 ${COLUMN}`}> <div className={`mb-8 flex items-start justify-between gap-6 ${COLUMN}`}>
<div> {/* `min-w-0` + Silbentrennung: „Persönlichkeitscoach“ ist auf dem Handy
breiter als der Platz neben „Schließen“ und schöbe den Knopf sonst
aus dem Bild. */}
<div className="min-w-0">
<p className="font-mono text-xs uppercase tracking-[0.12em] text-accent">{content.eyebrow}</p> <p className="font-mono text-xs uppercase tracking-[0.12em] text-accent">{content.eyebrow}</p>
{/* Playfair hat hier den Platz für seine 28-px-Untergrenze – im Abzeichen selbst nicht. */} {/* Playfair hat hier den Platz für seine 28-px-Untergrenze – im Abzeichen selbst nicht. */}
<h2 <h2
id={TITLE_ID} id={titleId}
className="mt-3 font-display text-[clamp(1.75rem,1.2rem+1vw,2.25rem)] font-normal leading-none tracking-[-0.01em] text-fg" className="mt-3 font-display text-[clamp(1.75rem,1.2rem+1vw,2.25rem)] font-normal leading-none tracking-[-0.01em] text-fg [hyphens:auto]"
> >
{content.title} {content.title}
</h2> </h2>
+25 -11
View File
@@ -138,17 +138,31 @@ export const homeEn: HomeContent = {
{ value: "LinkedIn", href: site.linkedin, external: true }, { value: "LinkedIn", href: site.linkedin, external: true },
{ value: "German · English" }, { value: "German · English" },
], ],
certificate: { certificates: {
title: "Greator Speaker", persoenlichkeitscoach: {
description: "Certified graduate of the Greator Speaker programme", title: "Greator Personality Coach",
date: "May 2026", description: "Certified graduate of the Greator Personality Coach programme",
cta: "View certificate", date: "October 2026",
eyebrow: "Certificate", cta: "View certificate",
dialogMeta: "Greator TheKey · issued 16 May 2026", eyebrow: "Certificate",
imageAlt: dialogMeta: "Greator · issued 9 October 2026",
"Certificate: Anelia Wolf has successfully completed the certified Greator Speaker programme.", imageAlt:
pdfLabel: "Open PDF", "Certificate: Anelia Wolf has successfully completed the theoretical and practical parts of the three-month Greator Personality Coach programme.",
close: "Close", pdfLabel: "Open PDF",
close: "Close",
},
speaker: {
title: "Greator Speaker",
description: "Certified graduate of the Greator Speaker programme",
date: "May 2026",
cta: "View certificate",
eyebrow: "Certificate",
dialogMeta: "Greator TheKey · issued 16 May 2026",
imageAlt:
"Certificate: Anelia Wolf has successfully completed the certified Greator Speaker programme.",
pdfLabel: "Open PDF",
close: "Close",
},
}, },
folio: { current: "05", total: "06" }, folio: { current: "05", total: "06" },
}, },
+31 -15
View File
@@ -262,20 +262,36 @@ export const about = {
{ value: "LinkedIn", href: site.linkedin, external: true }, { value: "LinkedIn", href: site.linkedin, external: true },
{ value: "Deutsch · Englisch" }, { value: "Deutsch · Englisch" },
] satisfies readonly MarginaliaItem[], ] satisfies readonly MarginaliaItem[],
/* Das Abzeichen in der rechten Gutter – Gegenstück zur klebenden „04“ links. /* Die Abzeichen in der rechten Gutter – Gegenstück zur klebenden „04“ links.
`seal` steht IM Lorbeerkranz, alles darunter ist die Bildunterschrift. */ `seal` steht IM Lorbeerkranz, alles darunter ist die Bildunterschrift.
certificate: { Schlüssel wie in `getCertificates()` (src/lib/assets.ts), dort auch die Reihenfolge. */
title: "Greator Speaker", certificates: {
description: "Zertifizierte Absolventin der Greator Speaker Ausbildung", persoenlichkeitscoach: {
date: "Mai 2026", title: "Greator Persönlichkeitscoach",
cta: "Zertifikat ansehen", description: "Zertifizierte Absolventin der Ausbildung zum Greator Persönlichkeitscoach",
/* Overlay */ date: "Oktober 2026",
eyebrow: "Zertifikat", cta: "Zertifikat ansehen",
dialogMeta: "Greator TheKey · Ausstellungsdatum 16. Mai 2026", /* Overlay */
imageAlt: eyebrow: "Zertifikat",
"Zertifikat: Anelia Wolf hat die Ausbildung zum zertifizierten Greator Speaker erfolgreich abgeschlossen.", dialogMeta: "Greator · Ausstellungsdatum 9. Oktober 2026",
pdfLabel: "PDF öffnen", imageAlt:
close: "Schließen", "Zertifikat: Anelia Wolf hat den theoretischen und praktischen Teil der dreimonatigen Ausbildung zum Greator Persönlichkeitscoach erfolgreich abgeschlossen.",
pdfLabel: "PDF öffnen",
close: "Schließen",
},
speaker: {
title: "Greator Speaker",
description: "Zertifizierte Absolventin der Greator Speaker Ausbildung",
date: "Mai 2026",
cta: "Zertifikat ansehen",
/* Overlay */
eyebrow: "Zertifikat",
dialogMeta: "Greator TheKey · Ausstellungsdatum 16. Mai 2026",
imageAlt:
"Zertifikat: Anelia Wolf hat die Ausbildung zum zertifizierten Greator Speaker erfolgreich abgeschlossen.",
pdfLabel: "PDF öffnen",
close: "Schließen",
},
}, },
folio: { current: "05", total: "06" }, folio: { current: "05", total: "06" },
} as const; } as const;
@@ -389,4 +405,4 @@ assertNoPlaceholders("src/content/home.ts", collectStrings(home));
/** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */ /** Struktur, nicht Wortlaut – die englische Fassung erfüllt denselben Typ. */
export type HomeContent = Widen<typeof home>; export type HomeContent = Widen<typeof home>;
/** Texte des Zertifikat-Abzeichens – Abzeichen und Overlay teilen sie sich. */ /** Texte des Zertifikat-Abzeichens – Abzeichen und Overlay teilen sie sich. */
export type CertificateCopy = HomeContent["about"]["certificate"]; export type CertificateCopy = HomeContent["about"]["certificates"]["speaker"];
+23 -10
View File
@@ -57,26 +57,39 @@ export function getSpeakerPdf(): string | null {
return publicUrl("speaker-profil.pdf"); return publicUrl("speaker-profil.pdf");
} }
/** Schlüssel in `about.certificates` (Texte) – Reihenfolge siehe `CERTIFICATES`. */
export type CertificateKey = "persoenlichkeitscoach" | "speaker";
export interface CertificateAsset { export interface CertificateAsset {
/** `/zertifikate/greator-speaker.pdf` – das Original zum Öffnen/Herunterladen. */ key: CertificateKey;
/** z. B. `/zertifikate/greator-speaker.pdf` – das Original zum Öffnen/Herunterladen. */
pdf: string; pdf: string;
/** Seite 1 als Bild für die Vorschau (kein eingebettetes PDF, kein iframe). */ /** Seite 1 als Bild für die Vorschau (kein eingebettetes PDF, kein iframe). */
image: string; image: string;
width: number; width: number;
height: number; height: number;
/** Das runde Greator-TheKey-Siegel, freigestellt – es steht im Lorbeerkranz. */ /** Das runde Greator-Siegel, freigestellt – es steht im Lorbeerkranz. */
seal: string; seal: string;
sealSize: number; sealSize: number;
} }
/** Oben steht das neueste Zertifikat. */
const CERTIFICATES = [
{ key: "persoenlichkeitscoach", file: "greator-persoenlichkeitscoach", seal: "greator-siegel-coach.webp", width: 989, height: 1398 },
{ key: "speaker", file: "greator-speaker", seal: "greator-siegel.webp", width: 989, height: 1400 },
] as const satisfies readonly { key: CertificateKey; file: string; seal: string; width: number; height: number }[];
/** /**
* Greator-Speaker-Zertifikat für die Sektion „Über Anelia“. Alle drei Dateien müssen * Greator-Zertifikate für die Sektion „Über Anelia“. Ein Zertifikat erscheint nur,
* liegen – fehlt eine, rendert die Sektion wie vorher (Variante B, wie bei den Fotos). * wenn alle drei Dateien liegen – fehlen alle, rendert die Sektion wie vorher
* (Variante B, wie bei den Fotos).
*/ */
export function getCertificate(): CertificateAsset | null { export function getCertificates(): CertificateAsset[] {
const pdf = publicUrl("zertifikate/greator-speaker.pdf"); return CERTIFICATES.flatMap(({ key, file, seal: sealFile, width, height }) => {
const image = publicUrl("zertifikate/greator-speaker.jpg"); const pdf = publicUrl(`zertifikate/${file}.pdf`);
const seal = publicUrl("zertifikate/greator-siegel.webp"); const image = publicUrl(`zertifikate/${file}.jpg`);
if (!pdf || !image || !seal) return null; const seal = publicUrl(`zertifikate/${sealFile}`);
return { pdf, image, width: 989, height: 1400, seal, sealSize: 372 }; if (!pdf || !image || !seal) return [];
return [{ key, pdf, image, width, height, seal, sealSize: 372 }];
});
} }