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