Initial portfolio site: Astro + Tailwind MVP

Outcome-led hero, about, grouped skills, experience summary, featured
projects + /projects index, static contact, SEO/OG, dark/light theme.
Dockerfile + nginx config + build script for homelab deploy.
This commit is contained in:
2026-06-17 16:22:53 +10:00
commit 2d4b6ea097
38 changed files with 8232 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
---
import { site } from "../data/site";
import { experience } from "../data/experience";
import { cvAvailable } from "../lib/assets";
---
<div class="about">
<div class="about__bio" data-reveal>
<p class="lead">{site.bio}</p>
{cvAvailable && (
<p class="about__cv">
<a class="btn" href="/cv.pdf" download>Download full CV (PDF)</a>
</p>
)}
</div>
<ol class="about__timeline">
{
experience.map((role) => (
<li class="about__role" data-reveal>
<div class="about__role-head">
<h3 class="about__role-title">{role.title}</h3>
<span class="mono about__role-period">{role.period}</span>
</div>
<p class="mono about__role-org">{role.org}</p>
<p class="about__role-summary">{role.summary}</p>
{role.highlights.length > 0 && (
<ul class="about__highlights">
{role.highlights.map((h) => (
<li>{h}</li>
))}
</ul>
)}
</li>
))
}
</ol>
</div>
<style>
.about {
display: grid;
gap: var(--space-7);
}
@media (min-width: 880px) {
.about {
grid-template-columns: 1fr 1.1fr;
gap: var(--space-8);
align-items: start;
}
}
.about__cv {
margin-top: var(--space-5);
}
.about__timeline {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-5);
}
.about__role {
position: relative;
padding-left: var(--space-5);
border-left: 1px solid var(--border-strong);
}
.about__role::before {
content: "";
position: absolute;
left: -5px;
top: 0.4rem;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-dim);
}
.about__role-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.about__role-title {
font-size: var(--step-1);
}
.about__role-period {
font-size: 0.72rem;
color: var(--text-faint);
}
.about__role-org {
color: var(--accent);
font-size: var(--step--1);
margin-top: 0.15rem;
}
.about__role-summary {
color: var(--text-dim);
margin-top: var(--space-2);
font-size: var(--step-0);
}
.about__highlights {
margin: var(--space-3) 0 0;
padding-left: 1.1rem;
color: var(--text-dim);
font-size: var(--step--1);
display: grid;
gap: 0.3rem;
}
.about__highlights li::marker {
color: var(--accent);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
---
// Cheap, CSS-only topology motif: a faint grid + slow-drifting accent glows.
// No canvas, no WebGL, no JS. Fully disabled under prefers-reduced-motion.
---
<div class="bg" aria-hidden="true">
<div class="bg__grid"></div>
<div class="bg__glow bg__glow--a"></div>
<div class="bg__glow bg__glow--b"></div>
</div>
<style>
.bg {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
.bg__grid {
position: absolute;
inset: -2px;
background-image:
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
background-size: 46px 46px;
/* Fade the grid out toward the edges so it reads as a motif, not a table. */
mask-image: radial-gradient(120% 90% at 50% 0%, #000 35%, transparent 78%);
-webkit-mask-image: radial-gradient(120% 90% at 50% 0%, #000 35%, transparent 78%);
}
.bg__glow {
position: absolute;
width: 46rem;
height: 46rem;
border-radius: 50%;
filter: blur(60px);
opacity: 0.5;
}
.bg__glow--a {
top: -22rem;
left: -10rem;
background: radial-gradient(circle, var(--accent-glow), transparent 62%);
animation: drift-a 26s var(--ease) infinite alternate;
}
.bg__glow--b {
top: -16rem;
right: -14rem;
background: radial-gradient(circle, var(--accent-glow), transparent 65%);
opacity: 0.35;
animation: drift-b 32s var(--ease) infinite alternate;
}
@keyframes drift-a {
to {
transform: translate(6rem, 4rem) scale(1.12);
}
}
@keyframes drift-b {
to {
transform: translate(-5rem, 3rem) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.bg__glow {
animation: none;
}
}
</style>
+77
View File
@@ -0,0 +1,77 @@
---
import { site } from "../data/site";
import { socials } from "../data/socials";
---
<div class="contact card" data-reveal>
<div class="contact__body">
<p class="contact__lead">
Open to conversations about platform engineering, edge infrastructure, and
GPU/AI systems. The fastest way to reach me is email.
</p>
<a class="btn btn--primary contact__mail mono" href={`mailto:${site.email}`}>
{site.email}
</a>
</div>
<ul class="contact__links">
{
socials.map((s) => (
<li>
<a
class="contact__link"
href={s.href}
rel={s.external ? "noopener noreferrer" : undefined}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d={s.icon} fill="currentColor" />
</svg>
<span>{s.label}</span>
</a>
</li>
))
}
</ul>
</div>
<style>
.contact {
padding: var(--space-6);
display: grid;
gap: var(--space-6);
}
@media (min-width: 760px) {
.contact {
grid-template-columns: 1.4fr 1fr;
align-items: center;
}
}
.contact__lead {
color: var(--text-dim);
font-size: var(--step-1);
line-height: 1.55;
max-width: 38ch;
}
.contact__mail {
margin-top: var(--space-5);
}
.contact__links {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3);
}
.contact__link {
display: inline-flex;
align-items: center;
gap: 0.6rem;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--step--1);
}
.contact__link:hover {
color: var(--accent);
text-decoration: none;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
---
import { site } from "../data/site";
import { socials } from "../data/socials";
const year = 2026; // build-stamped; bump via the build, not a runtime Date()
---
<footer class="footer">
<div class="container footer__inner">
<div>
<p class="mono footer__name">{site.name}</p>
<p class="footer__meta">
{site.role} · Served from a homelab Kubernetes cluster over an encrypted tunnel.
</p>
</div>
<div class="footer__links">
{
socials.map((s) => (
<a
href={s.href}
rel={s.external ? "noopener noreferrer" : undefined}
aria-label={s.label}
title={s.label}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d={s.icon} fill="currentColor" />
</svg>
</a>
))
}
</div>
</div>
<div class="container footer__base">
<span class="mono">© {year} {site.name}</span>
<span class="mono footer__dot">·</span>
<span class="mono">built with Astro, shipped via GitOps</span>
</div>
</footer>
<style>
.footer {
border-top: 1px solid var(--border);
padding-block: var(--space-7) var(--space-6);
margin-top: var(--space-8);
color: var(--text-dim);
}
.footer__inner {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
justify-content: space-between;
align-items: center;
}
.footer__name {
color: var(--text);
font-weight: 600;
}
.footer__meta {
font-size: var(--step--1);
color: var(--text-faint);
margin-top: 0.25rem;
}
.footer__links {
display: flex;
gap: var(--space-4);
}
.footer__links a {
color: var(--text-dim);
}
.footer__links a:hover {
color: var(--accent);
}
.footer__base {
margin-top: var(--space-5);
font-size: 0.72rem;
color: var(--text-faint);
display: flex;
gap: 0.5rem;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
---
import BackgroundGrid from "./BackgroundGrid.astro";
import { site } from "../data/site";
import { cvAvailable } from "../lib/assets";
---
<section class="hero">
<BackgroundGrid />
<div class="container hero__inner">
<p class="eyebrow hero__eyebrow">{site.role}</p>
<h1 class="hero__title">
{site.name}
</h1>
<p class="hero__positioning">{site.positioning}</p>
<ul class="hero__tags" aria-label="Core technologies">
{site.tagline.map((t) => <li class="mono">{t}</li>)}
</ul>
<div class="hero__cta">
<a class="btn btn--primary" href="/#projects">View Projects</a>
{cvAvailable && (
<a class="btn" href="/cv.pdf" download>
Download CV
</a>
)}
<a class="btn" href="/#contact">Contact</a>
</div>
</div>
</section>
<style>
.hero {
position: relative;
min-height: min(88vh, 760px);
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
}
.hero__inner {
position: relative;
z-index: 1;
padding-block: var(--space-9);
}
.hero__eyebrow {
margin-bottom: var(--space-4);
}
.hero__title {
font-size: var(--step-5);
letter-spacing: -0.03em;
background: linear-gradient(180deg, var(--text), color-mix(in srgb, var(--text) 62%, var(--accent)));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero__positioning {
margin-top: var(--space-5);
max-width: 42rem;
font-size: var(--step-2);
line-height: 1.35;
color: var(--text);
font-weight: 500;
letter-spacing: -0.01em;
}
.hero__tags {
list-style: none;
padding: 0;
margin: var(--space-5) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--step--1);
color: var(--text-dim);
}
.hero__tags li {
position: relative;
padding-left: 1rem;
}
.hero__tags li::before {
content: "▹";
position: absolute;
left: 0;
color: var(--accent);
}
.hero__cta {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
+98
View File
@@ -0,0 +1,98 @@
---
import ThemeToggle from "./ThemeToggle.astro";
import { site } from "../data/site";
const links = [
{ label: "About", href: "/#about" },
{ label: "Skills", href: "/#skills" },
{ label: "Projects", href: "/#projects" },
{ label: "Contact", href: "/#contact" },
];
---
<header class="nav">
<div class="container nav__inner">
<a class="nav__brand mono" href="/" aria-label={`${site.name} — home`}>
<span class="nav__prompt">~/</span><span>{site.handle}</span><span class="nav__caret" aria-hidden="true">▮</span>
</a>
<nav class="nav__links" aria-label="Primary">
{links.map((l) => <a href={l.href}>{l.label}</a>)}
</nav>
<div class="nav__actions">
<ThemeToggle />
</div>
</div>
</header>
<style>
.nav {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
.nav__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
height: 4rem;
}
.nav__brand {
display: inline-flex;
align-items: center;
gap: 0.1rem;
font-size: var(--step-0);
font-weight: 600;
color: var(--text);
}
.nav__brand:hover {
text-decoration: none;
color: var(--accent);
}
.nav__prompt {
color: var(--text-faint);
}
.nav__caret {
color: var(--accent);
animation: blink 1.1s steps(1) infinite;
margin-left: 0.1rem;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.nav__caret {
animation: none;
}
}
.nav__links {
display: none;
gap: var(--space-5);
font-family: var(--font-mono);
font-size: var(--step--1);
}
.nav__links a {
color: var(--text-dim);
}
.nav__links a:hover {
color: var(--text);
text-decoration: none;
}
.nav__actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
@media (min-width: 720px) {
.nav__links {
display: flex;
}
}
</style>
+88
View File
@@ -0,0 +1,88 @@
---
import type { Project } from "../data/projects";
interface Props {
project: Project;
}
const { project } = Astro.props;
const href = project.hasCaseStudy ? `/projects/${project.slug}` : undefined;
const Tag = href ? "a" : "article";
---
<Tag class="project card" href={href} data-reveal>
<div class="project__top">
<h3 class="project__title">{project.title}</h3>
{href && <span class="project__arrow" aria-hidden="true">→</span>}
</div>
<p class="project__outcome">{project.outcome}</p>
<p class="project__summary">{project.summary}</p>
<ul class="project__stack">
{project.stack.map((s) => <li class="tag">{s}</li>)}
</ul>
<p class="project__meta mono">
<span>{project.role}</span><span class="project__sep">·</span><span>{project.period}</span>
</p>
</Tag>
<style>
.project {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
color: inherit;
height: 100%;
}
a.project:hover {
text-decoration: none;
transform: translateY(-3px);
}
.project__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.project__title {
font-size: var(--step-1);
}
.project__arrow {
color: var(--accent);
transition: transform 0.2s var(--ease);
}
a.project:hover .project__arrow {
transform: translateX(4px);
}
.project__outcome {
color: var(--text);
font-weight: 500;
border-left: 2px solid var(--accent);
padding-left: var(--space-3);
}
.project__summary {
color: var(--text-dim);
font-size: var(--step--1);
line-height: 1.6;
}
.project__stack {
list-style: none;
padding: 0;
margin: var(--space-2) 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.project__meta {
margin-top: auto;
padding-top: var(--space-3);
font-size: 0.72rem;
color: var(--text-faint);
display: flex;
gap: 0.5rem;
}
.project__sep {
opacity: 0.6;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
---
interface Props {
id: string;
eyebrow: string;
title: string;
/** Monospace index like "01" shown beside the eyebrow. */
index?: string;
}
const { id, eyebrow, title, index } = Astro.props;
---
<section id={id} class="section">
<div class="container">
<header class="section__head" data-reveal>
<p class="eyebrow">
{index && <span class="section__index">{index}</span>}{eyebrow}
</p>
<h2 class="section__title">{title}</h2>
</header>
<slot />
</div>
</section>
<style>
.section__head {
margin-bottom: var(--space-6);
}
.section__index {
color: var(--text-faint);
margin-right: 0.6rem;
}
.section__title {
margin-top: var(--space-3);
font-size: var(--step-3);
max-width: 22ch;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
---
import type { SkillGroup } from "../data/skills";
interface Props {
group: SkillGroup;
}
const { group } = Astro.props;
---
<article class="skill card" data-reveal>
<h3 class="skill__title">{group.title}</h3>
<p class="skill__blurb">{group.blurb}</p>
<ul class="skill__items">
{group.items.map((item) => <li class="tag">{item}</li>)}
</ul>
</article>
<style>
.skill {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.skill__title {
font-size: var(--step-1);
}
.skill__title::before {
content: "# ";
color: var(--accent);
font-family: var(--font-mono);
}
.skill__blurb {
color: var(--text-dim);
font-size: var(--step--1);
line-height: 1.5;
}
.skill__items {
list-style: none;
padding: 0;
margin: var(--space-2) 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
---
// Light/dark toggle. The pre-paint script in Layout sets the initial theme;
// this just flips + persists it.
---
<button
id="theme-toggle"
class="theme-toggle"
type="button"
aria-label="Toggle colour theme"
title="Toggle colour theme"
>
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true" width="18" height="18">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.6" />
<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" />
</g>
</svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true" width="18" height="18">
<path
d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
</svg>
</button>
<style>
.theme-toggle {
display: inline-grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
cursor: pointer;
transition: border-color 0.2s var(--ease), color 0.2s var(--ease);
}
.theme-toggle:hover {
border-color: var(--accent-line);
color: var(--text);
}
.icon {
grid-area: 1 / 1;
}
/* Show the icon for the theme you'd switch TO. */
:global([data-theme="dark"]) .icon-sun {
display: block;
}
:global([data-theme="dark"]) .icon-moon {
display: none;
}
:global([data-theme="light"]) .icon-sun {
display: none;
}
:global([data-theme="light"]) .icon-moon {
display: block;
}
</style>
<script>
const btn = document.getElementById("theme-toggle");
btn?.addEventListener("click", () => {
const root = document.documentElement;
const next = root.dataset.theme === "light" ? "dark" : "light";
root.dataset.theme = next;
try {
localStorage.setItem("theme", next);
} catch (e) {
/* ignore */
}
});
</script>
+33
View File
@@ -0,0 +1,33 @@
// Short experience summary for the About section (M1).
// A fuller, dated timeline lands in M2.
// TODO(Jonathon): fill employer names, exact titles, and dates.
export type Role = {
title: string;
org: string;
period: string;
summary: string;
highlights: string[];
};
export const experience: Role[] = [
{
title: "Platform / Infrastructure Engineer",
org: "TODO: Employer", // TODO(Jonathon)
period: "TODO: dates", // TODO(Jonathon)
summary:
"Operate and automate a fleet of GPU-backed edge Kubernetes clusters running computer-vision workloads, plus the IaC pipelines that deploy them.",
highlights: [
"Hardened GPU readiness and egress networking across the edge fleet",
"Built Ansible/AWX automation for repeatable cluster bring-up",
"Owned observability and network policy for the platform",
],
},
{
title: "TODO: Previous role",
org: "TODO: Employer", // TODO(Jonathon)
period: "TODO: dates", // TODO(Jonathon)
summary: "TODO(Jonathon): one or two lines on a previous role.",
highlights: [],
},
];
+82
View File
@@ -0,0 +1,82 @@
// Featured project cards for the homepage + the basic /projects index (M1).
// In M2 these become full case-study Content Collections at src/content/projects/.
// Keep `slug` stable so the M2 migration keeps the same URLs.
export type Project = {
slug: string;
title: string;
// One-line "so what" outcome — the most important field.
outcome: string;
summary: string;
role: string;
period: string;
stack: string[];
featured: boolean;
// Set true in M2 when /projects/[slug] case studies exist.
hasCaseStudy?: boolean;
};
export const projects: Project[] = [
{
slug: "edge-gpu-inference-platform",
title: "Edge Kubernetes / GPU Inference Platform",
outcome:
"Made GPU computer-vision reliable across a fleet of store-edge clusters — no more inference pods racing the GPU at boot.",
summary:
"A fleet of single-purpose edge Kubernetes clusters running GPU-accelerated computer-vision workloads. Hardened the GPU readiness path (init-gated so pods never schedule before the device plugin is up) and corrected the egress NAT path so inference results reached upstream services reliably.",
role: "Platform / Infrastructure Engineer",
period: "Recent", // TODO(Jonathon): dates
stack: ["OpenShift", "NVIDIA GPU Operator", "Multus", "SNAT / egress", "GPU device plugin"],
featured: true,
},
{
slug: "cv-compliance-verifier",
title: "Computer-Vision Compliance Verifier",
outcome:
"Turned a manual visual check into a containerized GPU workload emitting a structured pass/fail verdict.",
summary:
"A YOLO-based vision tool packaged as a containerized workload on GPU edge nodes. Runs an inference pipeline against a defined scene and emits a structured, machine-readable verdict — replacing a manual, subjective check with a repeatable one.",
role: "Platform / Infrastructure Engineer",
period: "Recent", // TODO(Jonathon): dates
stack: ["YOLO", "Containers", "GPU nodes", "Kubernetes", "Structured output"],
featured: true,
},
{
slug: "self-hosted-agent-infra",
title: "Self-Hosted AI Agent Infrastructure",
outcome:
"Ran a private, channel-connected AI agent on my own hardware — no third-party platform holding the data or the keys.",
summary:
"A self-hosted agent on a GPU workstation with a Signal-based sibling agent, built on a skill/context-loading framework. Local LLM inference, scoped tool access, and a credential model where the agent never holds a downstream secret directly.",
role: "Builder",
period: "Ongoing",
stack: ["Local LLM (llama.cpp)", "Agent framework", "Skills/tooling", "Signal channel", "RTX 5080"],
featured: true,
},
{
slug: "homelab-platform",
title: "Homelab Platform",
outcome:
"A production-grade homelab: GitOps from bare metal to apps, with the same rigor I'd apply at work.",
summary:
"Proxmox with PCIe passthrough (GPU/SATA) underneath single-node Talos clusters, all driven by ArgoCD GitOps. Split-horizon DNS, SSO, observability, NAS-backed restic backups, and local LLM inference on a Blackwell GPU — the platform that hosts this very site.",
role: "Owner / Operator",
period: "Ongoing",
stack: ["Proxmox", "Talos", "ArgoCD", "Cilium", "Cloudflare Tunnel", "Prometheus"],
featured: false,
},
{
slug: "iac-fleet-automation",
title: "IaC Automation for Fleet Deployments",
outcome:
"Stood up identical edge clusters from code — GPU stack, networking, and secrets templated, not hand-configured.",
summary:
"Ansible/AWX playbooks for fleet deployment: GPU operator install, CNI and templated network attachments, container image pre-pull, and secrets delivered via Vault — so a new edge site comes up the same way every time.",
role: "Automation Engineer",
period: "Recent", // TODO(Jonathon): dates
stack: ["Ansible", "AWX", "Vault", "Multus", "GPU Operator"],
featured: false,
},
];
export const featuredProjects = projects.filter((p) => p.featured);
+34
View File
@@ -0,0 +1,34 @@
// Central site config — single source of truth for identity + metadata.
// Edit here, not in components.
export const site = {
name: "Jonathon Wright",
// Short handle used in the mono "logo".
handle: "jwright",
role: "Infrastructure Engineer",
// Outcome-led positioning (hero headline).
positioning:
"Building secure Kubernetes platforms, automated infrastructure fleets and GPU-backed edge systems.",
// Supporting capability line under the headline.
tagline: [
"Talos Linux",
"Kubernetes",
"GitOps",
"Ansible",
"Observability",
"Edge AI",
],
// One-paragraph elevator pitch for the About section.
bio: "I design and run platforms that have to keep working when no one is watching — fleets of GPU-backed Kubernetes clusters at the edge, the IaC pipelines that deploy them, and the observability and network policy that keep them honest. I care about reliability you can reason about, security that's the default rather than a bolt-on, and automation that removes the manual step entirely instead of documenting it.",
// Canonical URL (used for OG/sitemap/RSS).
url: "https://www.bztmon.com",
// Contact.
email: "jonny.wright225@gmail.com",
// Set when a real CV is dropped at public/cv.pdf — the button is hidden until then.
// Detection is automatic (see Layout/Hero); this is just an override if ever needed.
ogImage: "/og.png",
locale: "en",
} as const;
export type Site = typeof site;
+86
View File
@@ -0,0 +1,86 @@
// Grouped capability matrix — NO percentage bars / ratings / logo walls.
// Driven entirely by this file; the Skills section renders whatever is here.
export type SkillGroup = {
title: string;
// Short framing line for the group.
blurb: string;
items: string[];
};
export const skills: SkillGroup[] = [
{
title: "Platform Engineering",
blurb: "Kubernetes platforms designed to be reasoned about and recovered.",
items: [
"Kubernetes",
"Talos Linux",
"OpenShift",
"ArgoCD / GitOps",
"Helm & Kustomize",
"Cilium / flannel",
"Gateway API & Ingress",
],
},
{
title: "Infrastructure Automation",
blurb: "Removing the manual step entirely, not documenting it.",
items: [
"Ansible",
"AWX",
"GitOps pipelines",
"Renovate",
"Image pre-pull & templating",
"Reproducible cluster bring-up",
],
},
{
title: "Linux & Systems",
blurb: "From the hypervisor up to the workload.",
items: [
"Debian / RHEL",
"systemd",
"Proxmox / KVM",
"PCIe / GPU passthrough",
"LVM & block storage",
"restic / SMB backup",
"Bash & PowerShell",
],
},
{
title: "Networking & Security",
blurb: "Default-deny, least-privilege, and a small attack surface.",
items: [
"Split-horizon DNS",
"Cloudflare Tunnel",
"Reverse proxy (Traefik / nginx)",
"NetworkPolicies",
"OIDC / SSO (Authentik)",
"Secrets (Vault / Infisical / ESO)",
"TLS & cert-manager",
],
},
{
title: "Observability",
blurb: "Knowing the system is healthy, not just the pods.",
items: [
"Prometheus",
"Grafana",
"Alertmanager",
"node-exporter / kube-state-metrics",
"ServiceMonitors",
],
},
{
title: "AI & GPU Infrastructure",
blurb: "Serving vision and language models on real hardware.",
items: [
"NVIDIA GPU Operator",
"Multus / SR-IOV",
"YOLO / computer-vision inference",
"Local LLM serving (llama.cpp)",
"Agent frameworks & tooling",
"Hardware-isolated workloads (Kata)",
],
},
];
+31
View File
@@ -0,0 +1,31 @@
// Social / contact links. `mailto` is built from site.email.
// TODO(Jonathon): confirm GitHub + LinkedIn handles (placeholders below).
export type Social = {
label: string;
href: string;
// Inline SVG path data (24x24 viewBox) so we ship zero icon-font / external requests.
icon: string;
// External link gets rel=noopener; mailto does not.
external?: boolean;
};
export const socials: Social[] = [
{
label: "GitHub",
href: "https://github.com/jwrong96", // TODO(Jonathon): confirm public GitHub handle
external: true,
icon: "M12 .5A11.5 11.5 0 0 0 .5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.06-.72.08-.71.08-.71 1.17.08 1.78 1.2 1.78 1.2 1.04 1.79 2.73 1.27 3.4.97.1-.76.41-1.27.74-1.56-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.59.24 2.76.12 3.05.74.81 1.18 1.84 1.18 3.1 0 4.43-2.69 5.4-5.25 5.69.42.36.8 1.08.8 2.18v3.23c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12 11.5 11.5 0 0 0 12 .5Z",
},
{
label: "LinkedIn",
href: "https://www.linkedin.com/in/jonathon-wright", // TODO(Jonathon): confirm LinkedIn URL
external: true,
icon: "M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28ZM5.34 7.43a2.07 2.07 0 1 1 0-4.14 2.07 2.07 0 0 1 0 4.14ZM7.12 20.45H3.55V9h3.57v11.45ZM22.22 0H1.77C.8 0 0 .78 0 1.74v20.51C0 23.22.8 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.75V1.74C24 .78 23.2 0 22.22 0Z",
},
{
label: "Email",
href: "mailto:jonny.wright225@gmail.com",
icon: "M2 4h20c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1H2c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1Zm1.4 2 8.6 6 8.6-6H3.4ZM21 7.87l-8.43 5.9a1 1 0 0 1-1.14 0L3 7.87V18h18V7.87Z",
},
];
+100
View File
@@ -0,0 +1,100 @@
---
import "../styles/global.css";
import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
import { site } from "../data/site";
interface Props {
title?: string;
description?: string;
/** Path to the page, e.g. "/projects" — used for canonical + OG url. */
path?: string;
}
const {
title,
description = site.positioning,
path = "/",
} = Astro.props;
const pageTitle = title ? `${title} — ${site.name}` : `${site.name} · ${site.role}`;
const canonical = new URL(path, site.url).href;
const ogImage = new URL(site.ogImage, site.url).href;
---
<!doctype html>
<html lang={site.locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={canonical} />
<title>{pageTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={site.name} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content={site.name} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
<meta name="theme-color" content="#090c14" />
<!-- Pre-paint theme bootstrap: avoids a flash of the wrong theme.
The ONE inline script on the site; hashed by Astro CSP in M4. -->
<script is:inline>
(function () {
try {
var stored = localStorage.getItem("theme");
var theme =
stored ||
(window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark");
document.documentElement.dataset.theme = theme;
} catch (e) {
document.documentElement.dataset.theme = "dark";
}
})();
</script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<Nav />
<main id="main">
<slot />
</main>
<Footer />
<!-- Reveal-on-scroll: progressive enhancement, motion-aware. -->
<script>
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targets = document.querySelectorAll<HTMLElement>("[data-reveal]");
if (!reduce && "IntersectionObserver" in window) {
targets.forEach((el) => el.classList.add("reveal"));
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
targets.forEach((el) => io.observe(el));
}
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
// Build-time checks for optional static assets.
// Astro frontmatter runs at build (SSG), so the filesystem is available here.
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
const publicDir = fileURLToPath(new URL("../../public/", import.meta.url));
/** True only when a real CV has been dropped at public/cv.pdf. */
export const cvAvailable = existsSync(publicDir + "cv.pdf");
+35
View File
@@ -0,0 +1,35 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="404 — Not found" path="/404">
<section class="nf">
<div class="container nf__inner">
<p class="eyebrow">Error 404</p>
<h1 class="nf__code mono">404</h1>
<p class="nf__msg">
This route returned no endpoint. The page you're looking for isn't here.
</p>
<a class="btn btn--primary" href="/">← Back home</a>
</div>
</section>
</Layout>
<style>
.nf {
min-height: 64vh;
display: grid;
place-items: center;
text-align: center;
}
.nf__code {
font-size: var(--step-5);
color: var(--accent);
margin-block: var(--space-3);
}
.nf__msg {
color: var(--text-dim);
max-width: 38ch;
margin: 0 auto var(--space-6);
}
</style>
+55
View File
@@ -0,0 +1,55 @@
---
import Layout from "../layouts/Layout.astro";
import Hero from "../components/Hero.astro";
import Section from "../components/Section.astro";
import About from "../components/About.astro";
import SkillGroup from "../components/SkillGroup.astro";
import ProjectCard from "../components/ProjectCard.astro";
import Contact from "../components/Contact.astro";
import { skills } from "../data/skills";
import { featuredProjects } from "../data/projects";
---
<Layout path="/">
<Hero />
<Section id="about" eyebrow="About" index="01" title="Reliability you can reason about.">
<About />
</Section>
<Section id="skills" eyebrow="Capabilities" index="02" title="What I work with, grouped by what it's for.">
<div class="grid grid--skills">
{skills.map((group) => <SkillGroup group={group} />)}
</div>
</Section>
<Section id="projects" eyebrow="Selected work" index="03" title="Platforms built to keep working unattended.">
<div class="grid grid--projects">
{featuredProjects.map((project) => <ProjectCard project={project} />)}
</div>
<p class="projects__more" data-reveal>
<a class="btn" href="/projects">All projects →</a>
</p>
</Section>
<Section id="contact" eyebrow="Get in touch" index="04" title="Let's talk infrastructure.">
<Contact />
</Section>
</Layout>
<style>
.grid {
display: grid;
gap: var(--space-5);
}
.grid--skills {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.grid--projects {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.projects__more {
margin-top: var(--space-6);
text-align: center;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
---
import Layout from "../../layouts/Layout.astro";
import Section from "../../components/Section.astro";
import ProjectCard from "../../components/ProjectCard.astro";
import { projects } from "../../data/projects";
---
<Layout title="Projects" path="/projects" description="Selected platform & infrastructure projects by Jonathon Wright.">
<Section id="all-projects" eyebrow="Projects" index="*" title="Everything I've built worth writing about.">
<p class="lead projects__intro" data-reveal>
Edge Kubernetes, GPU inference, self-hosted AI, and the automation that ties
it together. Full case studies are on the way.
</p>
<div class="grid">
{projects.map((project) => <ProjectCard project={project} />)}
</div>
</Section>
</Layout>
<style>
.projects__intro {
max-width: 50ch;
margin-bottom: var(--space-6);
}
.grid {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
</style>
+188
View File
@@ -0,0 +1,188 @@
@import "tailwindcss";
@import "./tokens.css";
/* ---- Base ---------------------------------------------------------------- */
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
/* Offset for the sticky nav when jumping to #sections */
scroll-padding-top: 5rem;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--step-0);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4 {
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
:focus-visible {
outline: 2px solid var(--accent-strong);
outline-offset: 3px;
border-radius: 4px;
}
::selection {
background: var(--accent);
color: var(--accent-ink);
}
img,
svg {
max-width: 100%;
height: auto;
display: block;
}
/* ---- Layout helpers ------------------------------------------------------ */
.container {
width: 100%;
max-width: var(--measure);
margin-inline: auto;
padding-inline: var(--space-5);
}
.section {
padding-block: var(--space-9);
position: relative;
}
.eyebrow {
font-family: var(--font-mono);
font-size: var(--step--1);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.lead {
font-size: var(--step-1);
color: var(--text-dim);
line-height: 1.6;
}
.mono {
font-family: var(--font-mono);
}
/* ---- Buttons ------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: var(--step--1);
letter-spacing: 0.02em;
padding: 0.7rem 1.15rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
color: var(--text);
background: var(--surface);
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease),
background 0.2s var(--ease), color 0.2s var(--ease);
text-decoration: none;
}
.btn:hover {
text-decoration: none;
border-color: var(--accent-line);
transform: translateY(-1px);
}
.btn--primary {
background: var(--accent);
color: var(--accent-ink);
border-color: transparent;
font-weight: 600;
}
.btn--primary:hover {
background: var(--accent-strong);
}
/* ---- Cards / tags -------------------------------------------------------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color 0.25s var(--ease), transform 0.25s var(--ease);
}
.card:hover {
border-color: var(--accent-line);
}
.tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--text-dim);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
background: var(--accent);
color: var(--accent-ink);
padding: 0.6rem 1rem;
border-radius: 0 0 var(--radius-sm) 0;
}
.skip-link:focus {
left: 0;
}
/* ---- Reveal-on-scroll (progressive enhancement) -------------------------- */
/* Default = visible (JS-off safe). The .reveal class is only added by JS when
IntersectionObserver is supported AND motion is allowed. */
.reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
+90
View File
@@ -0,0 +1,90 @@
/* Design tokens — theme the whole site from here.
Dark-first: :root carries the dark palette; [data-theme="light"] overrides. */
:root {
color-scheme: dark;
/* Surfaces */
--bg: #090c14;
--bg-soft: #0c1019;
--surface: #11161f;
--surface-2: #161d29;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.16);
/* Text */
--text: #e7edf4;
--text-dim: #9aa7b8;
--text-faint: #61708a;
/* Accent — single restrained teal */
--accent: #5eead4;
--accent-strong: #2dd4bf;
--accent-ink: #042f2a; /* text on an accent fill */
--accent-dim: rgba(94, 234, 212, 0.12);
--accent-line: rgba(94, 234, 212, 0.28);
--accent-glow: rgba(45, 212, 191, 0.22);
/* Grid / topology motif */
--grid-line: rgba(148, 163, 184, 0.06);
/* Fonts — system stacks only (zero external requests). */
--font-sans: "Inter var", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Cascadia Code", "SF Mono",
Menlo, Consolas, "Liberation Mono", monospace;
/* Fluid type scale */
--step--1: clamp(0.8rem, 0.76rem + 0.2vw, 0.9rem);
--step-0: clamp(0.95rem, 0.9rem + 0.25vw, 1.05rem);
--step-1: clamp(1.15rem, 1.05rem + 0.5vw, 1.35rem);
--step-2: clamp(1.4rem, 1.2rem + 1vw, 1.85rem);
--step-3: clamp(1.75rem, 1.4rem + 1.7vw, 2.6rem);
--step-4: clamp(2.2rem, 1.6rem + 3vw, 3.6rem);
--step-5: clamp(2.6rem, 1.8rem + 4.5vw, 4.6rem);
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-7: 3rem;
--space-8: 4.5rem;
--space-9: 7rem;
/* Layout */
--measure: 68rem;
--radius: 14px;
--radius-sm: 9px;
--ring: 0 0 0 1px var(--border);
--shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
[data-theme="light"] {
color-scheme: light;
--bg: #f6f8fb;
--bg-soft: #ffffff;
--surface: #ffffff;
--surface-2: #eef1f6;
--border: rgba(2, 6, 23, 0.1);
--border-strong: rgba(2, 6, 23, 0.18);
--text: #0e1726;
--text-dim: #475467;
--text-faint: #6b7689;
--accent: #0d9488;
--accent-strong: #0f766e;
--accent-ink: #ecfdf5;
--accent-dim: rgba(13, 148, 136, 0.1);
--accent-line: rgba(13, 148, 136, 0.3);
--accent-glow: rgba(13, 148, 136, 0.16);
--grid-line: rgba(15, 23, 42, 0.05);
--shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
}