M3: theme-aware SVG architecture diagrams + hero motion
Hand-authored inline-SVG Diagram component (no runtime JS, CSP-clean, themeable) rendering edge-AI, IaC-fleet and homelab architectures on the case studies. Staggered CSS hero entrance, motion-aware.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
---
|
||||
// Hand-authored, theme-aware architecture diagrams rendered as inline SVG at build.
|
||||
// No client JS, no headless browser, no inline <style> — CSP-clean and themeable
|
||||
// via CSS custom properties (colours follow the site theme).
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
caption?: string;
|
||||
}
|
||||
const { name, caption } = Astro.props;
|
||||
|
||||
type Node = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h?: number;
|
||||
label: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
dashed?: boolean;
|
||||
};
|
||||
type Edge = {
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
d?: string; // explicit path (elbows)
|
||||
dashed?: boolean;
|
||||
label?: string;
|
||||
lx?: number;
|
||||
ly?: number;
|
||||
};
|
||||
type Spec = { vb: string; nodes: Node[]; edges: Edge[] };
|
||||
|
||||
const H = 64;
|
||||
|
||||
const diagrams: Record<string, Spec> = {
|
||||
"edge-ai": {
|
||||
vb: "0 0 860 175",
|
||||
nodes: [
|
||||
{ x: 12, y: 46, w: 150, label: "High-level design", sub: "the idea" },
|
||||
{ x: 212, y: 46, w: 150, label: "CD pipeline", sub: "single press" },
|
||||
{ x: 412, y: 46, w: 165, label: "Helm + manifests", sub: "end-state" },
|
||||
{ x: 655, y: 46, w: 180, label: "Edge K8s", sub: "GPU inference", accent: true },
|
||||
],
|
||||
edges: [
|
||||
{ x1: 162, y1: 78, x2: 212, y2: 78 },
|
||||
{ x1: 362, y1: 78, x2: 412, y2: 78 },
|
||||
{ x1: 577, y1: 78, x2: 655, y2: 78, dashed: true, label: "readiness-gated", lx: 616, ly: 30 },
|
||||
],
|
||||
},
|
||||
"iac-fleet": {
|
||||
vb: "0 0 620 232",
|
||||
nodes: [
|
||||
{ x: 12, y: 40, w: 150, label: "Git source of truth", sub: "vars + code" },
|
||||
{ x: 212, y: 40, w: 160, label: "AWX / Ansible", sub: "single-touch" },
|
||||
{ x: 432, y: 40, w: 170, label: "Edge fleet", sub: "templated per-store", accent: true },
|
||||
{ x: 212, y: 150, w: 160, h: 58, label: "ACR / NVCR mirror", sub: "air-gapped images", dashed: true },
|
||||
],
|
||||
edges: [
|
||||
{ x1: 162, y1: 72, x2: 212, y2: 72 },
|
||||
{ x1: 372, y1: 72, x2: 432, y2: 72 },
|
||||
{ x1: 292, y1: 150, x2: 292, y2: 106 },
|
||||
],
|
||||
},
|
||||
homelab: {
|
||||
vb: "0 0 700 280",
|
||||
nodes: [
|
||||
{ x: 12, y: 34, w: 158, h: 62, label: "Proxmox", sub: "PCIe passthrough" },
|
||||
{ x: 212, y: 34, w: 178, h: 62, label: "Talos / OpenShift", sub: "single-node clusters" },
|
||||
{ x: 432, y: 34, w: 160, h: 62, label: "ArgoCD", sub: "GitOps reconcile", accent: true },
|
||||
{ x: 12, y: 182, w: 210, h: 62, label: "Local AI · DNS · Obs", sub: "LLM · Pi-hole · Prometheus" },
|
||||
{ x: 282, y: 182, w: 172, h: 62, label: "Cloudflare Tunnel", sub: "outbound only", accent: true },
|
||||
{ x: 512, y: 182, w: 150, h: 62, label: "Internet", sub: "www.bztmon.com" },
|
||||
],
|
||||
edges: [
|
||||
{ x1: 170, y1: 65, x2: 212, y2: 65 },
|
||||
{ x1: 390, y1: 65, x2: 432, y2: 65 },
|
||||
{ d: "M512 96 L512 139 L117 139 L117 182", dashed: true, label: "deploys", lx: 300, ly: 132 },
|
||||
{ x1: 222, y1: 213, x2: 282, y2: 213 },
|
||||
{ x1: 454, y1: 213, x2: 512, y2: 213 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const captions: Record<string, string> = {
|
||||
"edge-ai": "Design → single-press pipeline → readiness-gated GPU inference at the edge",
|
||||
"iac-fleet": "One source of truth → AWX/Ansible → identical edge nodes, even air-gapped",
|
||||
homelab: "Bare metal → GitOps clusters → services, exposed outbound-only via a tunnel",
|
||||
};
|
||||
const spec = diagrams[name];
|
||||
const cap = caption ?? captions[name];
|
||||
---
|
||||
|
||||
{
|
||||
spec && (
|
||||
<figure class="diagram" data-reveal>
|
||||
<svg viewBox={spec.vb} role="img" aria-label={cap ?? `${name} architecture diagram`} class="diagram__svg">
|
||||
<defs>
|
||||
<marker id="dg-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g class="dg-edges">
|
||||
{spec.edges.map((e) => (
|
||||
<>
|
||||
{e.d ? (
|
||||
<path d={e.d} class:list={["dg-edge", e.dashed && "dg-edge--dashed"]} marker-end="url(#dg-arrow)" />
|
||||
) : (
|
||||
<line
|
||||
x1={e.x1}
|
||||
y1={e.y1}
|
||||
x2={e.x2}
|
||||
y2={e.y2}
|
||||
class:list={["dg-edge", e.dashed && "dg-edge--dashed"]}
|
||||
marker-end="url(#dg-arrow)"
|
||||
/>
|
||||
)}
|
||||
{e.label && (
|
||||
<text x={e.lx} y={e.ly} class="dg-edge-label" text-anchor="middle">
|
||||
{e.label}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</g>
|
||||
|
||||
<g class="dg-nodes">
|
||||
{spec.nodes.map((n) => {
|
||||
const h = n.h ?? H;
|
||||
const cx = n.x + n.w / 2;
|
||||
return (
|
||||
<g class:list={["dg-node", n.accent && "dg-node--accent"]}>
|
||||
<rect
|
||||
x={n.x}
|
||||
y={n.y}
|
||||
width={n.w}
|
||||
height={h}
|
||||
rx="10"
|
||||
class:list={["dg-box", n.dashed && "dg-box--dashed"]}
|
||||
/>
|
||||
<text x={cx} y={n.sub ? n.y + h / 2 - 4 : n.y + h / 2 + 5} class="dg-label" text-anchor="middle">
|
||||
{n.label}
|
||||
</text>
|
||||
{n.sub && (
|
||||
<text x={cx} y={n.y + h / 2 + 16} class="dg-sub" text-anchor="middle">
|
||||
{n.sub}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
{cap && <figcaption class="diagram__caption mono">{cap}</figcaption>}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.diagram {
|
||||
margin: var(--space-6) 0;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.diagram__svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.diagram__caption {
|
||||
margin-top: var(--space-4);
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Edges */
|
||||
.dg-edges {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.dg-edge {
|
||||
stroke: var(--text-faint);
|
||||
stroke-width: 1.6;
|
||||
fill: none;
|
||||
}
|
||||
.dg-edge--dashed {
|
||||
stroke: var(--accent);
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.dg-edge-label {
|
||||
fill: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Nodes */
|
||||
.dg-box {
|
||||
fill: var(--surface);
|
||||
stroke: var(--border-strong);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.dg-box--dashed {
|
||||
stroke-dasharray: 5 4;
|
||||
fill: transparent;
|
||||
}
|
||||
.dg-node--accent .dg-box {
|
||||
stroke: var(--accent);
|
||||
fill: var(--accent-dim);
|
||||
}
|
||||
.dg-label {
|
||||
fill: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dg-sub {
|
||||
fill: var(--text-dim);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -90,4 +90,37 @@ import { cvAvailable } from "../lib/assets";
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Staggered entrance — CSS-only (runs with JS off), motion-aware. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hero__eyebrow,
|
||||
.hero__title,
|
||||
.hero__positioning,
|
||||
.hero__tags,
|
||||
.hero__cta {
|
||||
animation: heroIn 0.7s var(--ease) both;
|
||||
}
|
||||
.hero__title {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
.hero__positioning {
|
||||
animation-delay: 0.14s;
|
||||
}
|
||||
.hero__tags {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.hero__cta {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
}
|
||||
@keyframes heroIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import Diagram from "../../components/Diagram.astro";
|
||||
import { getCollection, render } from "astro:content";
|
||||
import type { GetStaticPaths } from "astro";
|
||||
|
||||
@@ -40,6 +41,8 @@ const { Content } = await render(entry);
|
||||
)}
|
||||
</header>
|
||||
|
||||
{p.diagram && <Diagram name={p.diagram} />}
|
||||
|
||||
<div class="prose case__body" data-reveal>
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user