redesign ArchitectureSlide with metro/subway map theme
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 32s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 32s
- Replace rectangular cards with circular station nodes on metro tracks - Right-angle SVG paths only (no distortion with preserveAspectRatio=none) - Animated data packet flows along tracks, bidirectional MCP arc - LiteLLM hub enlarged (82px), centered mandants strip - Vertical tier labels, BSI badges, junction corner dots - Preserve all node data, detail panel, i18n, TokenTicker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,7 @@ import {
|
||||
Server, Network, ChevronRight, BadgeCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface ArchitectureSlideProps {
|
||||
lang: Language
|
||||
}
|
||||
interface ArchitectureSlideProps { lang: Language }
|
||||
|
||||
type NodeId = 'certifai' | 'complai' | 'scanner' | 'litellm' | 'llm' | 'embeddings' | 'tools'
|
||||
type ConnType = 'api' | 'mcp' | 'embed' | 'tool'
|
||||
@@ -29,8 +27,8 @@ interface NodeDef {
|
||||
twBorder: string
|
||||
twBg: string
|
||||
twDot: string
|
||||
cx: number
|
||||
cy: number
|
||||
cx: number // % of container width (aligns with SVG x/1100)
|
||||
cy: number // % of container height (aligns with SVG y/420)
|
||||
tier: 'product' | 'proxy' | 'inference'
|
||||
tech: string[]
|
||||
services: { name: string; desc: string }[]
|
||||
@@ -41,30 +39,39 @@ interface ConnDef {
|
||||
from: NodeId
|
||||
to: NodeId
|
||||
type: ConnType
|
||||
d: string
|
||||
revD?: string // reverse path for bidirectional MCP
|
||||
d: string // SVG path in viewBox "0 0 1100 420" — right-angle only
|
||||
revD?: string
|
||||
}
|
||||
|
||||
// All right-angle paths — safe with preserveAspectRatio="none" + vectorEffect="non-scaling-stroke"
|
||||
const CONNS: ConnDef[] = [
|
||||
{ from: 'certifai', to: 'litellm', type: 'api', d: 'M 18 22 Q 28 43 50 52' },
|
||||
{ from: 'complai', to: 'litellm', type: 'api', d: 'M 50 22 L 50 52' },
|
||||
{ from: 'scanner', to: 'litellm', type: 'api', d: 'M 82 22 Q 72 43 50 52' },
|
||||
{ from: 'complai', to: 'scanner', type: 'mcp', d: 'M 50 22 Q 66 9 82 22', revD: 'M 82 22 Q 66 9 50 22' },
|
||||
{ from: 'litellm', to: 'llm', type: 'api', d: 'M 50 52 Q 37 67 18 82' },
|
||||
{ from: 'litellm', to: 'embeddings', type: 'embed', d: 'M 50 52 L 50 82' },
|
||||
{ from: 'litellm', to: 'tools', type: 'tool', d: 'M 50 52 Q 63 67 82 82' },
|
||||
{ from: 'certifai', to: 'litellm', type: 'api',
|
||||
d: 'M 195 105 L 195 158 L 522 158 L 522 210' },
|
||||
{ from: 'complai', to: 'litellm', type: 'api',
|
||||
d: 'M 550 105 L 550 210' },
|
||||
{ from: 'scanner', to: 'litellm', type: 'api',
|
||||
d: 'M 905 105 L 905 158 L 578 158 L 578 210' },
|
||||
{ from: 'complai', to: 'scanner', type: 'mcp',
|
||||
d: 'M 550 105 Q 727 52 905 105',
|
||||
revD: 'M 905 105 Q 727 52 550 105' },
|
||||
{ from: 'litellm', to: 'llm', type: 'api',
|
||||
d: 'M 550 210 L 550 268 L 218 268 L 218 340' },
|
||||
{ from: 'litellm', to: 'embeddings', type: 'embed',
|
||||
d: 'M 550 210 L 550 340' },
|
||||
{ from: 'litellm', to: 'tools', type: 'tool',
|
||||
d: 'M 550 210 L 550 268 L 882 268 L 882 340' },
|
||||
]
|
||||
|
||||
// cx = svgX/1100*100, cy = svgY/420*100 (exact match between SVG coords and CSS %)
|
||||
function getNodes(de: boolean): NodeDef[] {
|
||||
return [
|
||||
{
|
||||
id: 'certifai',
|
||||
icon: Brain,
|
||||
id: 'certifai', icon: Brain,
|
||||
title: 'CERTifAI',
|
||||
subtitle: de ? 'GenAI Mandantenportal' : 'GenAI Tenant Portal',
|
||||
color: '#c084fc', twColor: 'text-purple-400',
|
||||
twBorder: 'border-purple-500/50', twBg: 'bg-purple-500/10', twDot: 'bg-purple-400',
|
||||
cx: 18, cy: 22, tier: 'product',
|
||||
cx: 17.7, cy: 25, tier: 'product',
|
||||
badge: 'Rust · Dioxus',
|
||||
tech: ['Rust', 'Dioxus', 'MongoDB', 'Keycloak', 'SearXNG', 'LangGraph'],
|
||||
services: [
|
||||
@@ -75,13 +82,12 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'complai',
|
||||
icon: Shield,
|
||||
id: 'complai', icon: Shield,
|
||||
title: 'COMPLAI',
|
||||
subtitle: de ? 'Compliance & Audit' : 'Compliance & Audit',
|
||||
color: '#818cf8', twColor: 'text-indigo-400',
|
||||
twBorder: 'border-indigo-500/50', twBg: 'bg-indigo-500/10', twDot: 'bg-indigo-400',
|
||||
cx: 50, cy: 22, tier: 'product',
|
||||
cx: 50, cy: 25, tier: 'product',
|
||||
badge: 'Next.js · FastAPI',
|
||||
tech: ['Next.js 15', 'FastAPI', 'Go/Gin', 'PostgreSQL', 'Qdrant', 'Valkey'],
|
||||
services: [
|
||||
@@ -92,13 +98,12 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'scanner',
|
||||
icon: ScanLine,
|
||||
id: 'scanner', icon: ScanLine,
|
||||
title: 'Compliance Scanner',
|
||||
subtitle: de ? 'Code-Sicherheit' : 'Code Security',
|
||||
color: '#34d399', twColor: 'text-emerald-400',
|
||||
twBorder: 'border-emerald-500/50', twBg: 'bg-emerald-500/10', twDot: 'bg-emerald-400',
|
||||
cx: 82, cy: 22, tier: 'product',
|
||||
cx: 82.3, cy: 25, tier: 'product',
|
||||
badge: 'Rust · Axum',
|
||||
tech: ['Rust', 'Axum', 'MongoDB', 'Semgrep', 'Gitleaks', 'Syft'],
|
||||
services: [
|
||||
@@ -109,13 +114,12 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'litellm',
|
||||
icon: Zap,
|
||||
id: 'litellm', icon: Zap,
|
||||
title: 'LiteLLM Proxy',
|
||||
subtitle: de ? 'KI-Gateway & Guardrails' : 'AI Gateway & Guardrails',
|
||||
color: '#fbbf24', twColor: 'text-amber-400',
|
||||
twBorder: 'border-amber-500/60', twBg: 'bg-amber-500/10', twDot: 'bg-amber-400',
|
||||
cx: 50, cy: 52, tier: 'proxy',
|
||||
cx: 50, cy: 50, tier: 'proxy',
|
||||
badge: 'Hub',
|
||||
tech: ['OpenAI-kompatible API', 'Bearer Auth', 'Rate Limiting', 'PII-Filter', 'Spend Tracking'],
|
||||
services: [
|
||||
@@ -127,14 +131,13 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
icon: Cpu,
|
||||
id: 'llm', icon: Cpu,
|
||||
title: de ? 'LLM Inferenz' : 'LLM Inference',
|
||||
subtitle: de ? 'Lokale Sprachmodelle' : 'Local Language Models',
|
||||
color: '#60a5fa', twColor: 'text-blue-400',
|
||||
twBorder: 'border-blue-500/50', twBg: 'bg-blue-500/10', twDot: 'bg-blue-400',
|
||||
cx: 18, cy: 82, tier: 'inference',
|
||||
badge: de ? 'On-Premise · BSI' : 'On-Premise · BSI',
|
||||
cx: 17.7, cy: 81, tier: 'inference',
|
||||
badge: 'On-Premise · BSI',
|
||||
tech: ['Qwen3-32B', 'Qwen3-Coder-30B', 'DeepSeek-R1-8B', 'Ollama'],
|
||||
services: [
|
||||
{ name: de ? 'Vollständig lokal' : 'Fully local', desc: de ? 'Daten verlassen nie den Server' : 'Data never leaves the server' },
|
||||
@@ -143,13 +146,12 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'embeddings',
|
||||
icon: Layers,
|
||||
id: 'embeddings', icon: Layers,
|
||||
title: 'Embeddings',
|
||||
subtitle: de ? 'Semantische Suche' : 'Semantic Search',
|
||||
color: '#a78bfa', twColor: 'text-violet-400',
|
||||
twBorder: 'border-violet-500/50', twBg: 'bg-violet-500/10', twDot: 'bg-violet-400',
|
||||
cx: 50, cy: 82, tier: 'inference',
|
||||
cx: 50, cy: 81, tier: 'inference',
|
||||
badge: de ? 'EU-Souverän' : 'EU Sovereign',
|
||||
tech: ['bge-m3', 'Qdrant Vector DB', 'Sentence-Transformers'],
|
||||
services: [
|
||||
@@ -159,13 +161,12 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
icon: Wrench,
|
||||
id: 'tools', icon: Wrench,
|
||||
title: de ? 'KI-Tools' : 'AI Tools',
|
||||
subtitle: de ? 'Web-Suche & MCP' : 'Web Search & MCP',
|
||||
color: '#2dd4bf', twColor: 'text-teal-400',
|
||||
twBorder: 'border-teal-500/50', twBg: 'bg-teal-500/10', twDot: 'bg-teal-400',
|
||||
cx: 82, cy: 82, tier: 'inference',
|
||||
cx: 82.3, cy: 81, tier: 'inference',
|
||||
badge: de ? 'EU-Souverän' : 'EU Sovereign',
|
||||
tech: ['SearXNG', 'MCP Protocol', 'Semgrep API', 'Gitleaks API'],
|
||||
services: [
|
||||
@@ -177,7 +178,7 @@ function getNodes(de: boolean): NodeDef[] {
|
||||
]
|
||||
}
|
||||
|
||||
// ── Token counter (inference tier ambient animation) ─────────────────────────
|
||||
// ── Token counter ─────────────────────────────────────────────────────────────
|
||||
function TokenTicker({ color }: { color: string }) {
|
||||
const [n, setN] = useState(() => 12480 + Math.floor(Math.random() * 8000))
|
||||
useEffect(() => {
|
||||
@@ -186,12 +187,8 @@ function TokenTicker({ color }: { color: string }) {
|
||||
}, [])
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<motion.div
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ background: color }}
|
||||
animate={{ opacity: [1, 0.3, 1] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity }}
|
||||
/>
|
||||
<motion.div className="w-1 h-1 rounded-full" style={{ background: color }}
|
||||
animate={{ opacity: [1, 0.3, 1] }} transition={{ duration: 0.6, repeat: Infinity }} />
|
||||
<span className="text-[7px] font-mono tabular-nums" style={{ color, opacity: 0.65 }}>
|
||||
{n.toLocaleString('de-DE')} tok/s
|
||||
</span>
|
||||
@@ -199,158 +196,173 @@ function TokenTicker({ color }: { color: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Data-flow path (always-on packets, fast when active) ─────────────────────
|
||||
function DataFlow({
|
||||
d, revD, color, active, type,
|
||||
}: {
|
||||
// ── Data-flow packets along metro tracks ──────────────────────────────────────
|
||||
function DataFlow({ d, revD, color, active, type }: {
|
||||
d: string; revD?: string; color: string; active: boolean; type: ConnType
|
||||
}) {
|
||||
const isMcp = type === 'mcp'
|
||||
const speed = active ? 1.2 : 5
|
||||
const op = active ? 0.85 : 0.2
|
||||
const w = active ? 2.5 : 1.2
|
||||
const dash = active ? 3 : 2
|
||||
const isMcp = type === 'mcp'
|
||||
const dashLen = active ? 18 : 8
|
||||
const gapLen = active ? 110 : 280
|
||||
const speed = active ? 1.9 : 5.5
|
||||
const cycle = dashLen + gapLen
|
||||
const op = active ? 0.9 : 0.16
|
||||
const pw = active ? 3.5 : 1.5
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Base line */}
|
||||
<path
|
||||
d={d} fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={active ? 1 : 0.5}
|
||||
strokeOpacity={active ? 0.4 : 0.07}
|
||||
strokeDasharray={isMcp ? '3 6' : undefined}
|
||||
{/* Track rail */}
|
||||
<path d={d} fill="none" stroke={color}
|
||||
strokeWidth={active ? 1.8 : 0.9}
|
||||
strokeOpacity={active ? 0.38 : 0.08}
|
||||
strokeDasharray={isMcp ? '6 12' : undefined}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Forward packets */}
|
||||
{[0, -28, -56].map((off, i) => (
|
||||
<motion.path key={`f${i}`}
|
||||
d={d} fill="none"
|
||||
stroke={color} strokeWidth={w} strokeLinecap="round"
|
||||
strokeDasharray={`${dash} 80`}
|
||||
strokeOpacity={op - i * 0.07}
|
||||
{/* Primary packet */}
|
||||
<motion.path d={d} fill="none" stroke={color}
|
||||
strokeWidth={pw} strokeLinecap="round"
|
||||
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||
strokeOpacity={op}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ strokeDashoffset: 0 }}
|
||||
animate={{ strokeDashoffset: -cycle }}
|
||||
transition={{ duration: speed, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
{/* Staggered trailing packet when active */}
|
||||
{active && (
|
||||
<motion.path d={d} fill="none" stroke={color}
|
||||
strokeWidth={pw * 0.65} strokeLinecap="round"
|
||||
strokeDasharray={`${Math.round(dashLen * 0.7)} ${gapLen}`}
|
||||
strokeOpacity={op * 0.55}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ strokeDashoffset: off }}
|
||||
animate={{ strokeDashoffset: off - 83 }}
|
||||
initial={{ strokeDashoffset: Math.round(cycle * 0.55) }}
|
||||
animate={{ strokeDashoffset: Math.round(cycle * 0.55) - cycle }}
|
||||
transition={{ duration: speed, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Reverse packets for MCP (bidirectional) */}
|
||||
{isMcp && revD && [0, -42].map((off, i) => (
|
||||
<motion.path key={`r${i}`}
|
||||
d={revD} fill="none"
|
||||
stroke={color} strokeWidth={w * 0.75} strokeLinecap="round"
|
||||
strokeDasharray={`${dash} 80`}
|
||||
strokeOpacity={(op - i * 0.1) * 0.75}
|
||||
)}
|
||||
{/* MCP reverse flow */}
|
||||
{isMcp && revD && (
|
||||
<motion.path d={revD} fill="none" stroke={color}
|
||||
strokeWidth={active ? 2.5 : 1.2} strokeLinecap="round"
|
||||
strokeDasharray={`${Math.round(dashLen * 0.7)} ${gapLen}`}
|
||||
strokeOpacity={op * 0.72}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ strokeDashoffset: off }}
|
||||
animate={{ strokeDashoffset: off - 83 }}
|
||||
transition={{ duration: speed * 1.4, repeat: Infinity, ease: 'linear' }}
|
||||
initial={{ strokeDashoffset: Math.round(cycle * 0.48) }}
|
||||
animate={{ strokeDashoffset: Math.round(cycle * 0.48) - cycle }}
|
||||
transition={{ duration: speed * 1.25, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Node card ──────────────────────────────────────────────────────────────────
|
||||
// Position wrapper (div) is never scaled — only the inner motion.button scales.
|
||||
// This prevents the "snap" that happened when translate(-50%,-50%) and scale
|
||||
// were on the same element.
|
||||
function NodeCard({
|
||||
node, active, onClick, de,
|
||||
}: {
|
||||
// ── Metro station card ────────────────────────────────────────────────────────
|
||||
// Outer div handles absolute positioning — never scaled.
|
||||
// Inner motion.button handles hover scale only.
|
||||
function MetroStation({ node, active, onClick, de }: {
|
||||
node: NodeDef; active: boolean; onClick: () => void; de: boolean
|
||||
}) {
|
||||
const Icon = node.icon
|
||||
const isHub = node.id === 'litellm'
|
||||
const Icon = node.icon
|
||||
const isHub = node.id === 'litellm'
|
||||
const sz = isHub ? 82 : 52
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: `${node.cx}%`, top: `${node.cy}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
<div className="absolute" style={{
|
||||
left: `${node.cx}%`, top: `${node.cy}%`, transform: 'translate(-50%, -50%)',
|
||||
}}>
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileHover={{ scale: 1.06 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
className="focus:outline-none block"
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 22 }}
|
||||
className="focus:outline-none flex flex-col items-center"
|
||||
style={{ gap: 6 }}
|
||||
>
|
||||
<div
|
||||
className={`relative rounded-xl border text-left transition-colors duration-200 overflow-hidden ${
|
||||
isHub ? 'w-[148px] px-3 py-3' : 'w-[128px] px-2.5 py-2.5'
|
||||
} ${
|
||||
active
|
||||
? `${node.twBorder} ${node.twBg}`
|
||||
: 'border-white/[0.09] bg-white/[0.04] hover:border-white/20'
|
||||
}`}
|
||||
style={{ boxShadow: active ? `0 0 22px -6px ${node.color}55` : 'none' }}
|
||||
>
|
||||
{/* Left accent bar */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[2px] rounded-l-xl transition-all duration-300"
|
||||
style={{ background: active ? node.color : `${node.color}28` }}
|
||||
/>
|
||||
|
||||
{/* Icon + title */}
|
||||
<div className="flex items-center gap-2 pl-1 mb-1">
|
||||
<div
|
||||
className={`w-[22px] h-[22px] rounded-md flex items-center justify-center flex-shrink-0 border transition-colors duration-200 ${
|
||||
active ? `${node.twBorder} ${node.twBg}` : 'border-white/10 bg-white/[0.05]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3 h-3 ${active ? node.twColor : 'text-white/35'}`} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold leading-tight truncate ${active ? 'text-white' : 'text-white/55'}`}>
|
||||
{node.title}
|
||||
</span>
|
||||
{active && (
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse ${node.twDot}`} />
|
||||
)}
|
||||
{/* Station circle */}
|
||||
<div className="relative flex items-center justify-center" style={{ width: sz, height: sz }}>
|
||||
{/* Outer pulse ring on active */}
|
||||
{active && (
|
||||
<motion.div className="absolute rounded-full pointer-events-none"
|
||||
style={{ inset: -10, border: `1.5px solid ${node.color}`, borderRadius: '50%' }}
|
||||
animate={{ scale: [1, 1.55], opacity: [0.45, 0] }}
|
||||
transition={{ duration: 1.9, repeat: Infinity, ease: 'easeOut' }}
|
||||
/>
|
||||
)}
|
||||
{/* Hub ambient glow */}
|
||||
{isHub && (
|
||||
<div className="absolute rounded-full pointer-events-none"
|
||||
style={{ inset: -6, background: `radial-gradient(circle, ${node.color}22, transparent)`, borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
{/* Main circle */}
|
||||
<div className="rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
width: sz, height: sz,
|
||||
background: active
|
||||
? `radial-gradient(circle at 38% 32%, ${node.color}38, ${node.color}14)`
|
||||
: 'rgba(6, 12, 28, 0.92)',
|
||||
border: `${active ? 2 : 1.5}px solid ${active ? node.color : node.color + '52'}`,
|
||||
boxShadow: active
|
||||
? `0 0 26px -4px ${node.color}75, inset 0 0 18px ${node.color}18`
|
||||
: 'none',
|
||||
transition: 'all 0.22s ease',
|
||||
}}
|
||||
>
|
||||
<Icon style={{
|
||||
width: isHub ? 26 : 20,
|
||||
height: isHub ? 26 : 20,
|
||||
color: active ? node.color : `${node.color}78`,
|
||||
transition: 'color 0.2s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className={`text-[8px] leading-snug pl-1 mb-1 ${active ? node.twColor : 'text-white/28'}`}
|
||||
style={{ opacity: active ? 0.8 : 1 }}>
|
||||
{node.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Badge */}
|
||||
{node.badge && (
|
||||
<div className="pl-1">
|
||||
<span className={`text-[7px] px-1.5 py-[1px] rounded border transition-colors duration-200 ${
|
||||
active ? `${node.twBorder} ${node.twColor}` : 'border-white/[0.08] text-white/22'
|
||||
}`}>
|
||||
{node.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token ticker on inference nodes */}
|
||||
{active && node.tier === 'inference' && (
|
||||
<div className="pl-1 mt-1.5">
|
||||
<TokenTicker color={node.color} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hub: spinning indicator ring */}
|
||||
{/* Hub spinning dashed ring */}
|
||||
{isHub && active && (
|
||||
<motion.div
|
||||
className="absolute rounded-full pointer-events-none"
|
||||
style={{ inset: -5, border: `1px dashed ${node.color}45`, borderRadius: '12px' }}
|
||||
<motion.div className="absolute pointer-events-none"
|
||||
style={{ inset: -11, borderRadius: '50%', border: `1px dashed ${node.color}48` }}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 12, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
)}
|
||||
{/* Active indicator dot */}
|
||||
{active && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full animate-pulse"
|
||||
style={{ background: node.color, boxShadow: `0 0 7px ${node.color}` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Station label */}
|
||||
<div className="text-center pointer-events-none" style={{ maxWidth: isHub ? 136 : 114 }}>
|
||||
<div
|
||||
className={`font-semibold leading-tight ${isHub ? 'text-[11px]' : 'text-[9.5px]'}`}
|
||||
style={{ color: active ? node.color : 'rgba(255,255,255,0.72)', transition: 'color 0.2s ease' }}
|
||||
>
|
||||
{node.title}
|
||||
</div>
|
||||
<div className="text-[8px] leading-tight mt-0.5"
|
||||
style={{ color: active ? `${node.color}c0` : 'rgba(255,255,255,0.28)', transition: 'color 0.2s ease' }}>
|
||||
{node.subtitle}
|
||||
</div>
|
||||
{node.badge && (
|
||||
<div className="mt-1 inline-block text-[7px] px-1.5 py-[1px] rounded font-mono"
|
||||
style={{
|
||||
border: `1px solid ${active ? node.color + '65' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: active ? node.color : 'rgba(255,255,255,0.26)',
|
||||
background: active ? `${node.color}14` : 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
{node.badge}
|
||||
</div>
|
||||
)}
|
||||
{active && node.tier === 'inference' && (
|
||||
<div className="mt-1.5"><TokenTicker color={node.color} /></div>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main slide ─────────────────────────────────────────────────────────────────
|
||||
// ── Main slide ────────────────────────────────────────────────────────────────
|
||||
export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
@@ -359,19 +371,16 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
const [activeId, setActiveId] = useState<NodeId | null>(null)
|
||||
const active = nodes.find(n => n.id === activeId) ?? null
|
||||
|
||||
function toggle(id: NodeId) {
|
||||
setActiveId(prev => (prev === id ? null : id))
|
||||
}
|
||||
function toggle(id: NodeId) { setActiveId(prev => prev === id ? null : id) }
|
||||
|
||||
const tenants = de
|
||||
? ['Mandant A', 'Mandant B', 'Mandant C', 'Mandant N…']
|
||||
: ['Namespace A', 'Namespace B', 'Namespace C', 'Namespace N…']
|
||||
|
||||
const tiers = [
|
||||
{ y: '22%', label: de ? 'Anwendungsschicht' : 'Application Layer', clr: '#818cf8', badge: null },
|
||||
{ y: '52%', label: de ? 'GenAI-Infrastruktur' : 'GenAI Infrastructure', clr: '#fbbf24', badge: de ? 'BSI-Rechenzentrum' : 'BSI Data Center' },
|
||||
{ y: '82%', label: de ? 'Inferenzschicht' : 'Inference Layer', clr: '#60a5fa', badge: de ? 'BSI · EU-Souverän' : 'BSI · EU Sovereign' },
|
||||
]
|
||||
// Tier separator positions (% of 420px container height)
|
||||
// App: 0–38.1% (y=0–160), Gateway: 38.1–64.3% (y=160–270), Inference: 64.3–100%
|
||||
const SEP1 = '38.1%'
|
||||
const SEP2 = '64.3%'
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -384,13 +393,13 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
<GradientText>{i.annex.architecture.title}</GradientText>
|
||||
</h2>
|
||||
<p className="text-xs text-white/35">
|
||||
{de ? 'Klicke auf eine Komponente für Details' : 'Click any component to explore'}
|
||||
{de ? 'Klicke auf eine Station für Details' : 'Click any station to explore'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
|
||||
<FadeInView delay={0.15} className="space-y-3">
|
||||
{/* Customer namespace strip */}
|
||||
<div className="flex items-center gap-2 flex-wrap px-[4%]">
|
||||
<div className="flex items-center justify-center gap-2 flex-wrap px-[4%]">
|
||||
<Users className="w-3 h-3 text-white/25 flex-shrink-0" />
|
||||
<span className="text-[9px] font-mono text-white/25 uppercase tracking-widest mr-1">
|
||||
{de ? 'Kundenmandanten' : 'Customer Namespaces'}
|
||||
@@ -404,114 +413,136 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-white/10 to-transparent ml-1" />
|
||||
</div>
|
||||
|
||||
{/* ── MAP ──────────────────────────────────────────────────────────── */}
|
||||
<div className="relative w-full" style={{ height: '410px' }}>
|
||||
{/* ── METRO MAP ──────────────────────────────────────────────── */}
|
||||
<div className="relative w-full rounded-xl overflow-hidden" style={{ height: '420px' }}>
|
||||
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 rounded-xl overflow-hidden"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse 50% 30% at 50% 52%, rgba(251,191,36,0.06) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 60% 22% at 50% 22%, rgba(129,140,248,0.07) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 20% at 50% 82%, rgba(96,165,250,0.045) 0%, transparent 55%),
|
||||
linear-gradient(170deg, #030d1e 0%, #040f21 50%, #030c1b 100%)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-[0.04]"
|
||||
<div className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(175deg, #030c1e 0%, #040e24 55%, #030c1c 100%)' }}>
|
||||
<div className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle, #6b7280 1px, transparent 1px)',
|
||||
backgroundSize: '22px 22px',
|
||||
backgroundImage: 'radial-gradient(circle, rgba(100,110,140,0.18) 1px, transparent 1px)',
|
||||
backgroundSize: '28px 28px',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier band fills */}
|
||||
<div className="absolute inset-x-0 top-0 pointer-events-none"
|
||||
style={{ height: SEP1, background: 'rgba(129,140,248,0.028)' }} />
|
||||
<div className="absolute inset-x-0 pointer-events-none"
|
||||
style={{ top: SEP1, height: '26.2%', background: 'rgba(251,191,36,0.032)' }} />
|
||||
<div className="absolute inset-x-0 pointer-events-none"
|
||||
style={{ top: SEP2, bottom: 0, background: 'rgba(96,165,250,0.022)' }} />
|
||||
|
||||
{/* Tier separator lines */}
|
||||
{['37%', '66%'].map(y => (
|
||||
<div key={y} className="absolute left-0 right-0 h-px pointer-events-none"
|
||||
style={{ top: y, background: 'linear-gradient(to right, transparent 0%, rgba(255,255,255,0.055) 15%, rgba(255,255,255,0.055) 85%, transparent 100%)' }}
|
||||
{[SEP1, SEP2].map(y => (
|
||||
<div key={y} className="absolute inset-x-0 h-px pointer-events-none"
|
||||
style={{ top: y, background: 'linear-gradient(to right, transparent 2%, rgba(255,255,255,0.07) 15%, rgba(255,255,255,0.07) 85%, transparent 98%)' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Tier labels (left) + BSI badges (right) */}
|
||||
{tiers.map(({ y, label, clr, badge }) => (
|
||||
<div key={label} className="absolute inset-x-0 flex items-center justify-between px-3 pointer-events-none select-none"
|
||||
style={{ top: y, transform: 'translateY(-50%)' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-px" style={{ background: clr, opacity: 0.3 }} />
|
||||
<span className="text-[7.5px] font-mono tracking-[0.14em] uppercase" style={{ color: clr, opacity: 0.42 }}>
|
||||
{label}
|
||||
{/* Vertical tier labels — left strip */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-7 flex flex-col pointer-events-none select-none" style={{ zIndex: 2 }}>
|
||||
<div className="flex items-center justify-center" style={{ height: SEP1 }}>
|
||||
<span className="text-[6.5px] font-mono tracking-[0.22em] uppercase"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'rgba(129,140,248,0.38)' }}>
|
||||
{de ? 'ANWENDUNG' : 'APP LAYER'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center" style={{ height: '26.2%' }}>
|
||||
<span className="text-[6.5px] font-mono tracking-[0.22em] uppercase"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'rgba(251,191,36,0.38)' }}>
|
||||
GATEWAY
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<span className="text-[6.5px] font-mono tracking-[0.22em] uppercase"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'rgba(96,165,250,0.38)' }}>
|
||||
{de ? 'INFERENZ' : 'INFERENCE'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BSI badges — right strip */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-20 flex flex-col pointer-events-none select-none" style={{ zIndex: 2 }}>
|
||||
<div style={{ height: SEP1 }} />
|
||||
<div className="flex items-center justify-end pr-2" style={{ height: '26.2%' }}>
|
||||
<div className="flex items-center gap-1 opacity-45">
|
||||
<BadgeCheck className="w-2.5 h-2.5 text-amber-400" />
|
||||
<span className="text-[6.5px] font-mono tracking-wider text-amber-400">BSI DC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end pr-2 flex-1">
|
||||
<div className="flex items-center gap-1 opacity-45">
|
||||
<BadgeCheck className="w-2.5 h-2.5 text-blue-400" />
|
||||
<span className="text-[6.5px] font-mono tracking-wider text-blue-400">
|
||||
{de ? 'EU-Souverän' : 'EU Sovereign'}
|
||||
</span>
|
||||
</div>
|
||||
{badge && (
|
||||
<div className="flex items-center gap-1" style={{ opacity: 0.45 }}>
|
||||
<BadgeCheck className="w-2.5 h-2.5" style={{ color: clr }} />
|
||||
<span className="text-[7px] font-mono tracking-wider" style={{ color: clr }}>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* MCP badge between COMPLAI and Scanner */}
|
||||
<div
|
||||
className="absolute text-[7px] px-2 py-[2px] rounded-full border border-emerald-500/35 text-emerald-400/65 font-mono tracking-wider select-none pointer-events-none"
|
||||
style={{ left: '66%', top: '9%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
MCP ↔
|
||||
</div>
|
||||
|
||||
{/* Infra tech chips (secondary, very subtle, in the infra tier band) */}
|
||||
<div className="absolute left-[5%] right-[5%] flex flex-wrap gap-1.5 pointer-events-none select-none"
|
||||
style={{ top: '51.5%', transform: 'translateY(-50%)', paddingRight: '160px' }}>
|
||||
{['PostgreSQL', 'MongoDB', 'Qdrant', 'Valkey', 'Nginx', 'Vault', 'Gitea', 'Orca', 'Woodpecker'].map(chip => (
|
||||
<span key={chip}
|
||||
className="text-[6.5px] px-1.5 py-[1px] rounded border border-white/[0.055] text-white/14 font-mono">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
{/* MCP label */}
|
||||
<div className="absolute pointer-events-none select-none"
|
||||
style={{ left: '66%', top: '8%', transform: 'translate(-50%, -50%)', zIndex: 3 }}>
|
||||
<span className="text-[7px] px-2 py-[2px] rounded-full border border-emerald-500/35 text-emerald-400/65 font-mono tracking-wider">
|
||||
⇌ MCP
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SVG: island territories + data-flow paths */}
|
||||
{/* ── SVG: metro tracks + animated data flows ── */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none">
|
||||
viewBox="0 0 1100 420" preserveAspectRatio="none">
|
||||
|
||||
{/* Tier zone fills */}
|
||||
<rect x="0" y="0" width="100" height="37" fill="#818cf8" fillOpacity="0.018" />
|
||||
<rect x="0" y="37" width="100" height="29" fill="#fbbf24" fillOpacity="0.022" />
|
||||
<rect x="0" y="66" width="100" height="34" fill="#60a5fa" fillOpacity="0.015" />
|
||||
{/* Horizontal metro line — Application layer (y=105) */}
|
||||
<line x1="165" y1="105" x2="935" y2="105"
|
||||
stroke="rgba(129,140,248,0.14)" strokeWidth="4"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
{/* Station tick marks on app line */}
|
||||
{[195, 550, 905].map(x => (
|
||||
<line key={x} x1={x} y1="97" x2={x} y2="113"
|
||||
stroke="rgba(129,140,248,0.45)" strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
|
||||
{/* Island blobs per node */}
|
||||
{nodes.map(n => {
|
||||
const isHub = n.id === 'litellm'
|
||||
const isActive = activeId === n.id
|
||||
return (
|
||||
<g key={`blob-${n.id}`}>
|
||||
<ellipse cx={n.cx} cy={n.cy}
|
||||
rx={isHub ? 14 : 8} ry={isHub ? 8.5 : 5}
|
||||
fill={n.color} fillOpacity={isActive ? 0.13 : 0.038}
|
||||
stroke={n.color} strokeWidth={0.5}
|
||||
strokeOpacity={isActive ? 0.35 : 0.09}
|
||||
strokeDasharray={isHub ? undefined : '1.5 3.5'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* Horizontal metro line — Inference layer (y=340) */}
|
||||
<line x1="165" y1="340" x2="935" y2="340"
|
||||
stroke="rgba(96,165,250,0.14)" strokeWidth="4"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
{/* Station tick marks on inference line */}
|
||||
{[195, 550, 905].map(x => (
|
||||
<line key={x} x1={x} y1="332" x2={x} y2="348"
|
||||
stroke="rgba(96,165,250,0.45)" strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
|
||||
{/* Gateway stub lines (short horizontal stubs from hub) */}
|
||||
<line x1="440" y1="210" x2="660" y2="210"
|
||||
stroke="rgba(251,191,36,0.10)" strokeWidth="3"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
|
||||
{/* Junction corner dots */}
|
||||
{[
|
||||
{ cx: 195, cy: 158 }, { cx: 522, cy: 158 },
|
||||
{ cx: 905, cy: 158 }, { cx: 578, cy: 158 },
|
||||
{ cx: 218, cy: 268 }, { cx: 550, cy: 268 }, { cx: 882, cy: 268 },
|
||||
].map(({ cx, cy }) => (
|
||||
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="3.5"
|
||||
fill="rgba(160,170,200,0.22)" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
|
||||
{/* Connection flows */}
|
||||
{CONNS.map(c => {
|
||||
const fromNode = nodes.find(n => n.id === c.from)!
|
||||
const toNode = nodes.find(n => n.id === c.to)!
|
||||
const isActive = activeId === c.from || activeId === c.to
|
||||
const color = isActive
|
||||
const fromNode = nodes.find(n => n.id === c.from)!
|
||||
const toNode = nodes.find(n => n.id === c.to)!
|
||||
const isActive = activeId === c.from || activeId === c.to
|
||||
const color = isActive
|
||||
? (activeId === c.from ? fromNode.color : toNode.color)
|
||||
: '#ffffff'
|
||||
return (
|
||||
<DataFlow
|
||||
key={`${c.from}-${c.to}`}
|
||||
<DataFlow key={`${c.from}-${c.to}`}
|
||||
d={c.d} revD={c.revD}
|
||||
color={color} active={isActive} type={c.type}
|
||||
/>
|
||||
@@ -519,11 +550,9 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Node cards */}
|
||||
{/* Metro station nodes */}
|
||||
{nodes.map(node => (
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
<MetroStation key={node.id} node={node}
|
||||
active={activeId === node.id}
|
||||
onClick={() => toggle(node.id)}
|
||||
de={de}
|
||||
@@ -531,14 +560,14 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
))}
|
||||
|
||||
{!activeId && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 text-[8px] text-white/18 pointer-events-none">
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 text-[8px] text-white/18 pointer-events-none select-none">
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{de ? 'Komponente anklicken' : 'Click any node'}
|
||||
{de ? 'Station anklicken' : 'Click any station'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── DETAIL PANEL ──────────────────────────────────────────────────── */}
|
||||
{/* ── DETAIL PANEL ─────────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
@@ -549,14 +578,11 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
className={`relative rounded-2xl border ${active.twBorder} ${active.twBg} p-4`}
|
||||
style={{ boxShadow: `0 0 32px -10px ${active.color}45` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveId(null)}
|
||||
className="absolute top-3 right-3 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<button onClick={() => setActiveId(null)}
|
||||
className="absolute top-3 right-3 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors">
|
||||
<X className="w-3 h-3 text-white/50" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 border ${active.twBorder} ${active.twBg}`}>
|
||||
<active.icon className={`w-5 h-5 ${active.twColor}`} />
|
||||
@@ -569,7 +595,7 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
{active.badge}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[8px] px-1.5 py-0.5 rounded border border-white/10 text-white/35`}>
|
||||
<span className="text-[8px] px-1.5 py-0.5 rounded border border-white/10 text-white/35">
|
||||
{active.tier === 'product' ? (de ? 'Anwendungsschicht' : 'Application Layer') :
|
||||
active.tier === 'proxy' ? (de ? 'GenAI-Infrastruktur' : 'GenAI Infra') :
|
||||
(de ? 'Inferenzschicht' : 'Inference Layer')}
|
||||
@@ -579,7 +605,6 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<p className="text-[9px] uppercase tracking-widest text-white/30 mb-2 font-semibold">
|
||||
@@ -611,7 +636,6 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connects-to */}
|
||||
<div className="mt-3 pt-3 border-t border-white/[0.06] flex items-center gap-2 flex-wrap">
|
||||
<Network className="w-3 h-3 text-white/20 flex-shrink-0" />
|
||||
<span className="text-[9px] text-white/30">
|
||||
@@ -624,11 +648,8 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
const peer = nodes.find(n => n.id === peerId)!
|
||||
const label = c.type === 'mcp' ? 'MCP' : c.type === 'embed' ? 'Embed' : c.type === 'tool' ? 'Tool' : 'API'
|
||||
return (
|
||||
<button
|
||||
key={peerId}
|
||||
onClick={() => setActiveId(peerId)}
|
||||
className={`text-[9px] px-2 py-0.5 rounded-full border ${peer.twBorder} ${peer.twColor} transition-opacity hover:opacity-80 flex items-center gap-1`}
|
||||
>
|
||||
<button key={peerId} onClick={() => setActiveId(peerId)}
|
||||
className={`text-[9px] px-2 py-0.5 rounded-full border ${peer.twBorder} ${peer.twColor} transition-opacity hover:opacity-80 flex items-center gap-1`}>
|
||||
{peer.title}
|
||||
<span className="opacity-50 text-[7px]">{label}</span>
|
||||
</button>
|
||||
@@ -639,18 +660,17 @@ export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Bottom legend */}
|
||||
{/* Legend */}
|
||||
{!active && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
className="flex items-center justify-center gap-6 flex-wrap">
|
||||
{[
|
||||
{ icon: Lock, text: de ? 'Kein US-Anbieter · 100% DSGVO' : 'No US providers · 100% GDPR' },
|
||||
{ icon: Server, text: de ? 'BSI-zertifiziertes Rechenzentrum' : 'BSI-certified data center' },
|
||||
{ icon: Lock, text: de ? 'Kein US-Anbieter · 100% DSGVO' : 'No US providers · 100% GDPR' },
|
||||
{ icon: Server, text: de ? 'BSI-zertifiziertes Rechenzentrum' : 'BSI-certified data center' },
|
||||
{ icon: BadgeCheck, text: de ? 'EU-souveräne Inferenz' : 'EU-sovereign inference' },
|
||||
].map(({ icon: Icon, text }) => (
|
||||
<span key={text} className="flex items-center gap-1.5 text-[10px] text-white/22">
|
||||
<Icon className="w-3 h-3" />
|
||||
{text}
|
||||
<Icon className="w-3 h-3" />{text}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user