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:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
.vscode
|
||||||
|
npm-debug.log*
|
||||||
|
*.md
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Multi-stage: Debian build (Node; Chromium deps for Mermaid land in M3) →
|
||||||
|
# pinned nginx-unprivileged runtime serving the static dist/.
|
||||||
|
|
||||||
|
# ---- build stage ----------------------------------------------------------
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps from the lockfile only first (better layer caching).
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Build the static site.
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- runtime stage --------------------------------------------------------
|
||||||
|
# Same vetted digest used by the k8s Deployment. Renovate keeps it current.
|
||||||
|
FROM ghcr.io/nginx/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0
|
||||||
|
|
||||||
|
# Custom server config (security headers, caching, SPA-ish routing).
|
||||||
|
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# The built site.
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# bztmon-site
|
||||||
|
|
||||||
|
The source for **[www.bztmon.com](https://www.bztmon.com)** — Jonathon Wright's
|
||||||
|
portfolio / résumé site. A fast, animated, security-hardened static site for a
|
||||||
|
platform / infrastructure engineer.
|
||||||
|
|
||||||
|
> This repo is **public**. It lives on a self-hosted public Gitea (`git.bztmon.com`),
|
||||||
|
> isolated from the private homelab GitOps. **Never commit secrets** — the static
|
||||||
|
> site needs none.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Astro** (static output) + **TypeScript** + **Tailwind v4**
|
||||||
|
- Zero JS by default; tiny islands for the theme toggle + scroll reveals
|
||||||
|
- Content & config are data-driven (`src/data/`) — adding a project never touches a component
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:4321
|
||||||
|
npm run check # astro check (types + diagnostics)
|
||||||
|
npm run build # static build → dist/
|
||||||
|
npm run preview # serve the build locally
|
||||||
|
npm run gen:og # regenerate the social-preview image (public/og.png)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
data/ site.ts, socials.ts, skills.ts, projects.ts, experience.ts
|
||||||
|
components/ Hero, Nav, ThemeToggle, ProjectCard, SkillGroup, ...
|
||||||
|
layouts/ Layout.astro (SEO/OG, theme bootstrap)
|
||||||
|
pages/ index.astro, projects/, 404.astro
|
||||||
|
styles/ tokens.css (theme), global.css
|
||||||
|
lib/ build-time helpers (cv detection)
|
||||||
|
scripts/ gen-og.mjs, build-image.sh
|
||||||
|
nginx/ default.conf (security headers, caching) baked into the image
|
||||||
|
Dockerfile Debian build stage → nginx-unprivileged runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content TODOs (Jonathon)
|
||||||
|
|
||||||
|
- Drop a real CV at `public/cv.pdf` — the **Download CV** button appears automatically.
|
||||||
|
- Fill the `TODO(Jonathon)` markers in `src/data/experience.ts`, `projects.ts`, `socials.ts`
|
||||||
|
(employer names, dates, GitHub/LinkedIn handles).
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Built into a container image, served by nginx-unprivileged on a homelab Kubernetes
|
||||||
|
cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private
|
||||||
|
`home-ops` repo and rolled out by ArgoCD. See `scripts/build-image.sh`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: "https://www.bztmon.com",
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# nginx server config for the static site, baked into the image.
|
||||||
|
# Base image: nginxinc/nginx-unprivileged (runs as uid 101, listens on 8080).
|
||||||
|
# Read-only rootfs in k8s: /tmp and /var/cache/nginx are emptyDir mounts.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Don't leak the nginx version.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# ---- Security headers (origin) -------------------------------------------
|
||||||
|
# These travel with the artifact. HSTS + HTTPS redirect are set at the
|
||||||
|
# Cloudflare edge (the tunnel terminates TLS), so they are NOT set here.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
|
|
||||||
|
# ---- Content-Security-Policy ---------------------------------------------
|
||||||
|
# FILLED IN M4: Astro computes the inline script/style hashes at build; the
|
||||||
|
# final policy is emitted here as a header (a <meta> CSP can't set
|
||||||
|
# frame-ancestors). Until then, a conservative baseline:
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
|
||||||
|
# ---- Caching -------------------------------------------------------------
|
||||||
|
# Astro emits content-hashed assets under /_astro/ — cache them hard.
|
||||||
|
location /_astro/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML is revalidated so deploys show up immediately.
|
||||||
|
location ~* \.html$ {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Routing -------------------------------------------------------------
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ $uri.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml application/rss+xml;
|
||||||
|
gzip_vary on;
|
||||||
|
}
|
||||||
Generated
+6355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "bztmon-site",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"check": "astro check",
|
||||||
|
"gen:og": "node scripts/gen-og.mjs",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"astro": "^6.4.7",
|
||||||
|
"tailwindcss": "^4.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.9",
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="JW">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#090c14"/>
|
||||||
|
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#2dd4bf" stroke-opacity="0.5"/>
|
||||||
|
<text x="32" y="33" font-family="ui-monospace, Menlo, Consolas, monospace" font-size="22"
|
||||||
|
font-weight="700" fill="#5eead4" text-anchor="middle" dominant-baseline="central"
|
||||||
|
letter-spacing="1">jw</text>
|
||||||
|
<rect x="18" y="46" width="22" height="3" rx="1.5" fill="#2dd4bf"/>
|
||||||
|
<rect x="43" y="46" width="6" height="3" rx="1.5" fill="#2dd4bf">
|
||||||
|
<animate attributeName="opacity" values="1;0;1" dur="1.1s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 717 B |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://www.bztmon.com/sitemap-index.xml
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build + push the site image to the public Gitea registry using rootless buildah.
|
||||||
|
# Bootstrap path for M1-M4 (before the Gitea Actions runner exists in M5).
|
||||||
|
#
|
||||||
|
# Usage: scripts/build-image.sh [push]
|
||||||
|
# (no arg) -> build only
|
||||||
|
# push -> build then push, and print the pushed digest to pin in home-ops
|
||||||
|
#
|
||||||
|
# Requires: buildah (rootless) on the host, and `buildah login` to the registry
|
||||||
|
# for the push step (anonymous pull, authenticated push).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-git.bztmon.com}"
|
||||||
|
IMAGE="${IMAGE:-jwrong96/bztmon-site}"
|
||||||
|
REF="${REGISTRY}/${IMAGE}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Tag with the short git sha when available, else 'dev'.
|
||||||
|
TAG="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
|
||||||
|
echo ">> building ${REF}:${TAG}"
|
||||||
|
buildah build --layers -t "${REF}:${TAG}" -t "${REF}:latest" .
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "push" ]]; then
|
||||||
|
echo ">> pushing ${REF}:${TAG}"
|
||||||
|
buildah push --digestfile /tmp/bztmon-site.digest "${REF}:${TAG}"
|
||||||
|
buildah push "${REF}:latest"
|
||||||
|
DIGEST="$(cat /tmp/bztmon-site.digest)"
|
||||||
|
echo
|
||||||
|
echo ">> pin this in home-ops kubernetes/apps/bztmon-site/bztmon-site.yaml:"
|
||||||
|
echo " image: ${REF}:${TAG}@${DIGEST}"
|
||||||
|
else
|
||||||
|
echo ">> built (not pushed). Re-run with: scripts/build-image.sh push"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Generate the social preview image (public/og.png, 1200x630) from an inline SVG.
|
||||||
|
// Run: node scripts/gen-og.mjs (also wired into `npm run build` via prebuild).
|
||||||
|
// Self-hosted asset → no third-party OG service, nothing external at runtime.
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const out = fileURLToPath(new URL("../public/og.png", import.meta.url));
|
||||||
|
|
||||||
|
const W = 1200;
|
||||||
|
const H = 630;
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="title" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e7edf4"/>
|
||||||
|
<stop offset="1" stop-color="#7fdcc9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="18%" cy="0%" r="80%">
|
||||||
|
<stop offset="0" stop-color="#2dd4bf" stop-opacity="0.22"/>
|
||||||
|
<stop offset="60%" stop-color="#2dd4bf" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="grid" width="44" height="44" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M44 0H0V44" fill="none" stroke="#94a3b8" stroke-opacity="0.06" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="${W}" height="${H}" fill="#090c14"/>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#grid)"/>
|
||||||
|
<rect width="${W}" height="${H}" fill="url(#glow)"/>
|
||||||
|
<rect x="0" y="0" width="${W}" height="6" fill="#2dd4bf"/>
|
||||||
|
|
||||||
|
<text x="80" y="150" font-family="monospace" font-size="26" letter-spacing="6"
|
||||||
|
fill="#5eead4">~/ INFRASTRUCTURE ENGINEER</text>
|
||||||
|
|
||||||
|
<text x="78" y="300" font-family="sans-serif" font-size="118" font-weight="700"
|
||||||
|
letter-spacing="-3" fill="url(#title)">Jonathon Wright</text>
|
||||||
|
|
||||||
|
<text x="80" y="392" font-family="sans-serif" font-size="40" font-weight="500" fill="#cdd6e3">
|
||||||
|
Secure Kubernetes platforms · automated fleets
|
||||||
|
</text>
|
||||||
|
<text x="80" y="446" font-family="sans-serif" font-size="40" font-weight="500" fill="#cdd6e3">
|
||||||
|
· GPU-backed edge systems
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text x="80" y="560" font-family="monospace" font-size="28" fill="#61708a">
|
||||||
|
Talos · Kubernetes · GitOps · Ansible · Edge AI
|
||||||
|
</text>
|
||||||
|
<text x="${W - 80}" y="560" text-anchor="end" font-family="monospace" font-size="28"
|
||||||
|
fill="#5eead4">www.bztmon.com</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
await sharp(Buffer.from(svg)).png().toFile(out);
|
||||||
|
console.log("wrote", out);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user