feat: App-Hülle mit Header, Theme-Umschalter und Übersicht

Layout mit Inline-Skript, das data-theme setzt, bevor der erste Pixel
gemalt wird — sonst blitzt beim Laden das falsche Farbschema auf. Logo als
React-Komponente statt als Bilddatei: nimmt Theme-Farben an, spart einen
Netzwerkabruf und die Verläufe liegen einmalig im Root-Layout.

Im hellen Modus greifen eigene Tokens für Verlaufstext und Wortmarke; der
vollfarbige Signaturverlauf fällt auf warmem Weiß sonst unter WCAG AA.
This commit is contained in:
Florian Wilde
2026-09-01 18:46:47 +02:00
parent 01c0f9bc9f
commit b001e62695
11 changed files with 880 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
import { clsx } from "clsx";
/**
* Die Marke als React-Komponenten.
*
* Inline statt <img src="/brand/…">: so kostet das Logo keinen zweiten
* Netzwerkabruf, lässt sich animieren und die monochrome Variante nimmt die
* Textfarbe des Kontexts an.
*
* Die Verläufe liegen einmalig in <BrandDefs /> (im Root-Layout gerendert) und
* werden von allen Logo-Instanzen referenziert — sonst gäbe es doppelte IDs.
*/
const GRADIENT = "url(#fl-brand-gradient)";
const HALO = "url(#fl-brand-halo)";
const BLOOM = "url(#fl-brand-bloom)";
/** Einmalig pro Dokument rendern (Root-Layout). */
export function BrandDefs() {
return (
<svg width="0" height="0" aria-hidden focusable="false" style={{ position: "absolute" }}>
<defs>
<linearGradient
id="fl-brand-gradient"
x1="20"
y1="24"
x2="108"
y2="102"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--fl-brand-1)" />
<stop offset="0.38" stopColor="var(--fl-brand-2)" />
<stop offset="1" stopColor="var(--fl-brand-3)" />
</linearGradient>
<radialGradient id="fl-brand-halo" cx="64" cy="58" r="52" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="var(--fl-brand-1)" stopOpacity="0.5" />
<stop offset="0.45" stopColor="var(--fl-brand-2)" stopOpacity="0.24" />
<stop offset="1" stopColor="var(--fl-brand-3)" stopOpacity="0" />
</radialGradient>
<filter id="fl-brand-bloom" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" />
</filter>
<linearGradient
id="fl-brand-word"
x1="0"
y1="10"
x2="418"
y2="108"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--fl-brand-1)" />
<stop offset="0.38" stopColor="var(--fl-brand-2)" />
<stop offset="1" stopColor="var(--fl-brand-3)" />
</linearGradient>
</defs>
</svg>
);
}
type MarkProps = {
className?: string;
/** Ohne Glühen und ohne Verlauf — nimmt die Textfarbe an (currentColor). */
mono?: boolean;
/** Verlauf, aber kein Glühen — für sehr kleine Darstellungen. */
flat?: boolean;
title?: string;
};
export function FlimmerMark({ className, mono = false, flat = false, title }: MarkProps) {
const paint = mono ? "currentColor" : GRADIENT;
return (
<svg
viewBox="0 0 128 128"
className={clsx("h-8 w-8 shrink-0", className)}
role={title ? "img" : "presentation"}
aria-label={title}
aria-hidden={title ? undefined : true}
>
{!mono && !flat && (
<>
<ellipse cx="64" cy="58" rx="50" ry="40" fill={HALO} filter={BLOOM} />
<rect
x="22"
y="26"
width="84"
height="64"
rx="18"
fill="none"
stroke={paint}
strokeWidth="8"
filter={BLOOM}
opacity="0.7"
/>
</>
)}
<rect x="22" y="26" width="84" height="64" rx="18" fill="none" stroke={paint} strokeWidth="7" />
<path
d="M55.5 45.5 L80 58 L55.5 70.5 Z"
fill={paint}
stroke={paint}
strokeWidth="6.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
d="M64 90 V101 M49 105.5 H79"
stroke={paint}
strokeWidth="6.5"
strokeLinecap="round"
strokeOpacity="0.62"
/>
</svg>
);
}
/**
* Wortmarke als Pfad — unabhängig von installierten Schriften und damit
* überall identisch (Header, Login, E-Mail-Vorschau, Druck).
*/
export function FlimmerWordmark({
className,
mono = false,
}: {
className?: string;
mono?: boolean;
}) {
const paint = mono ? "currentColor" : "url(#fl-brand-word)";
return (
<svg
viewBox="0 0 432 118"
className={clsx("h-6 w-auto", className)}
role="presentation"
aria-hidden
>
<g
transform="translate(0 8)"
fill="none"
stroke={paint}
strokeWidth="15"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 100 V10 H62" />
<path d="M14 54 H52" />
<path d="M84 100 V6" />
<path d="M106 100 V46" />
<path d="M128 100 V64 Q128 46 146 46 Q164 46 164 64 V100" />
<path d="M164 64 Q164 46 182 46 Q200 46 200 64 V100" />
<path d="M218 100 V64 Q218 46 236 46 Q254 46 254 64 V100" />
<path d="M254 64 Q254 46 272 46 Q290 46 290 64 V100" />
<path d="M344.5 91.8 A29 19.5 0 1 1 366 73 H308" />
<path d="M392 100 V46" />
<path d="M392 64 Q392 46 414 46" />
</g>
<circle cx="106" cy="30" r="7.5" fill={paint} />
</svg>
);
}
/** Bildmarke plus Wortmarke — die Standard-Sperrform. */
export function FlimmerLogo({
className,
markClassName,
wordClassName,
mono = false,
}: {
className?: string;
markClassName?: string;
wordClassName?: string;
mono?: boolean;
}) {
return (
<span className={clsx("inline-flex items-center gap-2.5", className)}>
<FlimmerMark className={clsx("h-9 w-9", markClassName)} mono={mono} />
<FlimmerWordmark className={clsx("h-[1.35rem]", wordClassName)} mono={mono} />
<span className="sr-only">Flimmer</span>
</span>
);
}