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:
2026-06-17 17:02:22 +10:00
parent 22f482d89a
commit cb76a87c36
3 changed files with 261 additions and 0 deletions
+225
View File
@@ -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>
+33
View File
@@ -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>
+3
View File
@@ -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>