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
+2 -2
View File
@@ -3,7 +3,7 @@ import { auth } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { resolvePasswordPolicy, describePasswordPolicy } from "@/lib/password-policy";
import { ForcePasswordChangeForm } from "@/components/force-password-change-form";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* Erzwungener Passwortwechsel beim ersten Login (Force-Change). Liegt außerhalb der
@@ -29,7 +29,7 @@ export default async function ChangePasswordPage() {
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={34} />
<CraftviaLogo variant="horizontal" height={34} />
<p className="mt-5 font-heading text-lg font-semibold">Passwort ändern</p>
<p className="mt-1 text-sm text-muted-foreground">
Ihr Konto wurde mit einem Initialpasswort angelegt. Bitte vergeben Sie jetzt ein eigenes Passwort, um fortzufahren.
+2 -2
View File
@@ -7,7 +7,7 @@ import { newTotpSecret, totpUri } from "@/server/mfa";
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
import { TenantMfaEnrollForm } from "@/components/tenant-mfa-enroll-form";
import { PasskeyManager } from "@/components/passkey-manager";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* SEC3-a: Pflicht-MFA-Einrichtung für Mandanten-Nutzer, wenn der Mandant MFA verlangt
@@ -37,7 +37,7 @@ export default async function EnrollMfaPage() {
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-md rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={34} />
<CraftviaLogo variant="horizontal" height={34} />
<p className="mt-5 font-heading text-lg font-semibold">Zwei-Faktor-Authentifizierung einrichten</p>
<p className="mt-1 text-sm text-muted-foreground">
Ihre Organisation verlangt eine Zwei-Faktor-Authentifizierung. Scannen Sie den QR-Code mit
+2 -4
View File
@@ -1,8 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* Gebrandete Fehlerseite (Story S7). Zeigt bewusst keine technischen Details —
@@ -19,7 +18,7 @@ export default function Error({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CertviaLogo variant="lockup" theme="dark" height={34} className="mx-auto" />
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-6 font-heading text-lg font-semibold">Es ist ein Fehler aufgetreten</p>
<p className="mt-1 text-sm text-muted-foreground">
Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut oder wenden
@@ -31,7 +30,6 @@ export default function Error({
<Button onClick={reset} className="mt-6 w-full justify-center">
Erneut versuchen
</Button>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -5
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { ForgotPasswordForm } from "@/components/auth-recovery-forms";
import { loginPathOf } from "@/server/auth-selfservice";
@@ -25,7 +24,7 @@ export default async function ForgotPasswordPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={36} />
<CraftviaLogo variant="horizontal" height={36} />
<p className="mt-5 font-heading text-lg font-semibold">Passwort vergessen</p>
<p className="mt-1 text-sm text-muted-foreground">
{domain === "platform"
@@ -40,8 +39,6 @@ export default async function ForgotPasswordPage({
Zurück zur Anmeldung
</Link>
</p>
<PoweredByGefim className="mt-6 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+78 -269
View File
@@ -7,9 +7,9 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-open-sans);
--font-sans: var(--font-inter);
--font-mono: ui-monospace, monospace;
--font-heading: var(--font-poppins);
--font-heading: var(--font-inter);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -39,7 +39,7 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* Semantische Zusatzflächen (dunkles Certvia-Theme) */
/* Semantische Zusatzflächen */
--color-panel: var(--panel);
--color-panel-brd: var(--panel-brd);
--color-elevated: var(--elevated);
@@ -47,6 +47,8 @@
--color-band: var(--band);
--color-band-text: var(--band-text);
--color-band-brd: var(--band-brd);
--color-cta: var(--ui-accent);
--color-cta-foreground: var(--ui-accent-foreground);
--color-ok: var(--ok);
--color-warn: var(--warn);
--color-risk: var(--risk);
@@ -61,133 +63,109 @@
}
/*
* Certvia — zentrale Token-Definition (Single Source of Truth für Farben).
* Craftvia — zentrale Token-Definition (Single Source of Truth für Farben).
* Quelle: docs/craftvia/BRANDBOOK.md §11.4. JS-Pendant: src/lib/brand.ts.
*
* Zwei bewusst getrennte Ebenen:
* --brand-* Markenfarben aus dem Certvia-Styleguide (Logo, Print, Verläufe,
* Dokument-/Export-CD). NICHT für UI-Flächen verwenden.
* --ui-* Produktpalette des dunklen App-Themes (Interaktion, Flächen).
* Sie ist die festgelegte Certvia-UI-Palette und wird nicht an die
* Markenfarben „angeglichen" — Marken-Violett #5d52a3 gilt fürs Logo,
* UI-Interaktiv bleibt #7d6fd6.
* Zwei Ebenen:
* --brand-* Kernfarben der Marke (Logo, Print, Dokumente).
* --ui-* Rollen im hellen App-Theme (Interaktion, Flächen), abgeleitet aus --brand-*.
*
* Die semantischen shadcn-Tokens (--primary, --background, …) leiten sich unten
* ausschließlich aus --ui-* / --bg-* ab; Hex-Werte stehen nur noch hier.
* Ein späterer Light-Mode kann über dieselben Token-Namen unter .light ergänzt werden.
* JS-seitiges Pendant (Canvas-/PNG-Export, Metadaten): src/lib/brand.ts.
* ausschließlich aus --brand-* / --ui-* ab; Hex-Werte stehen nur in diesem Block.
* Farbe nie als einziges Statussignal — immer mit Text/Icon kombinieren.
*/
:root {
/* ---- Marke (Logo / Print / Export) ---- */
--brand-violet: #5d52a3;
--brand-magenta: #812d80;
--brand-blue: #8dc4e0;
--brand-anthracite: #3b3b3a;
/* Marken-Varianten auf dunklem Grund (Styleguide) */
--brand-violet-on-dark: #7d6fd6;
--brand-magenta-on-dark: #d17bcf;
/* ---- Marke ---- */
--brand-lotsenblau: #082e5b;
--brand-signalorange: #ff6a00;
--brand-graphit: #34383d;
--brand-hafengrau: #f3f5f7;
--brand-stahlgrau: #66727d;
--brand-zink: #d9e0e5;
--brand-white: #ffffff;
/* ---- UI-Palette (dunkles Produkt-Theme) ---- */
--ui-primary: #7d6fd6;
--ui-primary-deep: #5d52a3;
--ui-blue: #8dc4e0;
--ui-magenta: #b45bb0;
--ui-primary-soft: rgba(125, 111, 214, 0.18);
--ui-primary-ring: rgba(125, 111, 214, 0.35);
/* ---- Funktionsfarben ---- */
--ok: #23845d;
--warn: #c87912;
--risk: #c64242;
--info: #2f6fa3;
/* ---- UI-Rollen (helles Produkt-Theme) ---- */
--ui-primary: var(--brand-lotsenblau);
--ui-primary-hover: #0b3c75;
--ui-primary-soft: rgba(8, 46, 91, 0.08);
--ui-primary-ring: rgba(8, 46, 91, 0.3);
/* Akzent/CTA: Signalorange — sparsam für die wichtigste Aktion je Ansicht. */
--ui-accent: var(--brand-signalorange);
--ui-accent-foreground: #ffffff;
/* Grundflächen */
--bg-0: #0e1220;
--bg-1: #141a2e;
--bg-2: #1a2138;
--bg-3: #1e2640;
--txt: #e8ecf7;
--txt-muted: #8b93ad;
--line: rgba(120, 135, 180, 0.18);
--bg-0: var(--brand-hafengrau);
--bg-1: var(--brand-white);
--bg-2: #eef1f4;
--txt: var(--brand-graphit);
--txt-muted: var(--brand-stahlgrau);
--line: var(--brand-zink);
/* Verläufe */
--grad: var(--grad-primary);
--grad-primary: linear-gradient(
135deg,
var(--ui-primary-deep) 0%,
var(--ui-primary) 100%
);
--grad-soft: var(--grad-primary);
--grad-critical: linear-gradient(90deg, var(--risk) 0%, var(--ui-magenta) 100%);
/* Keine Verläufe im Craftvia-CD: die Utility-Namen bleiben, zeigen aber Vollfarbe. */
--grad-primary: var(--ui-primary);
/* Status */
--ok: #39c07f;
--warn: #f0ad4e;
--risk: #ff6b6b;
--info: #5aa9e6;
/* Flächen (semantisch → leiten aus den Tokens oben ab) */
/* Flächen (semantisch) */
--background: var(--bg-0);
--foreground: var(--txt);
--card: var(--bg-1);
--card-foreground: var(--txt);
--popover: var(--bg-2);
--popover: var(--bg-1);
--popover-foreground: var(--txt);
--primary: var(--ui-primary);
--primary-foreground: #ffffff;
--secondary: var(--bg-2);
--secondary-foreground: #cfc9ee;
--secondary-foreground: var(--brand-lotsenblau);
--muted: var(--bg-2);
--muted-foreground: var(--txt-muted);
--accent: var(--bg-3);
--accent-foreground: #cfc9ee;
--accent: var(--ui-primary-soft);
--accent-foreground: var(--brand-lotsenblau);
--destructive: var(--risk);
--border: var(--line);
--input: rgba(120, 135, 180, 0.22);
--input: var(--line);
--ring: var(--ui-primary);
--chart-1: var(--ui-primary);
--chart-2: var(--ui-blue);
--chart-3: var(--ui-magenta);
--chart-1: var(--brand-lotsenblau);
--chart-2: var(--brand-signalorange);
--chart-3: var(--info);
--chart-4: var(--ok);
--chart-5: var(--warn);
--radius: 0.7rem;
--radius: 0.6rem;
/* Panels / Glas / erhöhte Flächen */
--panel: rgba(30, 38, 64, 0.72);
/* Panels / erhöhte Flächen */
--panel: rgba(255, 255, 255, 0.92);
--panel-brd: var(--line);
--elevated: var(--bg-2);
/* weiche Akzentkarte (früher #faf9fd) */
--surface-soft: rgba(125, 111, 214, 0.08);
/* Info-Band (früher #f2f0f9 / #e6e2f3 / #5a4e86) */
--band: rgba(125, 111, 214, 0.1);
--band-text: #c3bdec;
--band-brd: rgba(125, 111, 214, 0.22);
/* Chip auf farbiger Fläche (Risiko-Heatmap) — dunkler Grund, halbtransparent */
--chip-overlay: rgba(14, 18, 32, 0.75);
/* Graph (React Flow) — Linien/Labels, die als SVG-Attribute gesetzt werden */
--graph-edge: #4a5372;
--graph-edge-critical: var(--risk);
--surface-soft: var(--ui-primary-soft);
--band: rgba(47, 111, 163, 0.08);
--band-text: var(--info);
--band-brd: rgba(47, 111, 163, 0.25);
/* Sidebar / Topbar */
--sidebar: var(--bg-1);
--sidebar-foreground: #b6bdd4;
--sidebar-foreground: var(--brand-graphit);
--sidebar-primary: var(--ui-primary);
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--ui-primary-soft);
--sidebar-accent-foreground: #cfc9ee;
--sidebar-accent-foreground: var(--brand-lotsenblau);
--sidebar-border: var(--line);
--sidebar-ring: var(--ui-primary);
--focus-ring: 0 0 0 3px var(--ui-primary-ring);
--radius-card: 16px;
--radius-ctrl: 10px;
--dot-grid: radial-gradient(rgba(140, 155, 200, 0.1) 1px, transparent 1px);
--glow:
radial-gradient(1200px 700px at 20% -10%, #1c2340 0%, transparent 60%),
radial-gradient(900px 600px at 100% 110%, #1a2138 0%, transparent 55%);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 28px rgba(0, 0, 0, 0.28);
--radius-card: 12px;
--radius-ctrl: 8px;
/* Flaches CD: nur eine feine Kontur statt Schlagschatten. */
--shadow-card: 0 1px 2px rgba(8, 46, 91, 0.06);
}
body {
font-family: var(--font-sans), "Open Sans", system-ui, sans-serif;
font-family: var(--font-sans), Inter, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 14px;
background-color: var(--background);
background-image: var(--glow);
background-attachment: fixed;
}
@layer base {
@@ -204,149 +182,29 @@ body {
h2,
h3,
h4 {
font-family: var(--font-heading), "Poppins", sans-serif;
font-weight: 600;
font-family: var(--font-heading), Inter, system-ui, sans-serif;
font-weight: 700;
color: var(--brand-lotsenblau);
}
}
@layer utilities {
.bg-grad {
background: var(--grad-primary);
}
.bg-grad,
.bg-grad-soft {
background: var(--grad-primary);
}
.bg-grad-critical {
background: var(--grad-critical);
}
.shadow-card {
box-shadow: var(--shadow-card);
}
.dot-grid {
background-image: var(--dot-grid);
background-size: 26px 26px;
}
}
/* React Flow — Dark-Anpassungen + Glow für kritische Kanten (Teil B) */
.react-flow__edge.critical-edge .react-flow__edge-path {
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.7));
}
.react-flow__controls-button {
background: var(--elevated) !important;
border-color: var(--panel-brd) !important;
color: var(--muted-foreground) !important;
}
.react-flow__controls-button:hover {
background: var(--accent) !important;
}
.react-flow__controls-button svg {
fill: currentColor;
}
.react-flow__edge-text {
fill: var(--muted-foreground);
}
/* Gerenderte Richtlinien/Verfahren (Lesemodus) — Typografie ohne Plugin */
.policy-prose {
font-size: 13.5px;
line-height: 1.7;
color: var(--foreground);
}
.policy-prose h2 {
font-family: var(--font-heading, inherit);
font-size: 16px;
font-weight: 700;
margin: 1.6em 0 0.5em;
padding-top: 0.9em;
border-top: 1px solid var(--border);
scroll-margin-top: 1rem;
}
.policy-prose h2:first-child {
border-top: 0;
padding-top: 0;
margin-top: 0;
}
.policy-prose h3 {
font-family: var(--font-heading, inherit);
font-size: 14px;
font-weight: 700;
margin: 1.3em 0 0.4em;
}
.policy-prose h4 {
font-size: 13px;
font-weight: 700;
margin: 1em 0 0.3em;
}
.policy-prose p {
margin: 0.55em 0;
}
.policy-prose ul,
.policy-prose ol {
margin: 0.5em 0;
padding-left: 1.3em;
list-style: revert;
}
.policy-prose li {
margin: 0.25em 0;
}
.policy-prose strong {
font-weight: 700;
}
.policy-prose a {
color: var(--info);
text-decoration: none;
}
.policy-prose a:hover {
text-decoration: underline;
}
.policy-prose blockquote {
border-left: 3px solid var(--band-brd);
background: var(--band);
color: var(--band-text);
padding: 0.5em 0.9em;
margin: 0.8em 0;
border-radius: 0 8px 8px 0;
font-size: 12.5px;
}
.policy-prose table {
width: 100%;
border-collapse: collapse;
margin: 0.9em 0;
font-size: 12.5px;
}
.policy-prose th,
.policy-prose td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
vertical-align: top;
}
.policy-prose th {
background: var(--elevated);
font-weight: 700;
}
.policy-prose code {
background: var(--elevated);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
/* =========================================================================
* Dokument-/Druck-Corporate-Design (Story S5)
*
* Ausgaben außerhalb der App laufen im hellen Certvia-CD: Marken-Violett/
* Magenta auf Weiß, Poppins für Überschriften, Open Sans im Fließtext.
* Kopfzeile = Certvia-Lockup (das SVG trägt Poppins eingebettet, siehe
* public/assets/logo/certvia-lockup-positiv.svg), Fußzeile = Dachmarken-
* Hinweis „Certvia — ein Produkt von GEFIM" auf jeder Seite.
*
* Farb-/Textreferenz für den späteren DOCX/PDF-Export: src/lib/document-brand.ts.
* Dokument-/Druck-Corporate-Design (Einsatzberichte, Druckansicht)
* Farb-/Textreferenz für den PDF-Export: src/lib/document-brand.ts.
* ========================================================================= */
@page {
size: A4;
margin: 24mm 16mm 22mm;
margin: 20mm 16mm 20mm;
}
@media print {
@@ -361,82 +219,33 @@ body {
:root {
--background: #ffffff;
--foreground: var(--brand-anthracite);
--card: #ffffff;
--card-foreground: var(--brand-anthracite);
--muted-foreground: #6b6b70;
--primary: var(--brand-violet);
--border: #d9d7e4;
--panel: #ffffff;
--panel-brd: #d9d7e4;
--elevated: #f4f2fa;
--band: #f4f2fa;
--band-text: var(--brand-violet);
--band-brd: #d9d7e4;
--info: var(--brand-violet);
}
body {
background: #ffffff !important;
background-image: none !important;
color: var(--brand-anthracite);
color: var(--brand-graphit);
}
main {
padding: 0 !important;
}
/* Kopf: Certvia-Lockup, einmal am Dokumentanfang. */
body::before {
content: "";
display: block;
height: 13mm;
margin-bottom: 6mm;
padding-bottom: 4mm;
border-bottom: 1px solid #d9d7e4;
background: url("/assets/logo/certvia-lockup-positiv.svg") left center / auto 11mm no-repeat;
}
/* Fuß: Dachmarken-Hinweis, auf jeder Seite (fixed wiederholt beim Druck). */
/* Fuß: Produkthinweis auf jeder Seite. */
body::after {
content: "Certvia — ein Produkt von GEFIM";
content: "Erstellt mit Craftvia";
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-top: 2mm;
border-top: 1px solid #d9d7e4;
font-family: var(--font-sans), "Open Sans", sans-serif;
border-top: 1px solid var(--brand-zink);
font-size: 9pt;
color: #6b6b70;
color: var(--brand-stahlgrau);
}
.shadow-card,
.bg-grad,
.bg-grad-soft {
.shadow-card {
box-shadow: none !important;
background: #ffffff !important;
color: var(--brand-anthracite) !important;
}
.policy-prose {
color: var(--brand-anthracite);
font-size: 10.5pt;
}
.policy-prose h2,
.policy-prose h3 {
color: var(--brand-violet);
break-after: avoid;
}
.policy-prose table,
.policy-prose blockquote {
break-inside: avoid;
}
.policy-prose th {
background: #f4f2fa;
}
.policy-prose a {
color: var(--brand-violet);
text-decoration: none;
}
}
+2 -5
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import { prisma } from "@/server/db";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { ResetPasswordForm } from "@/components/auth-recovery-forms";
import { checkInvitationToken } from "@/server/actions/auth-recovery";
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
@@ -37,7 +36,7 @@ export default async function InvitePage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={36} />
<CraftviaLogo variant="horizontal" height={36} />
<p className="mt-5 font-heading text-lg font-semibold">Konto einrichten</p>
{valid ? (
@@ -57,8 +56,6 @@ export default async function InvitePage({
</Link>
</>
)}
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+12 -40
View File
@@ -5,30 +5,13 @@ import { getLocale } from "next-intl/server";
import { BRAND, BRAND_ASSETS, BRAND_COLORS } from "@/lib/brand";
import "./globals.css";
// Selbst gehostet über next/font/local (committete woff2 in ./fonts) — kein
// Google-Fonts-Fetch zur Build- oder Laufzeit. DSGVO-konform und macht den
// Produktiv-Build reproduzierbar/offline (next/font/google lud die Schriften
// sonst zur Build-Zeit von Google, was flakig fehlschlagen kann).
const openSans = localFont({
variable: "--font-open-sans",
// Inter (Brandbook §11.5), selbst gehostet über next/font/local — kein Google-Fonts-Fetch
// zur Build- oder Laufzeit (DSGVO, reproduzierbarer Offline-Build). Variable Font
// (Gewichte 100–900 in einer Datei). TODO(brand): auf woff2-Subsets umstellen (kleiner).
const inter = localFont({
variable: "--font-inter",
display: "swap",
src: [
{ path: "./fonts/open-sans-300.woff2", weight: "300", style: "normal" },
{ path: "./fonts/open-sans-400.woff2", weight: "400", style: "normal" },
{ path: "./fonts/open-sans-600.woff2", weight: "600", style: "normal" },
{ path: "./fonts/open-sans-700.woff2", weight: "700", style: "normal" },
],
});
const poppins = localFont({
variable: "--font-poppins",
display: "swap",
src: [
{ path: "./fonts/poppins-300.woff2", weight: "300", style: "normal" },
{ path: "./fonts/poppins-500.woff2", weight: "500", style: "normal" },
{ path: "./fonts/poppins-600.woff2", weight: "600", style: "normal" },
{ path: "./fonts/poppins-700.woff2", weight: "700", style: "normal" },
],
src: [{ path: "./fonts/inter-variable.ttf", weight: "100 900", style: "normal" }],
});
export const metadata: Metadata = {
@@ -37,35 +20,27 @@ export const metadata: Metadata = {
description: BRAND.description,
applicationName: BRAND.name,
manifest: "/site.webmanifest",
// Alle Icons aus dem Certvia-Asset-Bundle (public/favicon + public/assets/logo).
// Icons aus dem Craftvia-Signet erzeugt (scripts/generate-brand-icons.ts).
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: BRAND_ASSETS.favicon, type: "image/svg+xml" },
{ url: "/favicon/icon-16.png", type: "image/png", sizes: "16x16" },
{ url: "/favicon/icon-32.png", type: "image/png", sizes: "32x32" },
],
apple: [{ url: "/favicon/apple-touch-icon-180.png", sizes: "180x180" }],
apple: [{ url: BRAND_ASSETS.appleTouchIcon, sizes: "180x180" }],
},
appleWebApp: { capable: true, title: BRAND.name, statusBarStyle: "black-translucent" },
appleWebApp: { capable: true, title: BRAND.name, statusBarStyle: "default" },
openGraph: {
type: "website",
siteName: BRAND.name,
title: `${BRAND.name} — ${BRAND.tagline}`,
description: BRAND.description,
images: [{ url: "/assets/logo/certvia-lockup-positiv-1024.png", width: 1024, height: 259 }],
},
twitter: {
card: "summary_large_image",
title: `${BRAND.name} — ${BRAND.tagline}`,
description: BRAND.description,
images: ["/assets/logo/certvia-lockup-positiv-1024.png"],
images: [{ url: BRAND_ASSETS.ogImage, width: 512, height: 512 }],
},
};
export const viewport: Viewport = {
// Marken-Violett (wie auf der Certvia-Website), nicht die UI-Primärfarbe.
themeColor: BRAND_COLORS.violet,
themeColor: BRAND_COLORS.lotsenblau,
};
export default async function RootLayout({
@@ -76,10 +51,7 @@ export default async function RootLayout({
const locale = await getLocale();
return (
<html
lang={locale}
className={`${openSans.variable} ${poppins.variable} h-full antialiased`}
>
<html lang={locale} className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-background text-foreground">
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
+2 -5
View File
@@ -8,8 +8,7 @@ import { verifyMfaPending, signLoginTicket, MFA_PENDING_COOKIE } from "@/server/
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* WS5 (Option C) — Two-Step-Login, Schritt 2: MFA. Erreichbar NUR mit gültigem,
@@ -57,7 +56,7 @@ export default async function LoginMfaPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={40} />
<CraftviaLogo variant="horizontal" height={40} />
<p className="mt-5 font-heading text-lg font-semibold">Bestätigung (2. Schritt)</p>
<p className="mt-1 text-sm text-muted-foreground">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App oder einen Recovery-Code ein.
@@ -81,8 +80,6 @@ export default async function LoginMfaPage({
<p className="mt-4 text-center text-[12.5px]">
<Link href="/login" className="text-muted-foreground hover:text-foreground">Zurück zur Anmeldung</Link>
</p>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -5
View File
@@ -9,8 +9,7 @@ import { signMfaPending, signLoginTicket, MFA_PENDING_COOKIE } from "@/server/lo
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { PasskeyLoginButton } from "@/components/passkey-login-button";
export default async function LoginPage({
@@ -78,7 +77,7 @@ export default async function LoginPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={42} />
<CraftviaLogo variant="horizontal" height={42} />
<p className="mt-3 font-heading text-[13px] font-medium text-[var(--band-text)]">
{t("tagline")}
</p>
@@ -122,8 +121,6 @@ export default async function LoginPage({
{t("forgotPassword")}
</Link>
</p>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -4
View File
@@ -1,14 +1,13 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/** Gebrandete 404-Seite (Story S7 — keine ungebrandeten Systemscreens). */
export default function NotFound() {
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CertviaLogo variant="lockup" theme="dark" height={34} className="mx-auto" />
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-6 font-heading text-4xl font-bold text-[var(--primary)]">404</p>
<p className="mt-2 font-heading text-lg font-semibold">Seite nicht gefunden</p>
<p className="mt-1 text-sm text-muted-foreground">
@@ -21,7 +20,6 @@ export default function NotFound() {
>
Zum Dashboard
</Button>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -2
View File
@@ -5,7 +5,7 @@ import { prisma } from "@/server/db";
import { newTotpSecret, totpUri } from "@/server/mfa";
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
import { PlatformEnrollForm } from "@/components/platform-enroll-form";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* MFA-Einrichtung für Plattform-Administratoren (Pflicht beim ersten Login).
@@ -30,7 +30,7 @@ export default async function EnrollMfaPage() {
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-md rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={34} />
<CraftviaLogo variant="horizontal" height={34} />
<p className="mt-5 font-heading text-lg font-semibold">Zwei-Faktor-Authentifizierung einrichten</p>
<p className="mt-1 text-sm text-muted-foreground">
Für Plattform-Administratoren verpflichtend. Scannen Sie den QR-Code mit einer
+2 -5
View File
@@ -5,8 +5,7 @@ import { platformAuth, platformSignIn } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* Getrennter Login für Plattform-Administratoren (Phase-1-Härtung Paket 2).
@@ -41,7 +40,7 @@ export default async function PlatformLoginPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={38} />
<CraftviaLogo variant="horizontal" height={38} />
<p className="mt-4 font-heading text-lg font-semibold">Plattform-Administration</p>
<h2 className="mt-1 text-sm font-normal text-muted-foreground">
Betreiberzugang — getrennt vom Mandanten-Login
@@ -84,8 +83,6 @@ export default async function PlatformLoginPage({
Passwort vergessen?
</Link>
</p>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -5
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import { prisma } from "@/server/db";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { ResetPasswordForm } from "@/components/auth-recovery-forms";
import { checkResetToken } from "@/server/actions/auth-recovery";
import { loginPathOf } from "@/server/auth-selfservice";
@@ -43,7 +42,7 @@ export default async function ResetPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={36} />
<CraftviaLogo variant="horizontal" height={36} />
<p className="mt-5 font-heading text-lg font-semibold">Neues Passwort vergeben</p>
{valid ? (
@@ -74,8 +73,6 @@ export default async function ResetPage({
</Link>
</>
)}
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -5
View File
@@ -2,8 +2,7 @@ import { redirect } from "next/navigation";
import { auth, signOut } from "@/server/auth";
import { setActiveTenant } from "@/server/actions/tenant-switch";
import { Button } from "@/components/ui/button";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/**
* WS2 (Option C) — Mandantenauswahl nach dem Login, wenn eine Person mehreren
@@ -22,7 +21,7 @@ export default async function SelectTenantPage() {
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={36} />
<CraftviaLogo variant="horizontal" height={36} />
<p className="mt-5 font-heading text-lg font-semibold">Mandant wählen</p>
<p className="mt-1 text-sm text-muted-foreground">
Ihr Konto gehört mehreren Mandanten an. Bitte wählen Sie, in welchem Sie arbeiten möchten.
@@ -48,8 +47,6 @@ export default async function SelectTenantPage() {
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
<Button type="submit" variant="ghost" className="w-full justify-center text-[13px]">Abmelden</Button>
</form>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
+2 -5
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { CertviaLogo } from "@/components/brand/certvia-logo";
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { confirmEmailChange } from "@/server/actions/auth-recovery";
import { loginPathOf } from "@/server/auth-selfservice";
@@ -32,7 +31,7 @@ export default async function VerifyEmailPage({
return (
<main className="flex flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
<CertviaLogo variant="lockup" theme="dark" height={36} />
<CraftviaLogo variant="horizontal" height={36} />
<p className="mt-5 font-heading text-lg font-semibold">E-Mail-Adresse bestätigen</p>
<p
@@ -52,8 +51,6 @@ export default async function VerifyEmailPage({
>
Zur Anmeldung →
</Link>
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
</div>
</main>
);
-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>
+49 -68
View File
@@ -1,106 +1,88 @@
/**
* Certvia — Marken- und Produktkonstanten (JS-Pendant zu den CSS-Tokens in
* `src/app/globals.css`).
* Craftvia — Marken- und Produktkonstanten (JS-Pendant zu den CSS-Tokens in
* `src/app/globals.css`). Quelle: docs/craftvia/BRANDBOOK.md §11.4/§11.5.
*
* Hier steht nur, was CSS-Variablen NICHT auflösen können:
* - Metadaten (`<title>`, OG/Twitter, Manifest, TOTP-Issuer)
* - Canvas-/PNG-Export (html-to-image rendert außerhalb des CSS-Kontexts)
* - Dokument-/Export-Corporate-Design (DOCX/PDF, Print-Kopf/-Fußzeile)
* - Metadaten (`<title>`, OG, Manifest, TOTP-Issuer, WebAuthn-RP-Name)
* - Dokument-/Export-Corporate-Design (PDF-Berichte, Print)
* - E-Mail-Templates (Inline-Styles, kein CSS-Var-Support in Mail-Clients)
* - Inline-SVG-Logo (src/components/brand/craftvia-logo.tsx)
*
* Für alles, was im Browser gerendert wird, gelten die CSS-Tokens als Quelle —
* dieses Modul spiegelt sie nur, damit beide Seiten nicht auseinanderlaufen.
*
* Leitplanke: GEFIM bleibt Dachmarke. Produktmarke = Certvia.
*/
/** Markenfarben aus dem Certvia-Styleguide (Logo, Print, Verläufe). */
/** Kernfarben aus dem Craftvia-Brandbook (§11.4). */
export const BRAND_COLORS = {
violet: "#5d52a3",
magenta: "#812d80",
blue: "#8dc4e0",
anthracite: "#3b3b3a",
/** Varianten auf dunklem Grund */
violetOnDark: "#7d6fd6",
magentaOnDark: "#d17bcf",
white: "#ffffff",
/** Primär — Logo, Navigation, Headlines */
lotsenblau: "#082E5B",
/** Sekundär — „via", Haken-/Weg-Signet, CTA, aktive Elemente */
signalorange: "#FF6A00",
/** Dunkel — Fließtext, Footer */
graphit: "#34383D",
/** Hell — Flächen, Seitenhintergrund */
hafengrau: "#F3F5F7",
/** Neutral — Sekundärtext, Icons */
stahlgrau: "#66727D",
/** Linie — Trennlinien, Eingabefelder */
zink: "#D9E0E5",
white: "#FFFFFF",
} as const;
/**
* UI-Palette des dunklen Produkt-Themes. Bewusst getrennt von BRAND_COLORS:
* Marken-Violett #5d52a3 gilt fürs Logo, UI-Interaktiv bleibt #7d6fd6.
*/
export const UI_COLORS = {
primary: "#7d6fd6",
primaryDeep: "#5d52a3",
blue: "#8dc4e0",
magenta: "#b45bb0",
bg0: "#0e1220",
bg1: "#141a2e",
bg2: "#1a2138",
text: "#e8ecf7",
textMuted: "#8b93ad",
line: "rgba(120, 135, 180, 0.18)",
graphEdge: "#4a5372",
ok: "#39c07f",
warn: "#f0ad4e",
risk: "#ff6b6b",
info: "#5aa9e6",
/** Funktionsfarben (§11.4). Farbe nie als einziges Statussignal einsetzen. */
export const STATUS_COLORS = {
success: "#23845D",
warning: "#C87912",
error: "#C64242",
info: "#2F6FA3",
} as const;
/** Produktidentität — überall dort, wo der Name als Text erscheint. */
export const BRAND = {
/** Produktmarke */
name: "Certvia",
tagline: "Informationssicherheit. Endlich einfach.",
name: "Craftvia",
tagline: "Handwerk. Digital auf Kurs.",
description:
"Certvia — Informationssicherheits-Managementsystem für ISO/IEC 27001:2022 und TISAX/VDA-ISA.",
/** Dachmarke (bleibt erhalten, siehe docs/BRANDING-CERTVIA.md) */
parentBrand: "GEFIM",
/** Fußzeilen-/Signaturhinweis für UI, Export und E-Mail */
byline: "Ein Produkt von GEFIM",
"Craftvia — die Einsatz-App für Handwerks- und Montagebetriebe: Aufträge, Teams, Einsätze und Berichte in einem System.",
/** Name des In-Product-Assistenten (separat vom Logo, §Lotse) */
assistantName: "Lotse",
/** Kompakte Form für Dokument-Fußzeilen */
documentByline: "Certvia — ein Produkt von GEFIM",
documentByline: "Erstellt mit Craftvia",
} as const;
/** Asset-Pfade unter `public/` (Logo-Bundle aus dem Design-Paket). */
/** Asset-Pfade unter `public/` (aus dem Inline-SVG-Logo erzeugt). */
export const BRAND_ASSETS = {
markPositive: "/assets/logo/certvia-mark-positiv.svg",
markNegative: "/assets/logo/certvia-mark-negativ.svg",
markGrey: "/assets/logo/certvia-mark-grau.svg",
markAnthracite: "/assets/logo/certvia-mark-anthrazit.svg",
lockupPositive: "/assets/logo/certvia-lockup-positiv.svg",
favicon: "/assets/logo/certvia-favicon.svg",
/** Dachmarke */
parentLogo: "/assets/logo/gefim-logo.png",
} as const;
/** Schriftstapel für Kontexte ohne Tailwind/CSS-Vars (Export, E-Mail, Print). */
export const BRAND_FONTS = {
heading: "'Poppins', 'Segoe UI', system-ui, sans-serif",
body: "'Open Sans', system-ui, -apple-system, sans-serif",
favicon: "/favicon/craftvia-signet.svg",
appleTouchIcon: "/favicon/apple-touch-icon-180.png",
ogImage: "/favicon/pwa-512.png",
} as const;
/**
* Standard-Branding eines Mandanten (Story S8). Ohne eigenes Logo/Akzent zeigt
* jeder Mandant Certvia; ein späterer Logo-Upload (Admin Phase 2) überschreibt
* ausschließlich die gesetzten Felder.
* Schriftstapel für Kontexte ohne Tailwind/CSS-Vars (Export, E-Mail, Print).
* Primärschrift laut Brandbook: Inter. TODO(brand): Inter selbst hosten (woff2 in
* src/app/fonts) — bis dahin nutzt die App die vorhandenen selbst gehosteten Schriften.
*/
export const BRAND_FONTS = {
heading: "Inter, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
body: "Inter, system-ui, 'Segoe UI', Roboto, Arial, sans-serif",
} as const;
/**
* Standard-Branding eines Mandanten. Ohne eigenes Logo/Akzent zeigt jeder Mandant
* Craftvia; ein späterer Logo-Upload überschreibt ausschließlich die gesetzten Felder.
*/
export type TenantBranding = {
/** Produktname im Kopfbereich */
productName: string;
/** Custom-Logo des Mandanten; `null` ⇒ Certvia-Lockup */
/** Custom-Logo des Mandanten; `null` ⇒ Craftvia-Logo */
logoUrl: string | null;
/** Akzentfarbe; `null` ⇒ UI-Primärfarbe */
accent: string | null;
/** Dachmarken-Hinweis, unabhängig vom Mandanten-Branding */
byline: string;
};
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
/**
* Löst das effektive Branding eines Mandanten auf. Certvia ist der Default;
* Löst das effektive Branding eines Mandanten auf. Craftvia ist der Default;
* mandanteneigene Werte überschreiben nur, wenn sie gesetzt und plausibel sind.
*/
export function resolveTenantBranding(
@@ -112,6 +94,5 @@ export function resolveTenantBranding(
productName: BRAND.name,
logoUrl: logoUrl ? logoUrl : null,
accent: accent && HEX_RE.test(accent) ? accent : null,
byline: BRAND.byline,
};
}
+17 -28
View File
@@ -1,28 +1,23 @@
import { BRAND, BRAND_COLORS, BRAND_FONTS } from "@/lib/brand";
/**
* Branding-Layer für Dokument-Ausgaben (Story S5).
* Branding-Layer für Dokument-Ausgaben (Einsatzberichte, Druckansicht, PDF-Export).
*
* Ausgaben außerhalb der App-UI — Druckansicht, künftiger DOCX-/PDF-Export von
* Richtlinien, VDA-ISA-Katalog und Reports — laufen im **hellen** Corporate
* Design: Marken-Violett/Magenta auf Weiß, Poppins für Überschriften, Open Sans
* für Fließtext, Fußzeile mit dem Dachmarken-Hinweis.
*
* Bewusst ohne Abhängigkeit zu einer Export-Bibliothek: das Export-Feature
* (DOCX/PDF) existiert noch nicht. Dieses Modul liefert die Kopf-/Fußzeilen-
* Bausteine und die Farb-/Schriftreferenz, an die es dann andockt — damit das
* CD nicht ein zweites Mal definiert werden muss.
* Ausgaben außerhalb der App-UI laufen im hellen Craftvia-CD: Lotsenblau für
* Überschriften, Signalorange als sparsamer Akzent, Graphit für Fließtext.
* Bewusst ohne Abhängigkeit zu einer Export-Bibliothek — das Berichtsmodul dockt
* an diese Farb-/Schriftreferenz an.
*/
/** Farb-/Schriftreferenz für Dokumentausgaben (heller Grund). */
export const DOCUMENT_THEME = {
pageBackground: "#ffffff",
text: BRAND_COLORS.anthracite,
textMuted: "#6b6b70",
accent: BRAND_COLORS.violet,
accentStrong: BRAND_COLORS.magenta,
rule: "#d9d7e4",
tableHeaderBackground: "#f4f2fa",
pageBackground: BRAND_COLORS.white,
text: BRAND_COLORS.graphit,
textMuted: BRAND_COLORS.stahlgrau,
accent: BRAND_COLORS.lotsenblau,
accentStrong: BRAND_COLORS.signalorange,
rule: BRAND_COLORS.zink,
tableHeaderBackground: BRAND_COLORS.hafengrau,
headingFont: BRAND_FONTS.heading,
bodyFont: BRAND_FONTS.body,
} as const;
@@ -30,7 +25,7 @@ export const DOCUMENT_THEME = {
export type DocumentHeader = {
/** Produktmarke — steht im Kopf jeder Ausgabe */
brand: string;
/** Dokumenttitel, z. B. „Richtlinie Zugriffskontrolle" */
/** Dokumenttitel, z. B. „Einsatzbericht A-2026-0012" */
title: string;
/** Organisation des Mandanten */
organisation?: string;
@@ -41,10 +36,8 @@ export type DocumentHeader = {
};
export type DocumentFooter = {
/** „Certvia — ein Produkt von GEFIM" */
/** „Erstellt mit Craftvia" */
byline: string;
/** Logo-Pfad der Dachmarke für Ausgaben, die Bilder einbetten können */
parentLogo: string;
/** Optionaler Vertraulichkeitsvermerk */
confidentiality?: string;
};
@@ -54,18 +47,14 @@ export function documentHeader(input: Omit<DocumentHeader, "brand">): DocumentHe
return { brand: BRAND.name, ...input };
}
/** Fußzeilen-Daten — der Dachmarken-Hinweis ist nicht abschaltbar. */
/** Fußzeilen-Daten einer Dokumentausgabe. */
export function documentFooter(confidentiality?: string): DocumentFooter {
return {
byline: BRAND.documentByline,
parentLogo: "/assets/logo/gefim-logo.png",
confidentiality,
};
return { byline: BRAND.documentByline, confidentiality };
}
/**
* Einzeiler für Ausgabeformate, die nur eine Textfußzeile kennen
* (CSV-Kopfkommentar, Plaintext-Mail, DOCX-Footer ohne Bild).
* (CSV-Kopfkommentar, Plaintext, PDF-Footer ohne Bild).
*/
export function documentFooterLine(confidentiality?: string): string {
const f = documentFooter(confidentiality);
+10 -9
View File
@@ -5,7 +5,7 @@ import { DOCUMENT_THEME } from "@/lib/document-brand";
* Brandfähige E-Mail-Template-Basis (Story S6).
*
* SMTP und der Einladungs-/Reset-Flow kommen erst mit Paket 4 — dieses Modul
* liefert vorab das Certvia-Gerüst, damit die Templates dann nur noch Inhalt
* liefert das Craftvia-Gerüst, damit die Templates nur noch Inhalt
* einsetzen müssen und das CD nicht neu erfunden wird.
*
* Bewusste Einschränkungen für E-Mail-Clients:
@@ -39,7 +39,7 @@ export type EmailContent = {
* E-Mail-Clients nicht.
*/
function baseUrl(): string {
return (process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "https://app.certvia.de").replace(
return (process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000").replace(
/\/+$/,
""
);
@@ -58,13 +58,13 @@ export function emailSender(address: string): string {
return `${BRAND.name} <${address}>`;
}
/** Signatur-/Fußzeilentext — enthält immer den Dachmarken-Hinweis. */
/** Signatur-/Fußzeilentext. */
export function emailSignature(): string {
return `${BRAND.name} — ${BRAND.byline}`;
return `${BRAND.name} — ${BRAND.tagline}`;
}
/**
* Rendert eine Mail im Certvia-CD (heller Grund, Marken-Violett, Poppins-Kopf).
* Rendert eine Mail im Craftvia-CD (Hafengrau-Grund, Lotsenblau, Signalorange-CTA).
* Rückgabe ist ein vollständiges HTML-Dokument.
*/
export function renderHtmlEmail(content: EmailContent): string {
@@ -80,7 +80,7 @@ export function renderHtmlEmail(content: EmailContent): string {
const action = content.action
? `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr><td style="background:${BRAND_COLORS.violet};border-radius:8px;">
<tr><td style="background:${BRAND_COLORS.signalorange};border-radius:8px;">
<a href="${escapeHtml(content.action.url)}" style="display:inline-block;padding:12px 22px;font-family:${t.headingFont};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">${escapeHtml(content.action.label)}</a>
</td></tr>
</table>`
@@ -93,12 +93,13 @@ export function renderHtmlEmail(content: EmailContent): string {
return `<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(content.subject)}</title></head>
<body style="margin:0;padding:0;background:#f4f2fa;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f2fa;padding:28px 12px;">
<body style="margin:0;padding:0;background:${BRAND_COLORS.hafengrau};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${BRAND_COLORS.hafengrau};padding:28px 12px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:${t.pageBackground};border-radius:14px;overflow:hidden;border:1px solid ${t.rule};">
<tr><td style="padding:26px 30px 0;">
<img src="${url}/assets/logo/certvia-lockup-positiv.svg" alt="${BRAND.name}" height="34" style="height:34px;width:auto;display:block;border:0;">
<img src="${url}/favicon/pwa-192.png" alt="${BRAND.name}" height="34" width="34" style="height:34px;width:34px;display:inline-block;vertical-align:middle;border:0;border-radius:8px;">
<span style="display:inline-block;vertical-align:middle;margin-left:10px;font-family:${t.headingFont};font-size:22px;font-weight:800;color:${BRAND_COLORS.lotsenblau};">Craft<span style="color:${BRAND_COLORS.signalorange};">via</span></span>
</td></tr>
<tr><td style="padding:22px 30px 4px;">
<h1 style="margin:0 0 14px;font-family:${t.headingFont};font-size:20px;font-weight:600;color:${t.text};">${escapeHtml(content.heading)}</h1>
+1 -1
View File
@@ -90,7 +90,7 @@ export function resetMailConfigCache(): void {
cached = null;
}
/** Absenderzeile `Certvia <no-reply@certvia.de>`. */
/** Absenderzeile `Craftvia <no-reply@craftvia.de>`. */
export function mailFrom(config: MailConfig): string {
return `${config.fromName} <${config.from}>`;
}
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from "@simplewebauthn/types";
import { BRAND } from "@/lib/brand";
/**
* SEC3-b: WebAuthn/Passkey-Basis. Kapselt die RP-/Origin-Konfiguration (aus AUTH_URL bzw.
@@ -17,7 +18,7 @@ import type {
* (Cookie) und Persistenz.
*/
export const RP_NAME = "Certvia";
export const RP_NAME = BRAND.name;
/** Cookie-Name der kurzlebigen Login-Challenge (pre-session Passkey-Login). */
export const LOGIN_CHALLENGE_COOKIE = "wa_login_challenge";