Fundament: Branding Craftvia

- Farb-Tokens laut Brandbook §11.4 (Lotsenblau, Signalorange, Graphit, Hafengrau,
  Stahlgrau, Zink, Funktionsfarben) in globals.css + src/lib/brand.ts; helles Theme
- Inline-SVG-Logo src/components/brand/craftvia-logo.tsx (horizontal/signet,
  color/mono/reversed, App-Icon-Kachel, optionale Tagline); Certvia-/GEFIM-Logos entfernt
- Favicon/PWA-Icons aus dem Signet erzeugt (scripts/generate-brand-icons.ts),
  site.webmanifest (Craftvia, theme_color #082E5B)
- Inter selbst gehostet (next/font/local, lokale OFL-Datei), Open Sans/Poppins entfernt
- Metadaten, WebAuthn-RP-Name, Mail-/Dokument-CD auf Craftvia umgestellt
- docs/craftvia/BRANDING.md ersetzt docs/BRANDING-CERTVIA.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:39:52 +02:00
co-authored by Claude Opus 5
parent 8491c7f173
commit 9ec7fa5356
62 changed files with 549 additions and 1070 deletions
-119
View File
@@ -1,119 +0,0 @@
import { BRAND, BRAND_COLORS } from "@/lib/brand";
/**
* Certvia-Logo als Inline-SVG.
*
* Bewusst inline statt <Image src="…svg">: die Wortmarke setzt „Cert" in Poppins
* Light und „via" in Poppins Bold. Ein extern geladenes SVG hat keinen Zugriff auf
* die per next/font/local eingebundene Schrift (gehashter Family-Name) und würde
* auf eine System-Schrift zurückfallen. Inline greift `var(--font-poppins)`.
*
* Zeichen-Konstruktion (unverändert aus dem Design-Paket, viewBox 64×64):
* „C" nach rechts offen, der Haken liegt bündig in der Öffnung. Kein Verlauf.
*
* theme="light" → heller Grund (C Violett, Haken/„via" Magenta, „Cert" Anthrazit)
* theme="dark" → dunkler Grund (C #7d6fd6, Haken #d17bcf, „Cert" weiß)
*/
type LogoTheme = "light" | "dark";
const PALETTE: Record<LogoTheme, { arc: string; hook: string; word: string; accent: string }> = {
light: {
arc: BRAND_COLORS.violet,
hook: BRAND_COLORS.magenta,
word: BRAND_COLORS.anthracite,
accent: BRAND_COLORS.magenta,
},
dark: {
arc: BRAND_COLORS.violetOnDark,
hook: BRAND_COLORS.magentaOnDark,
word: BRAND_COLORS.white,
accent: BRAND_COLORS.magentaOnDark,
},
};
/** Das Monogramm allein — Pfade aus `certvia-mark-*.svg`. */
function Monogram({ arc, hook }: { arc: string; hook: string }) {
return (
<>
<path
d="M46 20 A17 17 0 1 0 46 44"
fill="none"
stroke={arc}
strokeWidth={6}
strokeLinecap="round"
/>
<path
d="M27 32 l6 6 l12 -15"
fill="none"
stroke={hook}
strokeWidth={5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
}
export function CertviaLogo({
variant = "lockup",
theme = "dark",
height = 40,
className,
title = BRAND.name,
}: {
variant?: "lockup" | "mark";
theme?: LogoTheme;
/** Renderhöhe in px; die Breite ergibt sich aus dem Seitenverhältnis. */
height?: number;
className?: string;
title?: string;
}) {
const c = PALETTE[theme];
if (variant === "mark") {
return (
<svg
viewBox="0 0 64 64"
width={height}
height={height}
role="img"
aria-label={title}
className={className}
>
<title>{title}</title>
<Monogram arc={c.arc} hook={c.hook} />
</svg>
);
}
return (
<svg
viewBox="0 0 380 96"
width={(height * 380) / 96}
height={height}
role="img"
aria-label={title}
className={className}
>
<title>{title}</title>
<g transform="translate(6,8) scale(1.25)">
<Monogram arc={c.arc} hook={c.hook} />
</g>
<text
x={112}
y={62}
fontSize={52}
letterSpacing={-1}
style={{ fontFamily: "var(--font-poppins), Poppins, 'Segoe UI', sans-serif" }}
>
<tspan fontWeight={300} fill={c.word}>
Cert
</tspan>
<tspan fontWeight={700} fill={c.accent}>
via
</tspan>
</text>
</svg>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useId } from "react";
import { BRAND, BRAND_COLORS } from "@/lib/brand";
/**
* Craftvia-Logo als Inline-SVG (Referenz: docs/craftvia/assets/craftvia-brand-identity-v1.png).
*
* Signet: kräftiges „C" in Lotsenblau, rechts oben geöffnet. Ein Haken in Signalorange
* startet links außerhalb, durchquert das C (das C ist dort freigestellt) und läuft als
* Pfeil nach rechts oben durch die Öffnung hinaus („auf Kurs").
* Wortmarke: „Craft" Lotsenblau + „via" Signalorange, fett; Tagline optional.
* Keine Schatten, keine Verläufe.
*
* Inline statt <img>: die Wortmarke nutzt die per next/font eingebundene App-Schrift.
*
* Varianten:
* variant="horizontal" (Default) | "signet"
* tone="color" (Default) | "mono" (einfarbig Lotsenblau) | "reversed" (weiß auf dunklem Grund)
* tile (nur signet): App-Icon — Lotsenblau-Kachel, weißes C, oranger Haken
*/
export type CraftviaLogoTone = "color" | "mono" | "reversed";
/** Geometrie des Signets (viewBox 0 0 64 64) — auch von scripts/generate-brand-icons.ts genutzt. */
export const SIGNET = {
/** C: Kreisbogen um (30,34), r=19, von -70° gegen den Uhrzeigersinn bis +10° (Öffnung rechts oben). */
arc: "M36.5 16.14 A19 19 0 1 0 48.71 37.3",
arcWidth: 11,
/** Haken: Schwanz links außerhalb → Tal → Richtung Öffnung. */
check: "M6 28 L22 42 L44 21",
checkWidth: 7,
/** Pfeilspitze nach rechts oben. */
arrow: "M52.7 12.7 L47.73 27.8 L37.37 16.96 Z",
} as const;
function palette(tone: CraftviaLogoTone, tile: boolean) {
if (tile) return { arc: BRAND_COLORS.white, check: BRAND_COLORS.signalorange, craft: BRAND_COLORS.white, via: BRAND_COLORS.signalorange, tagline: BRAND_COLORS.white };
switch (tone) {
case "mono":
return { arc: BRAND_COLORS.lotsenblau, check: BRAND_COLORS.lotsenblau, craft: BRAND_COLORS.lotsenblau, via: BRAND_COLORS.lotsenblau, tagline: BRAND_COLORS.lotsenblau };
case "reversed":
return { arc: BRAND_COLORS.white, check: BRAND_COLORS.white, craft: BRAND_COLORS.white, via: BRAND_COLORS.white, tagline: BRAND_COLORS.white };
default:
return { arc: BRAND_COLORS.lotsenblau, check: BRAND_COLORS.signalorange, craft: BRAND_COLORS.lotsenblau, via: BRAND_COLORS.signalorange, tagline: BRAND_COLORS.lotsenblau };
}
}
function Signet({ maskId, arc, check }: { maskId: string; arc: string; check: string }) {
return (
<>
<defs>
{/* Freistellung: das C ist dort unterbrochen, wo der Haken es durchquert. */}
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
<rect x="0" y="0" width="64" height="64" fill="#fff" />
<path d={SIGNET.check} fill="none" stroke="#000" strokeWidth={SIGNET.checkWidth + 6} strokeLinejoin="miter" />
</mask>
</defs>
<path d={SIGNET.arc} fill="none" stroke={arc} strokeWidth={SIGNET.arcWidth} strokeLinecap="butt" mask={`url(#${maskId})`} />
<path d={SIGNET.check} fill="none" stroke={check} strokeWidth={SIGNET.checkWidth} strokeLinejoin="miter" strokeLinecap="butt" />
<path d={SIGNET.arrow} fill={check} />
</>
);
}
export function CraftviaLogo({
variant = "horizontal",
tone = "color",
tile = false,
tagline = false,
height = 36,
className,
title = BRAND.name,
}: {
variant?: "horizontal" | "signet";
tone?: CraftviaLogoTone;
/** Nur für variant="signet": App-Icon-Kachel in Lotsenblau. */
tile?: boolean;
/** Tagline „Handwerk. Digital auf Kurs." unter der Wortmarke (nur horizontal). */
tagline?: boolean;
/** Renderhöhe in px; die Breite ergibt sich aus dem Seitenverhältnis. */
height?: number;
className?: string;
title?: string;
}) {
const maskId = `cv-mask-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const isTile = variant === "signet" && tile;
const c = palette(tone, isTile);
if (variant === "signet") {
return (
<svg viewBox="0 0 64 64" width={height} height={height} role="img" aria-label={title} className={className}>
<title>{title}</title>
{isTile ? (
<>
<rect x="0" y="0" width="64" height="64" rx="14" fill={BRAND_COLORS.lotsenblau} />
<g transform="translate(9.6 9.6) scale(0.7)">
<Signet maskId={maskId} arc={c.arc} check={c.check} />
</g>
</>
) : (
<Signet maskId={maskId} arc={c.arc} check={c.check} />
)}
</svg>
);
}
const vbW = 292;
const vbH = tagline ? 82 : 64;
return (
<svg viewBox={`0 0 ${vbW} ${vbH}`} width={(height * vbW) / vbH} height={height} role="img" aria-label={title} className={className}>
<title>{title}</title>
<Signet maskId={maskId} arc={c.arc} check={c.check} />
<text
x={70}
y={48}
fontSize={46}
fontWeight={800}
letterSpacing={-1.2}
style={{ fontFamily: "var(--font-sans), Inter, system-ui, 'Segoe UI', Arial, sans-serif" }}
>
<tspan fill={c.craft}>Craft</tspan>
<tspan fill={c.via}>via</tspan>
</text>
{tagline && (
<text
x={72}
y={74}
fontSize={15}
fontWeight={600}
fill={c.tagline}
style={{ fontFamily: "var(--font-sans), Inter, system-ui, 'Segoe UI', Arial, sans-serif" }}
>
{BRAND.tagline}
</text>
)}
</svg>
);
}
-37
View File
@@ -1,37 +0,0 @@
import Image from "next/image";
import { BRAND, BRAND_ASSETS } from "@/lib/brand";
/**
* Dachmarken-Hinweis „Ein Produkt von GEFIM".
*
* Bewusste Leitplanke der Branding-Umstellung: Certvia ist die Produktmarke,
* GEFIM bleibt Dachmarke und erscheint weiterhin in Fußzeilen, auf den
* Anmeldeseiten sowie später in Export- und Mail-Fußzeilen.
* Siehe docs/BRANDING-CERTVIA.md.
*/
export function PoweredByGefim({
withLogo = true,
className,
}: {
withLogo?: boolean;
className?: string;
}) {
return (
<p
className={`flex items-center justify-center gap-2 text-[11px] text-muted-foreground ${className ?? ""}`}
>
<span>{BRAND.byline}</span>
{withLogo && (
<Image
src={BRAND_ASSETS.parentLogo}
alt={BRAND.parentBrand}
// Seitenverhältnis der Quelldatei (1000×353)
width={76}
height={27}
// Negativdarstellung auf dunklem Grund
className="opacity-80 brightness-0 invert"
/>
)}
</p>
);
}
+7 -9
View File
@@ -1,13 +1,12 @@
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import type { TenantBranding } from "@/lib/brand";
/**
* Kopfbereichs-Marke eines Mandanten (Story S8).
* Kopfbereichs-Marke eines Mandanten.
*
* Default ist **Certvia**; ein mandanteneigenes Logo überschreibt nur, wenn es
* gesetzt ist. Der Upload je Mandant kommt mit Admin Phase 2 (Objektspeicher) —
* bis dahin liefert `resolveTenantBranding` konstant `logoUrl: null`, die
* Verzweigung ist aber bereits vorhanden und muss dann nicht nachgezogen werden.
* Default ist **Craftvia**; ein mandanteneigenes Logo überschreibt nur, wenn es
* gesetzt ist (TenantSettings.logoKey → Upload folgt). Bis dahin liefert
* `resolveTenantBranding` konstant `logoUrl: null`.
*/
export function TenantBrand({
branding,
@@ -20,8 +19,7 @@ export function TenantBrand({
}) {
if (branding.logoUrl) {
return (
// Mandanten-Logos liegen später im Objektspeicher (beliebige Domain) —
// daher bewusst <img> statt next/image ohne Remote-Pattern-Konfiguration.
// Mandanten-Logos liegen im Objektspeicher — daher bewusst <img> statt next/image.
// eslint-disable-next-line @next/next/no-img-element
<img
src={branding.logoUrl}
@@ -31,5 +29,5 @@ export function TenantBrand({
/>
);
}
return <CertviaLogo variant="lockup" theme="dark" height={height} className={className} />;
return <CraftviaLogo variant="horizontal" height={height} className={className} />;
}