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;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Diagram from "../../components/Diagram.astro";
|
||||||
import { getCollection, render } from "astro:content";
|
import { getCollection, render } from "astro:content";
|
||||||
import type { GetStaticPaths } from "astro";
|
import type { GetStaticPaths } from "astro";
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ const { Content } = await render(entry);
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{p.diagram && <Diagram name={p.diagram} />}
|
||||||
|
|
||||||
<div class="prose case__body" data-reveal>
|
<div class="prose case__body" data-reveal>
|
||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user