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} />;
}
+12 -90
View File
@@ -1,10 +1,9 @@
import { cn } from "@/lib/utils";
/**
* Basisbausteine im Look des UI-Mockups (docs/ISMS-Prototyp-GEFIM.html —
* historisches Projektartefakt, der Dateiname bleibt bewusst unverändert):
* PageHead (Crumb/Titel/Sub + Aktionen), KPI-Kapsel, C/I/A-Quadrate,
* Tag-Chip, Owner-Chip, Status-Pille.
* UI-Basisbausteine (Craftvia-CD): PageHead (Crumb/Titel/Sub + Aktionen), KPI-Kapsel,
* Tag-Chip, Owner-Chip, Status-Pille. Farbe nie als einziges Statussignal — Pillen
* tragen immer Text.
*/
export function PageHead({
@@ -56,80 +55,9 @@ export function KpiCard({
);
}
// Segmentbalken-Farben je Stufe 1–4 als Ampel: Grün → Gelb → Orange → Rot
const CIA_BAR_COLORS: Record<number, string> = {
1: "bg-[#2e9e6b]",
2: "bg-[#e5b000]",
3: "bg-[#e07d2e]",
4: "bg-[#d64c4c]",
};
/** Ein Schutzziel als 4er-Segmentbalken, gefüllt bis `level`. */
function CiaSegment({ level, label }: { level: number; label?: string }) {
const color = CIA_BAR_COLORS[level] ?? CIA_BAR_COLORS[1];
return (
<span className="inline-flex flex-col items-center gap-1">
<span className="flex gap-[2px]">
{[1, 2, 3, 4].map((seg) => (
<span
key={seg}
className={cn(
"h-4 w-[7px] rounded-[2px]",
seg <= level ? color : "bg-[rgba(120,135,180,0.2)]"
)}
/>
))}
</span>
{label && (
<small className="text-[9.5px] font-bold tracking-[.04em] text-muted-foreground">
{label}
</small>
)}
</span>
);
}
/**
* Schutzbedarf / Schadenshöhe als drei Segmentbalken (C/I/A). Mit `labels`
* werden VER/INT/VFB unter den Balken gezeigt (Detailkarten); ohne für
* kompakte Tabellenzellen.
*/
export function CiaBadge({
c,
i,
a,
labels = false,
}: {
c: number;
i: number;
a: number;
labels?: boolean;
}) {
return (
<span className="inline-flex items-center gap-2.5" title={`C${c} · I${i} · A${a}`}>
<CiaSegment level={c} label={labels ? "VER" : undefined} />
<CiaSegment level={i} label={labels ? "INT" : undefined} />
<CiaSegment level={a} label={labels ? "VFB" : undefined} />
</span>
);
}
/** VER/INT/VFB-Beschriftung passend zur Balkenbreite — einmal pro Tabelle als Überschrift. */
export function CiaLegend() {
return (
<span className="inline-flex items-center gap-2.5 text-[9.5px] font-bold tracking-[.04em] text-muted-foreground">
{["VER", "INT", "VFB"].map((l) => (
<span key={l} className="w-[34px] text-center">
{l}
</span>
))}
</span>
);
}
export function Tag({ children }: { children: React.ReactNode }) {
return (
<span className="inline-block rounded-md bg-[rgba(139,147,173,0.14)] px-2 py-0.5 text-[11px] font-bold text-[#b7bdd0]">
<span className="inline-block rounded-md bg-[var(--muted)] px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
{children}
</span>
);
@@ -153,15 +81,15 @@ export function OwnerChip({ name, noOwnerLabel }: { name?: string | null; noOwne
);
}
// Status-Chips: ~16–20 % Alpha der Statusfarbe auf Dunkel (Referenz-Vorgabe)
// Status-Chips: ~12 % Alpha der Funktionsfarbe auf hellem Grund (Brandbook §11.4).
const PILL_TONES = {
ok: "bg-[rgba(57,192,127,0.16)] text-[var(--ok)]",
warn: "bg-[rgba(240,173,78,0.16)] text-[var(--warn)]",
orange: "bg-[rgba(240,140,60,0.16)] text-[#f0a35a]",
risk: "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]",
info: "bg-[rgba(90,169,230,0.16)] text-[var(--info)]",
mut: "bg-[rgba(139,147,173,0.16)] text-muted-foreground",
violet: "bg-[rgba(125,111,214,0.2)] text-[#c3bdec]",
ok: "bg-[rgba(35,132,93,0.12)] text-[var(--ok)]",
warn: "bg-[rgba(200,121,18,0.12)] text-[var(--warn)]",
orange: "bg-[rgba(255,106,0,0.12)] text-[#b34a00]",
risk: "bg-[rgba(198,66,66,0.12)] text-[var(--risk)]",
info: "bg-[rgba(47,111,163,0.12)] text-[var(--info)]",
mut: "bg-[rgba(102,114,125,0.12)] text-muted-foreground",
violet: "bg-[rgba(8,46,91,0.1)] text-[var(--primary)]",
} as const;
export function Pill({
@@ -183,12 +111,6 @@ export function Pill({
);
}
/** Kritikalität 1–4 als Pille in Mockup-Farben (info/warn/risk). */
export function CriticalityPill({ level, label }: { level: number; label: string }) {
const tone = level >= 4 ? "risk" : level === 3 ? "warn" : level === 2 ? "info" : "ok";
return <Pill tone={tone}>{label}</Pill>;
}
export function SectTitle({ title, sub }: { title: string; sub?: string }) {
return (
<div>