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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user