Compare commits

..

24 Commits

Author SHA1 Message Date
jwright 4e13bf596c facelift: fix light-theme scrims/thumbs - :global() the data-theme overrides
Astro scopes compound selectors, so [data-theme=light] .hero__scrim inside a component style
block could never match html[data-theme] - the light theme rendered dark scrims (embossed
low-contrast copy). Wrap the theme prefix in :global() in Hero, ProjectCard and the blog index.
Light 'blueprint' hero now reads clean.
2026-07-03 19:50:18 +10:00
jwright 8a7ca88c40 facelift polish: native cross-document view transitions + og regen
@view-transition navigation:auto (pure CSS crossfade, 180ms; Firefox ignores, reduced-motion
disables) - zero JS, zero CSP surface. og.png regenerated from the electric-identity script.
Favicon already carries the identity (gradient jw chip + caret) - deliberately unchanged.
2026-07-03 19:47:03 +10:00
jwright de12f028eb facelift: blog index - featured security-series opener + image-led grid
Breadcrumb eyebrow (~/jwright/blog), 'Notes from the build.' H1, featured card for the series
opener (text left / hero art scrimmed right, series badge, eager-loaded), remaining posts in the
image-led responsive grid with series badges + reading time. Post CONTENT untouched (index only).
2026-07-03 19:46:07 +10:00
jwright 8d955aa802 facelift: the scroll story - bento capabilities, dossier cards, series badges, GitOps receipt
02 Capabilities becomes a 4-col bento (Edge AI 2x2 flagship w/ magenta corner radial, Platform
wide, magenta-flavoured security tile, Observability row-completer) driven by span/flavor fields
in skills.ts. 03 Selected Work becomes dossier cards: house-style SVG thumbnails (per-project
motif via DossierThumb), DOSSIER/00N corner tags, 'the argument' line from the outcome field,
chip tags, mono meta footer. 04 Writing cards gain the security-series badge (data/series.ts)
and sit three across. Section numbers glow cyan; a circuit divider (gradient hairlines + two
diamonds) closes the hero. Footer opens with the GitOps receipt - git sha + build date baked at
build time via lib/build-info.ts, zero runtime calls.
2026-07-03 19:43:45 +10:00
jwright 65ba1ec7dc facelift: fleet constellation hero + Bat-Computer type/token layer
Chakra Petch site-wide (300-700) + JetBrains Mono variable, all self-hosted (CSP zero-external
holds). Token layer gains the mock-exact text tiers, glass panel, cyan-tinted borders, glows,
tighter radii; light 'blueprint' variants included. New Constellation.astro: the six real nodes
as IC chips on dual-trace PCB paths (cyan out / magenta back, dasharray packets), radar ring on
the cave hub, vias at bends - pure SVG + scoped CSS keyframes, full reduced-motion fallback.
Hero rebuilt: scrim stack + masked grid over the board art, two-line gradient nameplate, typing
bar cycling six real fleet facts via the external site.js (facts from src/data/fleet.ts), scroll
indicator. Facts wrap, never clip.
2026-07-03 18:58:49 +10:00
jwright 08bab080b1 nav: stop tabs sticking lit on Projects/Blog pages
build-and-deploy / build (push) Failing after 14m26s
On pages with no scroll-spy sections, activeIdx stays -1 and indexOf(link)
is also -1, so the pointerleave 'keep lit if active' check matched every
hovered tab. Guard with activeIdx >= 0 so a hovered tab always retracts when
nothing is active.
2026-06-29 19:54:30 +10:00
jwright dae9bbc91a nav: animate Projects/Blog underlines + coherent directional slide
build-and-deploy / build (push) Failing after 12m11s
- Projects/Blog had no data-nav-id so the scroll-spy skipped them entirely;
  point them at the homepage #projects/#writing sections so all five animate
- Drive the underline declaratively (re-assert all on each change) so none can
  be left stranded mid-spring
- Slide follows scroll direction: activating underline grows from its trailing
  edge while the rest collapse toward the leading edge (down→right, up→left)
2026-06-29 19:46:46 +10:00
jwright 7c300faec8 home: fix nav active-section at page bottom + exact progress bar; add blog tags to Writing
build-and-deploy / build (push) Failing after 10m38s
- Nav underline highlighted the wrong section at the bottom (the short last
  section never crossed the threshold) — force the last section when scrolled
  to the bottom so Contact lights up instead of Skills
- Scroll-progress bar: compute progress from scrollY/maxScroll so it spans a
  true 0→1 (Motion's default topped out ~0.89 at the bottom)
- Writing section: add blog tag chips linking to /blog/tags/<tag>/
2026-06-29 19:08:17 +10:00
jwright 257af5d22e home: add Writing section; fix scroll-progress snap + nav underline reverse
build-and-deploy / build (push) Failing after 10m20s
- Scroll-progress bar: drive scaleX from scroll progress via callback form;
  the animation form snapped the bar to 0 at the bottom (progress=1 boundary)
- Nav active-underline: recompute active section from scroll position each
  frame so it tracks upward scroll too (inView-enter died on the way back up)
- Add a Writing section to the homepage with recent posts + a blog CTA
2026-06-29 18:53:35 +10:00
jwright 880b9daeac projects: reword index tagline; split global infra into estate + network-automation
build-and-deploy / build (push) Failing after 11m25s
- New projects tagline + open-minded lead
- Split the combined infra project: keep the estate/cloud modernisation,
  add a dedicated Network Automation at Fleet Scale case (Unimus + NetBox,
  config backup, bulk push, vaulted+rotated credentials) from the Linde role
2026-06-29 18:32:29 +10:00
jwright a685789322 blog: drop duplicate inline hero on secret-zero
build-and-deploy / build (push) Failing after 10m24s
The hero image is rendered from frontmatter; the leftover inline
markdown image rendered it a second time (and uncached, so the old
asset showed).
2026-06-29 18:13:32 +10:00
jwright 61642e8db8 blog: add secret-zero bootstrap diagram; clean up hero images
build-and-deploy / build (push) Failing after 13m51s
- Add the secret-zero bootstrap-chain diagram (was a broken image ref)
- Remove the corner sparkle artifact from all six blog hero images
- Bump hero/diagram cache-bust params so the updated assets serve
2026-06-29 18:05:03 +10:00
jwright bed2dbaa1f blog: add outbound + secret-zero hero images
build-and-deploy / build (push) Failing after 11m34s
outbound-only-exposure (corrected sealed-vault image) + secret-zero (the seed-
key circuit-tree, _R revision). Both verified against their prompts, optimized
to webp (17KB / ~120KB). Completes hero coverage for all 6 posts.
2026-06-29 17:47:23 +10:00
jwright 354475a453 blog: hero images + publish secret-zero
build-and-deploy / build (push) Failing after 11m14s
Add an optional hero field to the blog schema; render it on the post page and
as the index card thumbnail. Ship 4 verified Nano Banana hero images (shipping/
init-gating/authentik/workload) as optimized webp (64-134KB). Publish the
secret-zero post. (outbound hero pending — its file was a dup of shipping;
secret-zero hero pending generation.)
2026-06-29 17:37:40 +10:00
jwright d8a147aabe ui: design polish — themed scrollbar, focus-ring glow, card depth
build-and-deploy / build (push) Failing after 11m52s
Themed thin scrollbar (cyan→magenta thumb, Firefox + WebKit), an on-brand glow
halo on the keyboard focus ring (solid outline keeps WCAG visibility), and a 1px
inner top highlight so cards read raised at rest. CSS-only, token-pure.
2026-06-28 23:32:04 +10:00
jwright f6d92e5c87 ui: Motion-driven animation layer (motion.dev)
build-and-deploy / build (push) Failing after 14m48s
Unify scroll reveals, hero entrance, nav active-underline, button springs,
the scroll-progress bar and backdrop parallax under one Motion system
(src/scripts/anim.ts), bundled into a self-hosted /_astro module so CSP
script-src 'self' holds. Progress bar + parallax now scroll()-driven and
cross-browser (was Chromium-only animation-timeline). Fail-open, reduced-motion
aware, content fully visible with JS off.
2026-06-28 23:23:58 +10:00
jwright 5d439752de ui: retheme favicon + OG card to the cyan→magenta theme
build-and-deploy / build (push) Failing after 12m13s
The tab logo and social preview were still on the old teal palette. Retone both
to the electric cyan→magenta theme matching the hero nameplate:
- favicon.svg: jw monogram + prompt-line + cursor on a cyan→magenta gradient.
- og.png (gen-og.mjs): name in the cyan→violet→magenta hero gradient, cyan
  eyebrow/separators, magenta URL, cyan→magenta top bar + dual corner glows.
- bump ?v=2 on both refs (public/ assets are CF-edge-cached + social platforms
  cache OG hard) so the new art shows immediately.
2026-06-27 23:01:41 +10:00
jwright 0c93328a2d ui: vivid electric nameplate — fix invisible hero title in light mode
build-and-deploy / build (push) Failing after 10m26s
The hero name was a mostly---text gradient with a thin accent sweep; in light
mode it rested on low-contrast tones and the body text-halo washed it to a ghost.
Replace with a full cyan→violet→magenta fill driven by the theme-aware accent
tokens (deep on light, bright on dark) so it's unmissable in either mode, plus a
tight dark edge-shadow for legibility, the electric glow, and a one-shot light
sheen that parks off-screen. Opt the title out of the light text-halo.
2026-06-27 21:28:30 +10:00
jwright ef1719c84e ui: keep the board visible in light mode + bust diagram cache
build-and-deploy / build (push) Failing after 14m43s
Light mode: revert the heavy white-out scrim back to a moderate wash so the
board stays visible, and earn legibility with a soft light text-halo behind
running text instead (invisible on the solid cards, only works over the image).
Pairs with the darker light-mode text tokens.

Also append ?v=2 to every /diagrams/*.svg reference so the recoloured diagrams
bypass Cloudflare's 4h edge cache and show immediately (the SVG URLs aren't
content-hashed like the CSS bundle).
2026-06-27 21:19:13 +10:00
jwright e264f673d7 fix(ui): readable light mode over the board backdrop
build-and-deploy / build (push) Failing after 13m46s
Body text washed out in light mode — the secondary/tertiary text tokens were
too light against the bright sandstone board image. Darken light --text-dim and
--text-faint, and heavily strengthen the light-theme scrim so the board drops
to a faint texture behind content. Dark mode unchanged.
2026-06-27 21:15:08 +10:00
jwright 8702c8a293 ui: re-theme architecture diagrams to the cyan→magenta palette
build-and-deploy / build (push) Failing after 13m39s
The diagrams still used the old teal/green/blue scheme. Remap all six to the
electric theme: teal #0d9488 → cyan #0c8fce (primary), blue #3b6fb0 → violet
#7c3aed (containers/boundaries), green #2e8b57 → magenta #c026d3 (services).
Neutral slate + navy kept for lines and text; light card unchanged so the
fixed-colour art stays legible in both themes.
2026-06-27 21:10:17 +10:00
jwright b7c91081b2 fix(ui): scroll-coupled backdrop pan that works in every browser
build-and-deploy / build (push) Failing after 11m10s
CSS animation-timeline: scroll() is Chromium-only, so the backdrop didn't move
for non-Chromium browsers. Replace it with a tiny rAF-throttled scroll handler
in site.js (the existing same-origin script — CSP stays strict, no inline JS)
that drives a --bg-shift CSS var off scroll position. The board now pans
vertically in lock-step with scroll and sits still at rest, in all browsers.
2026-06-27 21:02:46 +10:00
jwright af7e0287d4 fix(ui): backdrop now moves in every browser
build-and-deploy / build (push) Failing after 14m47s
The backdrop's only motion was the scroll-driven pan (animation-timeline:
scroll()), which is Chromium-only — so in Firefox/Safari the board sat dead
still. Add a continuous timed top↔bottom drift (48s, alternates) as the
universal baseline; keep the scroll-coupled pan as a Chromium enhancement that
overrides it. Motion-safe: both gated behind prefers-reduced-motion.
2026-06-27 20:54:08 +10:00
jwright 2aae7c287e ui: backdrop pan — monotonic top→bottom drift
build-and-deploy / build (push) Failing after 11m19s
The S→E→SE→NE loop wandered back near its start, so on a long page the
per-screen movement was imperceptible (worse once the image was zoomed to
215%). Replace it with a single continuous vertical pan (~40% travel) so the
board obviously drifts downward as the page scrolls.
2026-06-27 20:47:36 +10:00
58 changed files with 2200 additions and 2474 deletions
+69 -2077
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"@astrojs/sitemap": "^3.7.3",
"@tailwindcss/vite": "^4.3.1",
"astro": "^7.0.3",
"motion": "^12.42.0",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+71
View File
@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="430" viewBox="0 0 1180 430" role="img" aria-label="The secret-zero bootstrap chain: one out-of-band machine identity (read-only, per-cluster, applied by hand, never in git) unlocks the secret store; a ClusterSecretStore and ExternalSecrets live in git as pointers (names, not values); the External Secrets Operator resolves each pointer and reads the real values from Infisical to materialise a Kubernetes Secret in-cluster.">
<rect width="1180" height="430" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Secret zero: the seed outside GitOps</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">one out-of-band credential unlocks the store &#8212; the repo only ever holds pointers</text>
<!-- Infisical store (real values) -->
<ellipse cx="975" cy="96" rx="78" ry="14" fill="#eef2f7" stroke="#0c8fce" stroke-width="1.6"></ellipse>
<path d="M897 96 V154" fill="none" stroke="#0c8fce" stroke-width="1.6"></path>
<path d="M1053 96 V154" fill="none" stroke="#0c8fce" stroke-width="1.6"></path>
<path d="M897 154 A78 14 0 0 0 1053 154" fill="none" stroke="#0c8fce" stroke-width="1.6"></path>
<text x="975" y="101" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#0e1726">Infisical</text>
<text x="975" y="130" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">the real values</text>
<!-- store -> materialised Secret (real values flow down) -->
<path d="M975 156 V232" fill="none" stroke="#0c8fce" stroke-width="2" stroke-dasharray="2 5" stroke-linecap="round"></path>
<path d="M969 222 L975 233 M981 222 L975 233" fill="none" stroke="#0c8fce" stroke-width="2" stroke-linecap="round"></path>
<text x="992" y="200" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">real values</text>
<text x="992" y="216" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">(authed by secret zero)</text>
<!-- git repo boundary (holds the pointers) -->
<rect x="310" y="196" width="508" height="160" rx="18" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="328" y="218" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">in git &#8212; pointers, never values</text>
<!-- out-of-band pill -->
<rect x="56" y="206" width="128" height="23" rx="11.5" fill="#ffffff" stroke="#c026d3" stroke-width="1.4"></rect>
<text x="120" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#c026d3">out-of-band</text>
<!-- secret zero box -->
<rect x="56" y="236" width="194" height="100" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="153" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15.5" font-weight="700" fill="#c026d3">secret zero</text>
<text x="153" y="298" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">read-only &#183; per-cluster</text>
<text x="153" y="319" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">applied by hand &#183; never in git</text>
<!-- ClusterSecretStore box -->
<rect x="330" y="236" width="194" height="100" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="427" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">ClusterSecretStore</text>
<text x="427" y="298" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">store URL + ref &#8594; secret zero</text>
<text x="427" y="319" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">wires the cluster up</text>
<!-- ExternalSecret box -->
<rect x="604" y="236" width="194" height="100" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="701" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0e1726">ExternalSecret &#215;N</text>
<text x="701" y="298" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">a pointer per app</text>
<text x="701" y="319" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">a name, never a value</text>
<!-- Kubernetes Secret box (materialised in-cluster) -->
<rect x="878" y="236" width="194" height="100" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="975" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0e1726">Kubernetes Secret</text>
<text x="975" y="298" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">materialised in-cluster</text>
<text x="975" y="319" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">kept in sync by ESO</text>
<!-- arrows (run clean under the labels) -->
<path d="M250 286 H324" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M314 280 L324 286 M314 292 L324 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M524 286 H598" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M588 280 L598 286 M588 292 L598 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M798 286 H872" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M862 280 L872 286 M862 292 L872 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<!-- arrow labels on clean white pills, seated above each arrow -->
<rect x="261" y="255" width="58" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="290" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">unlocks</text>
<rect x="527" y="255" width="74" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="564" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">references</text>
<rect x="807" y="255" width="62" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="838" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">resolves</text>
<!-- footer takeaway -->
<text x="60" y="398" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#64748b">Exactly one credential lives outside GitOps &#8212; read-only, per-cluster, the seed the whole tree grows from. Everything else is just a pointer.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

+13 -13
View File
@@ -13,39 +13,39 @@
<!-- broken link: API -> pod (token never mounted) -->
<text x="336" y="180" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#64748b">no API token mounted</text>
<path d="M244 198 H424" fill="none" stroke="#64748b" stroke-width="2" stroke-dasharray="6 6" stroke-linecap="round"></path>
<path d="M328 190 L344 206 M344 190 L328 206" fill="none" stroke="#0d9488" stroke-width="2.6" stroke-linecap="round"></path>
<path d="M328 190 L344 206 M344 190 L328 206" fill="none" stroke="#0c8fce" stroke-width="2.6" stroke-linecap="round"></path>
<!-- namespace boundary -->
<rect x="430" y="92" width="630" height="252" rx="18" fill="#0d9488" fill-opacity="0.04" stroke="#0d9488" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="452" y="121" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0d9488">namespace</text>
<rect x="792" y="104" width="246" height="30" rx="15" fill="#ffffff" stroke="#0d9488" stroke-width="1.4"></rect>
<text x="915" y="124" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0d9488">PodSecurity: baseline &#8594; restricted</text>
<rect x="430" y="92" width="630" height="252" rx="18" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="452" y="121" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">namespace</text>
<rect x="792" y="104" width="246" height="30" rx="15" fill="#ffffff" stroke="#0c8fce" stroke-width="1.4"></rect>
<text x="915" y="124" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0c8fce">PodSecurity: baseline &#8594; restricted</text>
<!-- workload pod -->
<rect x="470" y="156" width="420" height="176" rx="14" fill="#ffffff" stroke="#3b6fb0" stroke-width="1.8"></rect>
<rect x="470" y="156" width="420" height="176" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="490" y="184" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">workload pod</text>
<!-- badge 1 -->
<rect x="490" y="196" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="209" r="3.6" fill="#0d9488"></circle>
<circle cx="507" cy="209" r="3.6" fill="#0c8fce"></circle>
<text x="522" y="213" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">ServiceAccount: its own &#8212; not the default</text>
<!-- badge 2 -->
<rect x="490" y="228" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="241" r="3.6" fill="#0d9488"></circle>
<circle cx="507" cy="241" r="3.6" fill="#0c8fce"></circle>
<text x="522" y="245" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">automountServiceAccountToken: false</text>
<!-- badge 3 -->
<rect x="490" y="260" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="273" r="3.6" fill="#0d9488"></circle>
<circle cx="507" cy="273" r="3.6" fill="#0c8fce"></circle>
<text x="522" y="277" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">runAsNonRoot &#183; uid 1000 &#183; drop ALL caps</text>
<!-- badge 4 -->
<rect x="490" y="292" width="380" height="26" rx="13" fill="#f1f5f9"></rect>
<circle cx="507" cy="305" r="3.6" fill="#0d9488"></circle>
<circle cx="507" cy="305" r="3.6" fill="#0c8fce"></circle>
<text x="522" y="309" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">seccompProfile: RuntimeDefault</text>
<!-- the exception: components that DO call the API -->
<rect x="60" y="286" width="300" height="80" rx="12" fill="#ffffff" stroke="#2e8b57" stroke-width="1.6"></rect>
<text x="78" y="309" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#2e8b57">the documented exception</text>
<rect x="60" y="286" width="300" height="80" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.6"></rect>
<text x="78" y="309" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#c026d3">the documented exception</text>
<text x="78" y="330" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">GitOps &#183; LoadBalancer &#183; secrets operator</text>
<text x="78" y="350" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">&#8594; scoped Role + token, nothing more</text>
<path d="M150 286 V246" fill="none" stroke="#2e8b57" stroke-width="1.6" stroke-linecap="round"></path>
<path d="M150 286 V246" fill="none" stroke="#c026d3" stroke-width="1.6" stroke-linecap="round"></path>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+10 -4
View File
@@ -1,11 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="JW">
<defs>
<linearGradient id="jw" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3fbaf5"/>
<stop offset="1" stop-color="#e879f9"/>
</linearGradient>
</defs>
<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"/>
<rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="url(#jw)" stroke-opacity="0.6"/>
<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"
font-weight="700" fill="url(#jw)" 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">
<rect x="18" y="46" width="22" height="3" rx="1.5" fill="url(#jw)"/>
<rect x="43" y="46" width="6" height="3" rx="1.5" fill="#e879f9">
<animate attributeName="opacity" values="1;0;1" dur="1.1s" repeatCount="indefinite"/>
</rect>
</svg>

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 943 B

+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+49 -20
View File
@@ -12,6 +12,11 @@
document.documentElement.dataset.theme = "dark";
}
// Mark JS as live BEFORE first paint. CSS only hides the pre-reveal animation
// state under `html.js`, so with JS off every element stays visible (the
// Motion layer in /_astro reveals them once it runs). Progressive enhancement.
document.documentElement.classList.add("js");
function onReady(fn) {
if (document.readyState !== "loading") fn();
else document.addEventListener("DOMContentLoaded", fn);
@@ -33,27 +38,51 @@
});
}
// --- Reveal on scroll (progressive enhancement, motion-aware) ----------
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var targets = document.querySelectorAll("[data-reveal]");
if (!reduce && "IntersectionObserver" in window) {
targets.forEach(function (el) {
el.classList.add("reveal");
});
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
// --- Hero typing loop (fleet facts) -------------------------------------
// Facts arrive via a data attribute (no inline JS → script-src 'self' holds).
// Reduced motion or JS-off: the first fact renders statically from markup.
var factBox = document.getElementById("fleet-fact");
var factText = document.getElementById("fleet-fact-text");
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (factBox && factText && !reduced) {
var facts;
try {
facts = JSON.parse(factBox.dataset.facts || "[]");
} catch (e) {
facts = [];
}
});
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
targets.forEach(function (el) {
io.observe(el);
});
if (facts.length > 1) {
var fi = 0; // current fact
var ci = facts[0].length; // start fully typed (matches SSR markup)
var deleting = false;
var tick = function () {
var fact = facts[fi];
if (!deleting && ci === fact.length) {
// hold at end of fact, then start the next
deleting = true;
setTimeout(tick, 3200);
return;
}
if (deleting) {
// swap instantly to the next fact (clean cut, like a terminal)
deleting = false;
fi = (fi + 1) % facts.length;
ci = 0;
factText.textContent = "";
setTimeout(tick, 260);
return;
}
ci++;
factText.textContent = facts[fi].slice(0, ci);
setTimeout(tick, 45);
};
setTimeout(tick, 3200);
}
}
// Scroll reveals, hero entrance, backdrop parallax, scroll-progress bar,
// nav underline and button springs all live in the Motion layer
// (src/scripts/anim.ts → bundled /_astro/*.js). This file owns only the
// pre-paint theme + the typing loop so it stays a tiny blocking <head> script.
});
})();
+22 -11
View File
@@ -12,26 +12,37 @@ 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"/>
<!-- Electric cyan->violet->magenta — mirrors the live hero nameplate. -->
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#3fbaf5"/>
<stop offset="0.5" stop-color="#9a7cf2"/>
<stop offset="1" stop-color="#e879f9"/>
</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"/>
<linearGradient id="bar" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#3fbaf5"/>
<stop offset="1" stop-color="#e879f9"/>
</linearGradient>
<radialGradient id="glow" cx="14%" cy="0%" r="85%">
<stop offset="0" stop-color="#3fbaf5" stop-opacity="0.20"/>
<stop offset="55%" stop-color="#3fbaf5" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="92%" cy="100%" r="70%">
<stop offset="0" stop-color="#e879f9" stop-opacity="0.16"/>
<stop offset="60%" stop-color="#e879f9" 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"/>
<path d="M44 0H0V44" fill="none" stroke="#3fbaf5" stroke-opacity="0.05" 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"/>
<rect width="${W}" height="${H}" fill="url(#glow2)"/>
<rect x="0" y="0" width="${W}" height="6" fill="url(#bar)"/>
<text x="80" y="150" font-family="monospace" font-size="24" letter-spacing="4"
fill="#5eead4">~/ PLATFORM &amp; INFRASTRUCTURE ENGINEER</text>
fill="#3fbaf5">~/ PLATFORM &amp; 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>
@@ -42,9 +53,9 @@ const svg = `
<text x="80" y="434" font-family="C059, serif" font-style="italic" font-size="35"
fill="#aeb9c9">&#183; GPU-backed edge systems</text>
<text x="80" y="560" font-family="monospace" font-size="20" fill="#8aa0b8">RHEL <tspan fill="#5eead4">&#9656;</tspan> Kubernetes <tspan fill="#5eead4">&#9656;</tspan> GitOps <tspan fill="#5eead4">&#9656;</tspan> Ansible <tspan fill="#5eead4">&#9656;</tspan> Observability <tspan fill="#5eead4">&#9656;</tspan> Edge AI</text>
<text x="80" y="560" font-family="monospace" font-size="20" fill="#8aa0b8">RHEL <tspan fill="#3fbaf5">&#9656;</tspan> Kubernetes <tspan fill="#3fbaf5">&#9656;</tspan> GitOps <tspan fill="#3fbaf5">&#9656;</tspan> Ansible <tspan fill="#3fbaf5">&#9656;</tspan> Observability <tspan fill="#3fbaf5">&#9656;</tspan> Edge AI</text>
<text x="${W - 80}" y="560" text-anchor="end" font-family="monospace" font-size="22"
fill="#5eead4">www.bztmon.com</text>
fill="#e879f9">www.bztmon.com</text>
</svg>`;
await sharp(Buffer.from(svg)).png().toFile(out);
+42
View File
@@ -0,0 +1,42 @@
import { chromium } from "playwright";
import { preview } from "astro";
const server = await preview({ server: { port: 4399 } });
const base = "http://localhost:4399";
const browser = await chromium.launch();
async function shot(name, { theme = "dark", scrollY = 0, selector = null, path = "/", fullPage = false } = {}) {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
await page.addInitScript((t) => {
try { localStorage.setItem("theme", t); } catch {}
}, theme);
await page.goto(base + path, { waitUntil: "networkidle" });
await page.evaluate(
(t) => document.documentElement.setAttribute("data-theme", t),
theme
);
if (scrollY) {
await page.evaluate((y) => window.scrollTo(0, y), scrollY);
await page.waitForTimeout(400);
}
await page.waitForTimeout(2700);
if (selector) {
const el = await page.$(selector);
await el.scrollIntoViewIfNeeded();
await page.waitForTimeout(2700);
await el.screenshot({ path: `/tmp/site-${name}.png` });
} else {
await page.screenshot({ path: `/tmp/site-${name}.png`, fullPage });
}
await page.close();
console.log("wrote /tmp/site-" + name + ".png");
}
await shot("light-hero", { theme: "light" });
await shot("dark-hero", { theme: "dark" });
await browser.close();
await server.stop();
+47
View File
@@ -0,0 +1,47 @@
---
// Circuit divider — gradient hairlines meeting two rotated-square "diamonds"
// (cyan, magenta). Pure CSS, from the design handoff.
---
<div class="divider container" aria-hidden="true">
<div class="divider__line divider__line--in"></div>
<div class="divider__diamond divider__diamond--cyan"></div>
<div class="divider__line divider__line--mid"></div>
<div class="divider__diamond divider__diamond--magenta"></div>
<div class="divider__line divider__line--out"></div>
</div>
<style>
.divider {
display: flex;
align-items: center;
}
.divider__line {
height: 1px;
}
.divider__line--in {
flex: 1;
background: linear-gradient(90deg, transparent, rgba(63, 186, 245, 0.5));
}
.divider__line--mid {
width: 120px;
background: rgba(63, 186, 245, 0.35);
}
.divider__line--out {
flex: 1;
background: linear-gradient(90deg, rgba(232, 121, 249, 0.5), transparent);
}
.divider__diamond {
width: 6px;
height: 6px;
transform: rotate(45deg);
margin: 0 10px;
flex-shrink: 0;
}
.divider__diamond--cyan {
border: 1px solid var(--accent);
}
.divider__diamond--magenta {
border: 1px solid var(--accent-2);
}
</style>
+243
View File
@@ -0,0 +1,243 @@
---
// The fleet constellation — 6 real nodes as IC chips on PCB traces.
// Pure SVG + scoped CSS keyframes (zero JS; reduced-motion turns every animation off).
// Anatomy per the design handoff: each route is TWO parallel traces (offset ±(2,-3)) —
// one carries a cyan packet outbound, the other a magenta packet inbound. Packets are
// stroke-dasharray 9/127 sliding via stroke-dashoffset. Hub = cave (56px chip, radar ring).
---
<svg
class="constellation"
viewBox="0 0 760 640"
role="img"
aria-label="Live topology of the six-node homelab fleet: hub cave reconciling alfred, robin, kate, wgirl and elfastc"
>
<defs>
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="var(--accent)"></stop>
<stop offset="1" stop-color="var(--accent-2)"></stop>
</linearGradient>
</defs>
<!-- ── PCB traces: dual parallel lines per route (out = cyan, back = magenta) ── -->
<!-- alfred -->
<g transform="translate(2,-3)">
<path class="t-glow t-cyan" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
<path class="t-base t-cyan" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
<path class="t-pkt pkt-fwd" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
</g>
<g transform="translate(-2,3)">
<path class="t-glow t-mag" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
<path class="t-base t-mag" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
<path class="t-pkt t-pkt--mag pkt-rev-slow" d="M380 310 H460 L520 250 V180 L572 128 H595"></path>
</g>
<!-- robin -->
<g transform="translate(2,-3)">
<path class="t-glow t-cyan" d="M380 310 H470 L540 380 H602 L640 420"></path>
<path class="t-base t-cyan" d="M380 310 H470 L540 380 H602 L640 420"></path>
<path class="t-pkt pkt-fwd-slow" d="M380 310 H470 L540 380 H602 L640 420"></path>
</g>
<g transform="translate(-2,3)">
<path class="t-glow t-mag" d="M380 310 H470 L540 380 H602 L640 420"></path>
<path class="t-base t-mag" d="M380 310 H470 L540 380 H602 L640 420"></path>
<path class="t-pkt t-pkt--mag pkt-rev" d="M380 310 H470 L540 380 H602 L640 420"></path>
</g>
<!-- kate (standby — dimmer) -->
<g transform="translate(2,-3)" class="standby">
<path class="t-glow t-cyan" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
<path class="t-base t-cyan" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
<path class="t-pkt t-pkt--solid pkt-fwd-slow" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
</g>
<g transform="translate(-2,3)" class="standby">
<path class="t-glow t-mag" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
<path class="t-base t-mag" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
<path class="t-pkt t-pkt--mag t-pkt--solid pkt-rev" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
</g>
<!-- wgirl (standby) -->
<g transform="translate(2,-3)" class="standby">
<path class="t-glow t-cyan" d="M380 310 H310 L244 376 V446 L214 474"></path>
<path class="t-base t-cyan" d="M380 310 H310 L244 376 V446 L214 474"></path>
<path class="t-pkt t-pkt--solid pkt-fwd" d="M380 310 H310 L244 376 V446 L214 474"></path>
</g>
<g transform="translate(-2,3)" class="standby">
<path class="t-glow t-mag" d="M380 310 H310 L244 376 V446 L214 474"></path>
<path class="t-base t-mag" d="M380 310 H310 L244 376 V446 L214 474"></path>
<path class="t-pkt t-pkt--mag t-pkt--solid pkt-rev-slow" d="M380 310 H310 L244 376 V446 L214 474"></path>
</g>
<!-- elfastc (standby) -->
<g transform="translate(3,2)" class="standby">
<path class="t-glow t-cyan" d="M380 310 V400 L452 472 V532"></path>
<path class="t-base t-cyan" d="M380 310 V400 L452 472 V532"></path>
<path class="t-pkt t-pkt--solid pkt-fwd-slow" d="M380 310 V400 L452 472 V532"></path>
</g>
<g transform="translate(-3,-2)" class="standby">
<path class="t-glow t-mag" d="M380 310 V400 L452 472 V532"></path>
<path class="t-base t-mag" d="M380 310 V400 L452 472 V532"></path>
<path class="t-pkt t-pkt--mag t-pkt--solid pkt-rev" d="M380 310 V400 L452 472 V532"></path>
</g>
<!-- vias at trace bends -->
<circle class="via via--cyan" cx="460" cy="310" r="3"></circle>
<circle class="via via--cyan" cx="520" cy="250" r="3"></circle>
<circle class="via via--cyan" cx="520" cy="180" r="3"></circle>
<circle class="via via--mag" cx="470" cy="310" r="3"></circle>
<circle class="via via--mag" cx="540" cy="380" r="3"></circle>
<circle class="via via--dim" cx="300" cy="310" r="3"></circle>
<circle class="via via--dim" cx="240" cy="250" r="3"></circle>
<circle class="via via--dim" cx="310" cy="310" r="3"></circle>
<circle class="via via--dim" cx="244" cy="376" r="3"></circle>
<circle class="via via--dim" cx="380" cy="400" r="3"></circle>
<circle class="via via--dim" cx="452" cy="472" r="3"></circle>
<!-- ── hub: cave ── -->
<circle class="ring" cx="380" cy="310" r="46" fill="none" stroke="url(#lgA)" stroke-width="1.5"></circle>
<circle cx="380" cy="310" r="46" fill="rgba(63,186,245,0.05)" stroke="rgba(63,186,245,0.3)" stroke-width="1"></circle>
<!-- pin stubs -->
<g class="pins-cyan">
<line x1="352" y1="296" x2="340" y2="296"></line><line x1="352" y1="310" x2="340" y2="310"></line>
<line x1="352" y1="324" x2="340" y2="324"></line><line x1="366" y1="282" x2="366" y2="270"></line>
<line x1="380" y1="282" x2="380" y2="270"></line><line x1="366" y1="338" x2="366" y2="350"></line>
<line x1="380" y1="338" x2="380" y2="350"></line>
</g>
<g class="pins-mag">
<line x1="408" y1="296" x2="420" y2="296"></line><line x1="408" y1="310" x2="420" y2="310"></line>
<line x1="408" y1="324" x2="420" y2="324"></line><line x1="394" y1="282" x2="394" y2="270"></line>
<line x1="394" y1="338" x2="394" y2="350"></line>
</g>
<rect class="chip chip--hub" x="352" y="282" width="56" height="56" rx="8" stroke="url(#lgA)"></rect>
<rect x="364" y="294" width="32" height="32" rx="4" fill="none" stroke="rgba(63,186,245,0.4)" stroke-width="1"></rect>
<circle class="core core--cyan pulse" cx="380" cy="310" r="5"></circle>
<rect class="plate plate--hub" x="338" y="352" width="84" height="24" rx="4"></rect>
<text class="lbl lbl--hub" x="380" y="368" text-anchor="middle">cave</text>
<text class="cap cap--hub" x="380" y="392" text-anchor="middle">139 apps · reconciling</text>
<!-- ── awake: alfred ── -->
<circle cx="612" cy="128" r="24" fill="rgba(63,186,245,0.07)"></circle>
<rect class="chip chip--cyan" x="595" y="111" width="34" height="34" rx="5"></rect>
<circle class="core core--cyan pulse" cx="612" cy="128" r="4"></circle>
<rect class="plate plate--cyan" x="576" y="152" width="72" height="20" rx="3"></rect>
<text class="lbl" x="612" y="166" text-anchor="middle">alfred</text>
<!-- ── awake: robin ── -->
<circle cx="656" cy="420" r="24" fill="rgba(232,121,249,0.07)"></circle>
<rect class="chip chip--mag" x="639" y="403" width="34" height="34" rx="5"></rect>
<circle class="core core--mag pulse-slow" cx="656" cy="420" r="4"></circle>
<rect class="plate plate--mag" x="620" y="444" width="72" height="20" rx="3"></rect>
<text class="lbl" x="656" y="458" text-anchor="middle">robin</text>
<!-- ── standby: kate ── -->
<rect class="chip chip--cyan" x="145" y="135" width="30" height="30" rx="5"></rect>
<circle class="core core--cyan pulse-slow" cx="160" cy="150" r="3.5"></circle>
<rect class="plate plate--dim" x="126" y="172" width="68" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="160" y="186" text-anchor="middle">kate</text>
<text class="cap cap--dim" x="160" y="206" text-anchor="middle">standby · 33 apps known</text>
<!-- ── standby: wgirl ── -->
<rect class="chip chip--mag" x="199" y="471" width="30" height="30" rx="5"></rect>
<circle class="core core--mag pulse" cx="214" cy="486" r="3.5"></circle>
<rect class="plate plate--dim" x="180" y="508" width="68" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="214" y="522" text-anchor="middle">wgirl</text>
<!-- ── standby: elfastc ── -->
<rect class="chip chip--cyan" x="437" y="533" width="30" height="30" rx="5"></rect>
<circle class="core core--cyan pulse-slow" cx="452" cy="548" r="3.5"></circle>
<rect class="plate plate--dim" x="412" y="570" width="80" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="452" y="584" text-anchor="middle">elfastc</text>
</svg>
<style>
.constellation {
display: block;
width: 100%;
height: auto;
}
/* trace layers */
.t-glow { fill: none; stroke-width: 7; }
.t-glow.t-cyan { stroke: rgba(63, 186, 245, 0.12); }
.t-glow.t-mag { stroke: rgba(232, 121, 249, 0.1); }
.standby .t-glow.t-cyan { stroke: rgba(63, 186, 245, 0.1); }
.standby .t-glow.t-mag { stroke: rgba(232, 121, 249, 0.08); }
.t-base { fill: none; stroke-width: 2; }
.t-base.t-cyan { stroke: rgba(63, 186, 245, 0.45); }
.t-base.t-mag { stroke: rgba(232, 121, 249, 0.38); }
.standby .t-base.t-cyan { stroke: rgba(63, 186, 245, 0.35); }
.standby .t-base.t-mag { stroke: rgba(232, 121, 249, 0.3); }
/* the moving packets */
.t-pkt {
fill: none;
stroke: url(#lgA);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 9 127;
filter: drop-shadow(0 0 6px rgba(63, 186, 245, 0.9));
}
.t-pkt--mag { filter: drop-shadow(0 0 6px rgba(232, 121, 249, 0.9)); }
.t-pkt--solid { stroke: rgba(63, 186, 245, 0.85); }
.t-pkt--solid.t-pkt--mag { stroke: rgba(232, 121, 249, 0.85); }
.pkt-fwd { animation: bzDash 8s linear infinite; }
.pkt-fwd-slow { animation: bzDash 11s linear infinite; }
.pkt-rev { animation: bzDashRev 9s linear infinite; }
.pkt-rev-slow { animation: bzDashRev 13s linear infinite; }
/* vias */
.via { fill: var(--panel-solid); stroke-width: 1; }
.via--cyan { stroke: rgba(63, 186, 245, 0.6); }
.via--mag { stroke: rgba(232, 121, 249, 0.6); }
.via--dim { stroke: rgba(126, 139, 161, 0.5); }
/* chips + cores + plates + labels */
.chip { fill: var(--panel-solid); }
.chip--hub { stroke-width: 2; filter: drop-shadow(0 0 10px rgba(63, 186, 245, 0.45)); }
.chip--cyan { stroke: var(--accent); stroke-width: 1.5; filter: drop-shadow(0 0 8px rgba(63, 186, 245, 0.55)); }
.chip--mag { stroke: var(--accent-2); stroke-width: 1.5; filter: drop-shadow(0 0 8px rgba(232, 121, 249, 0.55)); }
.core--cyan { fill: var(--accent); filter: drop-shadow(0 0 6px rgba(63, 186, 245, 1)); }
.core--mag { fill: var(--accent-2); filter: drop-shadow(0 0 5px rgba(232, 121, 249, 1)); }
.pulse { animation: bzPulse 2.4s ease-in-out infinite; }
.pulse-slow { animation: bzPulse 3.4s ease-in-out infinite; }
.pins-cyan line { stroke: rgba(63, 186, 245, 0.6); stroke-width: 1.5; }
.pins-mag line { stroke: rgba(232, 121, 249, 0.6); stroke-width: 1.5; }
.plate { fill: rgba(13, 19, 34, 0.92); stroke-width: 1; }
.plate--hub { stroke: rgba(63, 186, 245, 0.4); }
.plate--cyan { stroke: rgba(63, 186, 245, 0.35); }
.plate--mag { stroke: rgba(232, 121, 249, 0.35); }
.plate--dim { stroke: rgba(126, 139, 161, 0.3); }
.lbl {
fill: #e8f2fb;
font-family: var(--font-mono);
font-size: 11px;
}
.lbl--hub { font-size: 12px; font-weight: 700; }
.lbl--dim { fill: #9fb0c8; }
.cap { font-family: var(--font-mono); }
.cap--hub {
fill: var(--accent);
font-size: 9.5px;
filter: drop-shadow(0 0 4px rgba(63, 186, 245, 0.7));
}
.cap--dim { fill: #5c6a82; font-size: 9px; }
/* radar ring off the hub */
.ring {
transform-box: fill-box;
transform-origin: center;
animation: bzRing 3.6s ease-out infinite;
}
@keyframes bzDash { to { stroke-dashoffset: -260; } }
@keyframes bzDashRev { to { stroke-dashoffset: 260; } }
@keyframes bzPulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@keyframes bzRing {
0% { transform: scale(0.5); opacity: 0.9; }
100% { transform: scale(1.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pkt-fwd, .pkt-fwd-slow, .pkt-rev, .pkt-rev-slow, .pulse, .pulse-slow, .ring {
animation: none;
}
.ring { opacity: 0.5; }
}
</style>
+1 -1
View File
@@ -22,7 +22,7 @@ const cap = caption ?? captions[name];
<figure class="diagram" data-reveal>
<img
class="diagram__svg"
src={`/diagrams/${name}.svg`}
src={`/diagrams/${name}.svg?v=2`}
alt={cap ?? `${name} architecture diagram`}
loading="lazy"
decoding="async"
+83
View File
@@ -0,0 +1,83 @@
---
// House-style mini-diagram for a dossier card thumbnail. Keyed by the project's
// `diagram` frontmatter; each motif is a tiny node/line sketch in the board palette.
// Pure SVG, pulsing cores only (reduced-motion turns them off).
interface Props {
kind?: string;
}
const { kind = "generic" } = Astro.props;
---
<svg viewBox="0 0 400 180" aria-hidden="true" class="thumb">
{kind === "edge-ai" ? (
<>
{/* GPU chip feeding a rack of stores */}
<line x1="70" y1="90" x2="330" y2="50" class="ln ln--c" />
<line x1="70" y1="90" x2="330" y2="90" class="ln ln--c" />
<line x1="70" y1="90" x2="330" y2="130" class="ln ln--m" />
<rect x="46" y="66" width="48" height="48" rx="6" class="nd nd--c" />
<text x="70" y="95" text-anchor="middle" class="tx">GPU</text>
<rect x="314" y="36" width="32" height="28" rx="4" class="nd nd--dim" />
<rect x="314" y="76" width="32" height="28" rx="4" class="nd nd--dim" />
<rect x="314" y="116" width="32" height="28" rx="4" class="nd nd--m" />
<circle cx="200" cy="70" r="2.5" class="dot dot--c pulse" />
<circle cx="200" cy="110" r="2.5" class="dot dot--m pulse-slow" />
</>
) : kind === "homelab" ? (
<>
{/* hub and spokes — the fleet */}
<line x1="200" y1="90" x2="90" y2="45" class="ln ln--c" />
<line x1="200" y1="90" x2="90" y2="135" class="ln ln--c" />
<line x1="200" y1="90" x2="310" y2="45" class="ln ln--m" />
<line x1="200" y1="90" x2="310" y2="135" class="ln ln--m" />
<circle cx="200" cy="90" r="24" class="hub" />
<circle cx="200" cy="90" r="3" class="dot dot--c pulse" />
<circle cx="90" cy="45" r="12" class="nd nd--c" />
<circle cx="90" cy="135" r="12" class="nd nd--c" />
<circle cx="310" cy="45" r="12" class="nd nd--m" />
<circle cx="310" cy="135" r="12" class="nd nd--m" />
</>
) : (
<>
{/* pipeline — commits flowing through gates to a fleet */}
<line x1="40" y1="90" x2="360" y2="90" class="ln ln--c" />
<rect x="88" y="70" width="40" height="40" rx="5" class="nd nd--c" />
<rect x="180" y="70" width="40" height="40" rx="5" class="nd nd--dim" />
<rect x="272" y="70" width="40" height="40" rx="5" class="nd nd--m" />
<circle cx="60" cy="90" r="2.5" class="dot dot--c pulse" />
<circle cx="156" cy="90" r="2.5" class="dot dot--c pulse-slow" />
<circle cx="248" cy="90" r="2.5" class="dot dot--m pulse" />
<circle cx="340" cy="90" r="2.5" class="dot dot--m pulse-slow" />
</>
)}
</svg>
<style>
.thumb {
width: 100%;
height: 100%;
display: block;
}
.ln {
stroke-width: 1;
}
.ln--c { stroke: rgba(63, 186, 245, 0.3); }
.ln--m { stroke: rgba(232, 121, 249, 0.3); }
.nd { fill: var(--panel-solid); stroke-width: 1.5; }
.nd--c { stroke: var(--accent); }
.nd--m { stroke: rgba(232, 121, 249, 0.5); stroke-width: 1; }
.nd--dim { stroke: rgba(126, 139, 161, 0.5); stroke-width: 1; }
.hub { fill: rgba(63, 186, 245, 0.06); stroke: var(--accent); stroke-width: 1.5; }
.tx { fill: var(--accent); font-family: var(--font-mono); font-size: 10px; }
.dot--c { fill: var(--accent); }
.dot--m { fill: var(--accent-2); }
.pulse { animation: thumbPulse 2.4s ease-in-out infinite; }
.pulse-slow { animation: thumbPulse 3.4s ease-in-out infinite; }
@keyframes thumbPulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pulse, .pulse-slow { animation: none; }
}
</style>
+42
View File
@@ -1,12 +1,23 @@
---
import { site } from "../data/site";
import { socials } from "../data/socials";
import { buildInfo } from "../lib/build-info";
const year = 2026; // build-stamped; bump via the build, not a runtime Date()
const linkedinHref = socials.find((s) => s.label === "LinkedIn")?.href;
const emailHref = socials.find((s) => s.label === "Email")?.href;
---
<footer class="footer">
<!-- the GitOps receipt — every value baked at build time, zero runtime calls -->
<div class="container">
<p class="footer__receipt mono" data-reveal>
<span class="footer__receipt-label">RECEIPT</span>
<span class="footer__rdot">·</span> HEAD <span class="footer__rval">{buildInfo.sha}</span>
<span class="footer__rdot">·</span> built <span class="footer__rval">{buildInfo.builtAt}</span>
<span class="footer__rdot">·</span> reconciled by <span class="footer__rval">ArgoCD</span>
<span class="footer__rdot">·</span> <span class="footer__rok">✓ Synced · Healthy</span>
</p>
</div>
<div class="container footer__inner">
<div>
<p class="mono footer__name">{site.name}</p>
@@ -62,6 +73,37 @@ const emailHref = socials.find((s) => s.label === "Email")?.href;
margin-top: var(--space-8);
color: var(--text-dim);
}
.footer__receipt {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
border: 1px solid var(--border-accent);
border-radius: var(--radius-btn);
background: var(--panel);
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
padding: 10px 14px;
margin-bottom: var(--space-6);
width: fit-content;
max-width: 100%;
}
.footer__receipt-label {
color: var(--accent);
letter-spacing: 0.14em;
}
.footer__rdot {
opacity: 0.5;
}
.footer__rval {
color: var(--text-dim);
}
.footer__rok {
color: var(--accent);
}
.footer__inner {
display: flex;
flex-wrap: wrap;
+218 -64
View File
@@ -1,10 +1,20 @@
---
import { site } from "../data/site";
import { cvAvailable } from "../lib/assets";
import { fleetFacts } from "../data/fleet";
import Constellation from "./Constellation.astro";
---
<section class="hero">
<!-- hero-scoped atmosphere over the site-wide board art: directional scrims,
a static radial glow, and a 56px grid masked to an ellipse -->
<div class="hero__scrim hero__scrim--x" aria-hidden="true"></div>
<div class="hero__scrim hero__scrim--y" aria-hidden="true"></div>
<div class="hero__glow" aria-hidden="true"></div>
<div class="hero__grid" aria-hidden="true"></div>
<div class="container hero__inner">
<div class="hero__copy">
<p class="eyebrow hero__eyebrow">{site.role}</p>
<h1 class="hero__title">
@@ -23,7 +33,7 @@ import { cvAvailable } from "../lib/assets";
</p>
<div class="hero__cta">
<a class="btn btn--primary" href="/#projects">View Projects</a>
<a class="btn btn--primary" href="/#projects">View Projects</a>
{cvAvailable && (
<a class="btn" href="/cv.pdf" download>
Download CV
@@ -31,61 +41,162 @@ import { cvAvailable } from "../lib/assets";
)}
<a class="btn" href="/#contact">Contact</a>
</div>
<!-- the typing fleet-fact bar: real numbers, cycled by site.js (external, CSP-safe);
with JS off / reduced motion it shows the first fact statically -->
<div
class="hero__fact mono"
id="fleet-fact"
data-facts={JSON.stringify(fleetFacts)}
>
<span class="hero__fact-prompt">~/{site.handle} ▸</span>
<span class="hero__fact-line"
><span id="fleet-fact-text">{fleetFacts[0]}</span><span
class="hero__caret"
aria-hidden="true">▮</span
></span
>
</div>
</div>
<div class="hero__viz" aria-hidden="false">
<Constellation />
</div>
</div>
<div class="hero__scroll mono" aria-hidden="true">
<span class="hero__diamond"></span>
<span>SCROLL — DESCENDING: EDGE / CLUSTER / WORKLOAD / SECURITY</span>
</div>
</section>
<style>
.hero {
position: relative;
min-height: min(88vh, 760px);
min-height: min(92vh, 820px);
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-accent);
overflow: hidden;
}
/* atmosphere layers (scoped to the hero, over the global board art) */
.hero__scrim,
.hero__glow,
.hero__grid {
position: absolute;
inset: 0;
pointer-events: none;
}
.hero__scrim--x {
background: linear-gradient(
90deg,
rgba(9, 12, 20, 0.96) 0%,
rgba(9, 12, 20, 0.88) 42%,
rgba(9, 12, 20, 0.62) 70%,
rgba(9, 12, 20, 0.55) 100%
);
}
.hero__scrim--y {
background: linear-gradient(
180deg,
rgba(9, 12, 20, 0.55) 0%,
transparent 30%,
transparent 70%,
rgba(9, 12, 20, 0.9) 100%
);
}
:global([data-theme="light"]) .hero__scrim--x {
background: linear-gradient(
90deg,
rgba(246, 248, 251, 0.96) 0%,
rgba(246, 248, 251, 0.86) 42%,
rgba(246, 248, 251, 0.6) 70%,
rgba(246, 248, 251, 0.5) 100%
);
}
:global([data-theme="light"]) .hero__scrim--y {
background: linear-gradient(
180deg,
rgba(246, 248, 251, 0.55) 0%,
transparent 30%,
transparent 70%,
rgba(246, 248, 251, 0.92) 100%
);
}
.hero__glow {
background:
radial-gradient(900px 600px at 78% 40%, rgba(232, 121, 249, 0.1), transparent 65%),
radial-gradient(1000px 700px at 15% 25%, rgba(63, 186, 245, 0.12), transparent 60%);
}
.hero__grid {
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 70% at 60% 40%, black 30%, transparent 75%);
}
.hero__inner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 640px) minmax(0, 1fr);
align-items: center;
gap: var(--space-6);
padding-block: var(--space-9);
width: 100%;
}
.hero__copy {
display: flex;
flex-direction: column;
}
.hero__viz {
justify-self: end;
width: min(48vw, 720px);
margin-right: -8%;
}
.hero__eyebrow {
margin-bottom: var(--space-4);
letter-spacing: 0.18em;
text-shadow: 0 0 14px rgba(63, 186, 245, 0.5);
}
.hero__title {
font-family: var(--font-display);
font-size: var(--step-5);
line-height: 1.12;
/* Chakra Petch is geometric/wide — looser tracking than Inter's -0.03em. */
line-height: 1.05;
letter-spacing: -0.005em;
/* Extend the paint box below the baseline so background-clip:text doesn't
crop glyph descenders (the "g" in Wright). */
padding-bottom: 0.14em;
/* Electric kinetic fill: light text with a cyan→magenta band — the same
sweep as the Bat-Computer board — gliding across once on load (heroSheen)
over an electric halo, tying the headline into the backdrop. */
background: linear-gradient(
100deg,
var(--text) 0%,
var(--text) 34%,
var(--accent) 46%,
var(--accent-2) 56%,
var(--text) 66%,
var(--text) 100%
);
background-size: 230% 100%;
background-position: 130% 0;
/* two-line nameplate (mock parity) — wrap at the space, keep the column clear
of the constellation's left-most node labels */
max-width: min-content;
background:
linear-gradient(
105deg,
transparent 0%,
rgba(255, 255, 255, 0.9) 50%,
transparent 100%
),
linear-gradient(100deg, var(--accent) 0%, #7c3aed 50%, var(--accent-2) 100%);
background-size: 220% 100%, 100% 100%;
background-position: 135% 0, 0 0;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 26px var(--accent-glow));
text-shadow: none;
filter: drop-shadow(0 1px 1px rgba(2, 6, 23, 0.22))
drop-shadow(0 6px 22px var(--accent-glow));
}
.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;
max-width: 32rem;
font-size: var(--step-1);
line-height: 1.55;
color: var(--text-dim);
font-weight: 400;
text-wrap: pretty;
}
.hero__tags {
margin: var(--space-5) 0 0;
@@ -95,7 +206,7 @@ import { cvAvailable } from "../lib/assets";
align-items: center;
gap: 0.4rem 0.6rem;
font-size: var(--step--1);
color: var(--text-dim);
color: var(--text-label);
line-height: 1.7;
}
.hero__sep {
@@ -108,48 +219,91 @@ import { cvAvailable } from "../lib/assets";
gap: var(--space-3);
}
/* Staggered entrance — CSS-only (runs with JS off), motion-aware. */
/* the typing bar — glass panel */
.hero__fact {
margin-top: var(--space-6);
display: flex;
align-items: center;
gap: 10px;
padding: 13px 16px;
background: var(--panel);
border: 1px solid var(--border-accent);
border-radius: var(--radius-btn);
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
/* one-liner: the box sizes to its fact (smaller type) and may run a little
past the copy column — there's clear board under it until the viz */
width: max-content;
max-width: min(52rem, calc(100vw - 2 * var(--space-5)));
min-height: 20px;
font-size: 11.5px;
}
.hero__fact-prompt {
color: var(--accent);
flex-shrink: 0;
}
.hero__fact-line {
color: var(--text-body-strong);
white-space: nowrap;
line-height: 1.4;
}
.hero__caret {
color: var(--accent);
animation: bzCaret 1.1s step-end infinite;
}
.hero__scroll {
position: absolute;
left: max(calc((100vw - var(--measure)) / 2), var(--space-5));
bottom: 28px;
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--text-faint);
z-index: 1;
}
.hero__diamond {
width: 8px;
height: 8px;
border: 1px solid var(--accent);
transform: rotate(45deg);
display: block;
}
@keyframes bzCaret {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
.hero__eyebrow,
.hero__title,
.hero__positioning,
.hero__tags,
.hero__cta {
animation: heroIn 0.7s var(--ease) both;
}
/* Entrance + a one-shot electric sheen sweeping across the name. */
.hero__title {
animation:
heroIn 0.7s var(--ease) 0.06s both,
heroSheen 1.6s var(--ease) 0.7s both;
}
.hero__positioning {
animation-delay: 0.14s;
}
.hero__tags {
animation-delay: 0.22s;
}
.hero__cta {
animation-delay: 0.3s;
animation: heroSheen 1.6s var(--ease) 0.7s both;
}
}
@keyframes heroIn {
from {
opacity: 0;
transform: translateY(12px);
@media (prefers-reduced-motion: reduce) {
.hero__caret { animation: none; }
}
to {
opacity: 1;
transform: none;
}
}
/* A bright royal band sweeps left→right across the glyphs, then rests. */
@keyframes heroSheen {
from {
background-position: 130% 0;
from { background-position: 135% 0, 0 0; }
to { background-position: -35% 0, 0 0; }
}
to {
background-position: 0% 0;
/* responsive: constellation folds under, then hides on small screens */
@media (max-width: 1100px) {
.hero__inner {
grid-template-columns: 1fr;
}
.hero__viz {
justify-self: center;
width: min(88vw, 560px);
margin-right: 0;
order: 2;
}
}
@media (max-width: 560px) {
.hero__viz { display: none; }
.hero__fact { font-size: 12px; }
.hero__scroll { display: none; }
}
</style>
+30 -6
View File
@@ -2,12 +2,16 @@
import ThemeToggle from "./ThemeToggle.astro";
import { site } from "../data/site";
// `id` ties a nav link to a homepage section so the Motion layer animates its
// underline as that section scrolls through (scroll-spy). Projects/Blog navigate
// to their own pages on click, but on the homepage they track the #projects and
// #writing sections so every nav item gets the underline — in both directions.
const links = [
{ label: "About", href: "/#about" },
{ label: "Skills", href: "/#skills" },
{ label: "Projects", href: "/projects/" },
{ label: "Blog", href: "/blog/" },
{ label: "Contact", href: "/#contact" },
{ label: "About", href: "/#about", id: "about" },
{ label: "Skills", href: "/#skills", id: "skills" },
{ label: "Projects", href: "/projects/", id: "projects" },
{ label: "Blog", href: "/blog/", id: "writing" },
{ label: "Contact", href: "/#contact", id: "contact" },
];
---
@@ -18,7 +22,11 @@ const links = [
</a>
<nav class="nav__links" aria-label="Primary">
{links.map((l) => <a href={l.href}>{l.label}</a>)}
{links.map((l) => (
<a href={l.href} data-nav-id={l.id}>
{l.label}<span class="nav__ul" aria-hidden="true"></span>
</a>
))}
</nav>
<div class="nav__actions">
@@ -80,12 +88,28 @@ const links = [
font-size: var(--step--1);
}
.nav__links a {
position: relative;
color: var(--text-dim);
}
.nav__links a:hover {
color: var(--text);
text-decoration: none;
}
/* Active-section / hover underline — scaleX scrubbed by the Motion layer
(spring). transform-origin left so it grows from the start of the label. */
.nav__ul {
position: absolute;
left: 0;
right: 0;
bottom: -0.45rem;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 8px var(--accent-glow);
transform: scaleX(0);
transform-origin: 0 50%;
pointer-events: none;
}
.nav__actions {
display: flex;
align-items: center;
+44 -1
View File
@@ -2,6 +2,7 @@
import type { CollectionEntry } from "astro:content";
import { formatDate } from "../lib/blog";
import { readingTime } from "../lib/reading";
import { series } from "../data/series";
interface Props {
posts: CollectionEntry<"blog">[];
@@ -14,7 +15,18 @@ const { posts } = Astro.props;
posts.map((post) => (
<li class="post" data-reveal>
<a class="post__link" href={`/blog/${post.id}/`}>
{post.data.hero && (
<div class="post__thumb">
<img src={`${post.data.hero}?v=2`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
</div>
)}
<div class="post__card-body">
<div class="post__meta mono">
{series[post.id] && (
<span class="post__series">
{series[post.id].name} · {String(series[post.id].number).padStart(2, "0")}
</span>
)}
<time datetime={post.data.date.toISOString()}>{formatDate(post.data.date)}</time>
<span class="post__sep">·</span>
<span>{readingTime(post.body)}</span>
@@ -26,6 +38,7 @@ const { posts } = Astro.props;
<li class="tag">{t}</li>
))}
</ul>
</div>
</a>
</li>
))
@@ -38,17 +51,37 @@ const { posts } = Astro.props;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: var(--space-4);
}
.post__link {
display: block;
padding: var(--space-5);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: inherit;
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease);
}
.post__thumb {
aspect-ratio: 1600 / 1073;
overflow: hidden;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.post__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s var(--ease);
}
.post__link:hover .post__thumb img {
transform: scale(1.03);
}
.post__card-body {
padding: var(--space-5);
}
.post__link:hover {
text-decoration: none;
border-color: var(--accent-line);
@@ -58,8 +91,18 @@ const { posts } = Astro.props;
font-size: 0.72rem;
color: var(--text-faint);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.post__series {
color: var(--accent-2);
border: 1px solid var(--border-magenta);
border-radius: 3px;
padding: 2px 7px;
letter-spacing: 0.1em;
font-size: 0.66rem;
}
.post__sep {
opacity: 0.6;
}
+97 -54
View File
@@ -1,89 +1,132 @@
---
// Dossier card — thumbnail diagram, DOSSIER/00N corner tag, title, "the argument",
// tag chips, mono meta footer. Facelift shape from the design handoff.
import type { CollectionEntry } from "astro:content";
import DossierThumb from "./DossierThumb.astro";
interface Props {
entry: CollectionEntry<"projects">;
/** 1-based position → the DOSSIER / 00N corner tag. */
index?: number;
}
const { entry } = Astro.props;
const { entry, index = 1 } = Astro.props;
const p = entry.data;
const href = `/projects/${entry.id}/`;
const dossierNo = String(index).padStart(3, "0");
---
<a class="project card" href={href} data-reveal>
<div class="project__top">
<h3 class="project__title">{p.title}</h3>
<span class="project__arrow" aria-hidden="true">→</span>
<a class="dossier" href={href} data-reveal>
<div class="dossier__thumb">
<DossierThumb kind={p.diagram} />
<span class="dossier__tag mono">DOSSIER / {dossierNo}</span>
</div>
<p class="project__outcome">{p.outcome}</p>
<p class="project__summary">{p.summary}</p>
<ul class="project__stack">
{p.stack.map((s) => <li class="tag">{s}</li>)}
<div class="dossier__body">
<h3 class="dossier__title">
{p.title} <span class="dossier__arrow" aria-hidden="true">→</span>
</h3>
<p class="dossier__argument">The argument: {p.outcome}</p>
<ul class="dossier__chips mono" role="list">
{p.stack.map((s) => <li class="dchip">{s}</li>)}
</ul>
<p class="project__meta mono">
<span>{p.role}</span><span class="project__sep">·</span><span>{p.period}</span>
</p>
<p class="dossier__meta mono">{p.role} · {p.period}</p>
</div>
</a>
<style>
.project {
padding: var(--space-5);
.dossier {
display: flex;
flex-direction: column;
gap: var(--space-3);
color: inherit;
height: 100%;
}
a.project:hover {
background: var(--panel);
border: 1px solid var(--border-accent);
border-radius: 10px;
overflow: hidden;
text-decoration: none;
color: inherit;
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
transform 180ms var(--ease);
}
.dossier:hover {
text-decoration: none;
border-color: rgba(63, 186, 245, 0.55);
box-shadow: 0 0 32px rgba(63, 186, 245, 0.15);
transform: translateY(-3px);
}
.project__top {
@media (prefers-reduced-motion: reduce) {
.dossier:hover { transform: none; }
}
.dossier__thumb {
height: 180px;
background: #0b101c;
position: relative;
border-bottom: 1px solid rgba(63, 186, 245, 0.12);
}
:global([data-theme="light"]) .dossier__thumb {
background: var(--surface-2);
border-bottom-color: var(--border-accent);
}
.dossier__tag {
position: absolute;
top: 12px;
left: 14px;
font-size: 10px;
letter-spacing: 0.14em;
color: var(--text-faint);
}
.dossier__body {
padding: 24px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
flex-direction: column;
gap: 12px;
flex: 1;
}
.project__title {
font-size: var(--step-1);
.dossier__title {
margin: 0;
font-family: var(--font-display);
font-size: 20px;
font-weight: 600;
color: var(--text-strong);
}
.project__arrow {
.dossier__arrow {
color: var(--accent);
display: inline-block;
transition: transform 0.2s var(--ease);
}
a.project:hover .project__arrow {
.dossier:hover .dossier__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);
.dossier__argument {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
text-wrap: pretty;
}
.project__stack {
.dossier__chips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: auto 0 0;
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;
.dchip {
font-size: 11px;
color: var(--text-secondary);
background: rgba(63, 186, 245, 0.08);
border-radius: 3px;
padding: 4px 8px;
}
.dossier__meta {
margin: 0;
font-size: 11px;
color: var(--text-faint);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.project__sep {
opacity: 0.6;
border-top: 1px solid rgba(63, 186, 245, 0.1);
padding-top: 12px;
}
</style>
+3 -1
View File
@@ -40,7 +40,9 @@ const { id, eyebrow, title, index } = Astro.props;
box-shadow: 0 0 8px var(--accent-glow);
}
.section__index {
color: var(--text-faint);
color: var(--accent);
text-shadow: var(--glow-num);
font-size: 1.05em;
margin-right: 0.6rem;
}
.section__title {
+125 -65
View File
@@ -1,94 +1,154 @@
---
// One bento tile in the capabilities grid — size/flavour driven by the data
// (span: hero 2×2 flagship · wide 2×1 · full row · default 1×1).
import type { SkillGroup } from "../data/skills";
interface Props {
group: SkillGroup;
}
const { group } = Astro.props;
const spanClass =
group.span === "hero"
? "tile--hero"
: group.span === "wide"
? "tile--wide"
: group.span === "full"
? "tile--full"
: "";
---
<article class="skill card" data-reveal>
<h3 class="skill__title">{group.title}</h3>
<p class="skill__blurb">{group.blurb}</p>
<ul class="skill__items">
<article
class:list={["tile", spanClass, group.flavor === "magenta" && "tile--magenta"]}
data-reveal
>
{group.span === "hero" && <div class="tile__corner" aria-hidden="true" />}
{group.flag && <p class="tile__flag mono">{group.flag}</p>}
<h3 class="tile__title">{group.title}</h3>
<p class="tile__blurb">{group.blurb}</p>
<ul class="tile__chips mono" role="list">
{group.items.map((item) => (
<li class="chip"><span class="chip__node" aria-hidden="true"></span>{item}</li>
<li class:list={["chip", group.span === "hero" && "chip--lit"]}>{item}</li>
))}
</ul>
</article>
<style>
.skill {
padding: var(--space-5);
.tile {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: var(--space-3);
gap: 12px;
padding: 28px;
background: var(--panel);
border: 1px solid var(--border-accent);
border-radius: 10px;
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.skill__title {
font-size: var(--step-1);
.tile:hover {
border-color: var(--border-accent-hover);
box-shadow: var(--glow-card);
}
.skill__title::before {
content: "# ";
color: var(--accent);
font-family: var(--font-mono);
.tile--magenta {
border-color: var(--border-magenta);
}
.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.5rem;
.tile--magenta:hover {
border-color: rgba(232, 121, 249, 0.5);
box-shadow: var(--glow-card-magenta);
}
/* Circuit-node chips — a glowing cyan→magenta node + mono label, wired to the
board palette. Gradient border via padding-box/border-box layering so the
radius survives. Lights to a full gradient outline + glow on hover. */
.chip {
--chip-grad: linear-gradient(100deg, var(--accent), var(--accent-2));
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.01em;
.tile--hero {
grid-column: span 2;
grid-row: span 2;
padding: 32px;
gap: 16px;
border-color: rgba(63, 186, 245, 0.2);
}
.tile--wide {
grid-column: span 2;
}
.tile--full {
/* "full" = the row-completer: 3 columns, sharing its row with the 1×1 before it */
grid-column: span 3;
}
.tile__corner {
position: absolute;
inset: 0;
background: radial-gradient(
500px 320px at 85% 100%,
rgba(232, 121, 249, 0.1),
transparent 70%
);
pointer-events: none;
}
.tile__flag {
position: relative;
margin: 0;
font-size: 11px;
letter-spacing: 0.14em;
color: var(--accent-2);
}
.tile__title {
position: relative;
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
color: var(--text-strong);
}
.tile--hero .tile__title {
font-size: 26px;
}
.tile__blurb {
position: relative;
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
text-wrap: pretty;
}
.tile--hero .tile__blurb {
font-size: 15px;
color: var(--text-dim);
padding: 0.3rem 0.62rem 0.3rem 0.55rem;
border-radius: 7px;
border: 1px solid var(--accent-line);
background: color-mix(in srgb, var(--surface-2) 88%, var(--accent) 12%);
transition: color 0.2s var(--ease), transform 0.2s var(--ease),
border-color 0.2s var(--ease), box-shadow 0.2s var(--ease),
background 0.2s var(--ease);
max-width: 420px;
}
/* The node: a small cyan→magenta square that glows, like a lit via/pad. */
.chip__node {
flex: none;
width: 6px;
height: 6px;
border-radius: 2px;
background: var(--chip-grad);
box-shadow: 0 0 7px var(--accent-glow);
.tile__chips {
position: relative;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: auto 0 0;
padding: 0;
font-size: 11.5px;
color: var(--text-dim);
}
.chip:hover {
color: var(--text);
transform: translateY(-1px);
border-color: transparent;
/* dark fill (padding-box) inside a live gradient border (border-box) */
background: linear-gradient(var(--surface-2), var(--surface-2)) padding-box,
var(--chip-grad) border-box;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32), 0 0 16px var(--accent-glow);
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
border: 1px solid rgba(126, 139, 161, 0.25);
border-radius: var(--radius-chip);
padding: 4px 9px;
}
.chip:hover {
transform: none;
.chip--lit {
font-size: 12px;
color: var(--text-body-strong);
border-color: rgba(63, 186, 245, 0.3);
padding: 5px 10px;
}
/* bento collapses gracefully on narrow screens (grid goes 2-col then 1-col
in the parent; spans clamp automatically via grid auto-placement) */
@media (max-width: 720px) {
.tile--hero,
.tile--wide,
.tile--full {
grid-column: 1 / -1;
grid-row: auto;
}
}
</style>
+3
View File
@@ -33,6 +33,9 @@ const blog = defineCollection({
summary: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
// Optional hero image — a /blog/<slug>.webp in public/. Rendered on the post + as the card thumbnail.
hero: z.string().optional(),
heroAlt: z.string().optional(),
}),
});
+4 -1
View File
@@ -4,6 +4,9 @@ date: 2026-06-23
summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen — it's about where your trust boundary lives, and how it fails."
tags: ["authentik", "oidc", "sso", "security", "kubernetes"]
draft: false
hero: "/blog/authentik-sso-2fa.webp"
heroAlt: "A single central gate ringed by a second concentric seal radiates beams of light to a circle of app-doors inside a glowing perimeter."
---
Most write-ups treat single sign-on as a feature you bolt on: stand up an identity provider, point the
@@ -13,7 +16,7 @@ and you've hardened the whole estate at once. Get it wrong and you've built a si
Here's how I think about it after wiring an OIDC provider in front of a self-hosted fleet.
![Authentik SSO flow — the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg)
![Authentik SSO flow — the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg?v=2)
## Enforce the second factor at the flow, not per app
@@ -3,6 +3,9 @@ title: "Init-gating GPU readiness on Kubernetes"
date: 2026-06-10
summary: "The single highest-leverage reliability fix for edge GPU workloads: never let an inference pod schedule before the GPU is actually ready."
tags: ["kubernetes", "gpu", "edge", "reliability"]
hero: "/blog/init-gating-gpu-readiness.webp"
heroAlt: "A GPU glows behind a sealed checkpoint gate while a waiting pod-orb is held in front; a readiness beacon shifts from red to green."
---
The most common way a GPU workload fails at the edge isn't the model, the driver, or the
+4 -1
View File
@@ -4,6 +4,9 @@ date: 2026-06-23
summary: "Exposing self-hosted services to the internet with zero open ports, zero port-forwarding, and the origin's IP never leaving the building."
tags: ["cloudflare", "networking", "security", "zero-trust", "kubernetes"]
draft: false
hero: "/blog/outbound-only-exposure.webp"
heroAlt: "A smooth sealed dark monolith with no doors; a single thin thread of light arcs out from inside it to a distant glowing cloud-edge node."
---
The default way to put a home service on the internet is to forward a port: poke a hole in the router, map
@@ -12,7 +15,7 @@ that's online whether or not anyone's knocking.
There's a better shape: don't open anything. Let the origin dial *out*.
![Outbound-only exposure — public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg)
![Outbound-only exposure — public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg?v=2)
## The tunnel dials out
+75
View File
@@ -0,0 +1,75 @@
---
title: "The first secret is the one you can't commit"
date: 2026-06-28
summary: "Secrets management has a bootstrap paradox: the credential that pulls every other secret can't itself live in git. Here's how a homelab fleet breaks the cycle — zero plaintext secrets in any repo, and a clean rule for which is the one exception."
tags: ["security", "gitops", "secrets", "kubernetes", "external-secrets"]
draft: false
hero: "/blog/secret-zero.webp"
heroAlt: "A single glowing seed-shaped key at the base of a dark circuit-board tree whose traces branch upward into hundreds of faint nodes, all drawing their glow from that one source."
---
Every "we do GitOps properly" story has a chicken-and-egg problem hiding in the first paragraph, and most
posts quietly skip it.
The pitch is clean: secrets never live in git. Instead, the cluster runs the [External Secrets
Operator](https://external-secrets.io) (ESO), which reads from a real secret store — here, a self-hosted
[Infisical](https://infisical.com) — and materialises a Kubernetes `Secret` for each app. Your repo only ever
contains an `ExternalSecret` manifest: a **pointer** ("give `immich` the value at key `IMMICH_DB_PASSWORD`"),
never a value. Beautiful. Auditable. Diff-able.
Except — ESO has to authenticate to Infisical somehow. That's a credential. Where does *it* live?
## Secret zero
You can't store the bootstrap credential in git (that's the whole point), and you can't fetch it from the
secret store (you need it *to reach* the secret store). This is **secret zero**: the one credential that the
entire chain hangs off, that has to be injected from outside the GitOps loop.
So you treat it as exactly that — special, minimal, and out-of-band:
- It's a **machine identity** scoped to *read-only*, and scoped per cluster. cave's ESO can't read alfred's
secrets and vice-versa. A leak is blast-radius-limited to one cluster's read path.
- It's applied **once, by hand** (or by a sealed bootstrap step), never committed. Everything downstream of it
is declarative.
- After it lands, a single `ClusterSecretStore` object — which *is* in git, because it's just a pointer to the
store plus a reference to secret zero — wires the whole cluster up.
From there the dam breaks in the good way: every app's `ExternalSecret` resolves through that store, ESO keeps
the `Secret` in sync, and your repo stays a map of *names*, not values.
<!-- DIAGRAM: secret-zero bootstrap chain — out-of-band identity → ClusterSecretStore → ExternalSecrets → app Secrets -->
![Secret-zero bootstrap chain](/diagrams/secret-zero.svg?v=1)
## The discipline around the one exception
One out-of-band credential is fine. The trap is letting it sprawl — or fumbling its rotation. Two rules earn
their keep:
**Per-cluster, least-privilege identities.** It's tempting to mint one powerful identity and reuse it
everywhere. Don't. A read-only identity per cluster means the bootstrap secret can *read* and nothing else, in
*one* place. The write path (provisioning new secrets) is a separate, rarely-used identity that doesn't sit on
every node.
**Never revoke a shared identity until every consumer has moved off it.** This sounds obvious and is the
single easiest way to take the fleet down. When you split a shared identity into per-cluster ones, the old one
stays valid until you've *verified* each cluster is happily authenticating on its own — then, and only then,
you revoke. Revoke-first-verify-later turns a tidy-up into an outage.
## What still belongs in git (encrypted)
A purist would stop here, but reality has a few things that genuinely need to live *in* the repo — a value a
bootstrap step reads before ESO is even running. For those, the answer isn't "commit it in plaintext and feel
bad," it's **SOPS + age**: the value is encrypted in git, decryptable only by a key that lives on the
operators' machines (and the cluster), never in the repo. Same principle as secret zero — the *decryption* key
is the out-of-band thing — applied to the handful of values that can't wait for the operator to spin up.
## The shape of it
The win isn't any one tool. It's the shape: **exactly one** credential lives outside GitOps, it's read-only
and per-cluster, and it's the seed the whole tree grows from. Everything else is a pointer you can show
your worst enemy. When someone asks "where are your secrets?", the honest answer is "in the store — the repo
just knows their *names*," and that one sentence is the whole security model.
*This runs across a five-cluster Talos homelab; the ESO + Infisical wiring, the per-cluster read-only
identities, and the revoke-order discipline are the load-bearing pieces.*
+3
View File
@@ -3,6 +3,9 @@ title: "Shipping this site: GitOps from a homelab to the public internet"
date: 2026-06-15
summary: "How this portfolio is built and served — Astro to a container image, a self-hosted Gitea registry, ArgoCD, and a Cloudflare Tunnel — with security as acceptance criteria, not polish."
tags: ["gitops", "astro", "homelab", "security"]
hero: "/blog/shipping-this-site.webp"
heroAlt: "A glowing data container travels a luminous rail from a small server rack toward a softly glowing globe of Earth, wrapped by a reconcile loop."
---
This site is a static Astro build, but how it gets to you is the interesting part. It's
+4 -1
View File
@@ -4,6 +4,9 @@ date: 2026-06-23
summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role — it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top."
tags: ["kubernetes", "security", "least-privilege", "rbac", "service-accounts"]
draft: false
hero: "/blog/workload-least-privilege.webp"
heroAlt: "Rows of glowing cube-pods each clutch a keycard, facing a sealed vault door none of them open; one keycard dissolves into particles, revoked."
---
Open a shell in almost any Kubernetes pod and look in `/var/run/secrets/kubernetes.io`. There's a token
@@ -14,7 +17,7 @@ a DNS sinkhole, a search proxy — the answer is no. Never. Not once.
So every one of those pods is holding a key to a door it never opens. That's not least privilege. That's a
standing credential waiting to be stolen.
![Workload least privilege — each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg)
![Workload least privilege — each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg?v=2)
## Identity first, RBAC second
@@ -1,10 +1,10 @@
---
title: "Global Infrastructure Modernisation"
outcome: "Modernised enterprise infrastructure at scale — ~1,000 VMs, segmented networks, multi-region cloud migration."
summary: "Across global IT roles: a ~1,000-VM VMware estate, flat-to-segmented network redesign with SD-WAN and Aruba ClearPass, firewall upgrades, and migration to Azure and Microsoft 365."
role: "Infrastructure Engineer · Virtus Health / Linde"
outcome: "Modernised a global, multi-region estate at scale — ~1,000 VMs, flat-to-segmented networks, and a migration to Azure and Microsoft 365 — on a live 24/7 business."
summary: "Across global IT roles at Virtus Health and Linde Asia Pacific: a ~1,000-VM VMware estate managed centrally, a flat-to-segmented network redesign with SD-WAN and Aruba ClearPass, Palo Alto / FortiGate firewall redesigns, and migration to Azure (Blob, AVS) and Microsoft 365."
role: "Infrastructure Engineer · Virtus Health & Linde Asia Pacific"
period: "2019 2025"
stack: ["VMware", "Azure", "SD-WAN", "Aruba ClearPass", "FortiGate", "Microsoft 365"]
stack: ["VMware / vSphere", "Azure (Blob, AVS)", "Microsoft 365", "SD-WAN", "Aruba ClearPass", "Palo Alto / FortiGate", "Veeam"]
featured: false
order: 40
---
@@ -12,23 +12,29 @@ order: 40
## Problem
Enterprise estates accrete. Flat networks, sprawling VM counts, aging firewalls, and
on-prem-only services become a security and operations drag. The work: modernise without
breaking a global business that runs 24/7.
on-prem-only services become a security and operations drag. The work: modernise a global,
multi-region business that runs 24/7 — without breaking it.
## Constraints
- **Keep the lights on** — change a live, multi-region estate without downtime.
- **Security and compliance** — segmentation, patching, and auditability throughout.
- **Cost-aware** — modernise to cloud where it pays, not for its own sake.
- **Cost-aware** — modernise to cloud where it pays, justified through CapEx/OpEx cases.
## Design
Across global roles I ran and improved a **~1,000-VM VMware estate** and re-segmented **flat
sites into isolated VLAN ranges**, layering in **SD-WAN** and **Aruba ClearPass** onboarding
for a tiered, authenticated network. **Palo Alto / FortiGate** firewalls were upgraded and
redesigned around the new segmentation. Workloads and identity moved to **Azure** (Blob, AVS)
and **Microsoft 365** — including an ERP hardware refresh with a new DR solution, and a
region-wide PBX-to-VoIP migration.
Across global roles I ran and improved a **~1,000-VM VMware estate**, managed centrally for
the IT team and operated across regions including the UK. I re-segmented **flat sites into
isolated VLAN ranges** with ACLs, layering in **SD-WAN** and **Aruba ClearPass** with 802.1x
onboarding for a tiered, authenticated network. **Palo Alto / FortiGate** firewalls were
upgraded and redesigned around the new segmentation — RCA, staging through FortiManager, and
a flat-to-segmented redesign.
On the platform side: workloads and identity moved to **Azure** (Blob storage, AVS — lifting
existing vSphere environments) and **Microsoft 365**, with a **hybrid AD sync** I architected
to bridge on-prem and cloud identity. The estate work also covered an **ERP hardware refresh
with a new DR / mainframe solution**, file shares to Azure Blob over Kerberos auth, **Veeam**
backups, and a region-wide **PBX-to-VoIP** migration (RingCentral).
## Security & reliability decisions
@@ -0,0 +1,64 @@
---
title: "Network Automation at Fleet Scale"
outcome: "Took a multi-region switch fleet from hand-managed to single-pane — automated config backup, bulk change in minutes, and credentials secured and rotated."
summary: "Productionised a vendor-agnostic network config-management platform (Unimus) with NetBox as the source-of-truth across an AU/NZ/PNG switch fleet: scheduled config backups, mass config push, and a centrally vaulted, rotated credential model — community tooling stood up ground-up under a corporate-supported licence."
role: "Global IT Infrastructure Engineer · Linde Asia Pacific"
period: "2019 2022"
stack: ["Unimus (NCM)", "NetBox (IPAM / SoT)", "Config backup & DR", "Bulk config push", "Credential vaulting + rotation", "Multi-vendor switching"]
featured: false
order: 45
links:
- label: "Unimus"
href: "https://unimus.net"
- label: "NetBox"
href: "https://netbox.dev"
---
## Problem
A multi-region switch fleet — Australia, New Zealand and PNG — was maintained by hand.
Configs drifted, backups were "hope someone saved the last one," bulk changes were pushed
box-by-box, and device credentials were scattered across people and scripts. For a network
that production depends on, there was no real recovery path and no audit trail.
## Constraints
- **Live production network** — change the fleet without taking sites down.
- **Mixed-vendor estate** — the tooling had to be vendor-agnostic, not tied to one OS.
- **Credentials are the crown jewels** — secured, rotated, and never sitting in a script.
- **Cost-justified** — a lean tool that earns its place through a real CapEx/OpEx case.
## Design
I productionised **Unimus** — a vendor-agnostic Network Configuration Management platform —
as the automation backbone, and stood it up **ground-up**: install, device onboarding,
backup scheduling, and change workflows. It gave the fleet three things it never had from one
place: **automated, scheduled config backups**, **change tracking with config diffs**, and
**mass / bulk config push** across many devices at once.
Paired with it, **NetBox** served as the **IPAM and source-of-truth** for the device
inventory the automation runs against — so the fleet is driven from a single, accurate model
of what exists rather than tribal knowledge. Both are lean, community-rooted tools adopted
under a **corporate-supported licence**, which made them viable inside an enterprise change
process — the open-source-into-corporate pattern, done properly.
## Security & reliability decisions
- **Centralised, vaulted credentials with rotation** — the network's secrets live in one
scoped, rotated place, never embedded in scripts or shared by hand.
- **Config backup = real network DR** — every device restorable from a known-good config.
- **Diffs + auditing** — every configuration change is visible, attributable, and reversible.
- **Source-of-truth driven** — automation acts on NetBox's model, not on guesswork.
## Outcome
The switch fleet across AU/NZ/PNG was maintained from a single pane: configs backed up
automatically, bulk changes pushed in minutes instead of days, credentials secured and
rotated, and — for the first time — a genuine recovery path for network configuration. A lean
community toolset, productionised with enterprise rigour.
## Future improvements
Drive the automation deeper from the source-of-truth — intent in NetBox, pushed to the fleet
through Unimus — and template standard site configs so a new location onboards straight from
the inventory.
+20
View File
@@ -0,0 +1,20 @@
// The fleet, as data — drives the hero constellation + typing facts.
// REAL numbers, captured 2026-07-03; refresh from the fleet at rebuild
// (kubectl get applications -n argocd | wc -l · kubectl get cnp -A | wc -l per cluster).
// Facts the hero typing bar cycles through. Real, verbatim — no invented numbers.
export const fleetFacts = [
"6 single-node Talos clusters · 0 SSH daemons · every machine is a document",
"139 ArgoCD Applications reconciled from one hub",
"170+ CiliumNetworkPolicies fleet-wide — every namespace starts at default-deny",
"zero open inbound ports — the origin dials out",
"*.bztmon.org — a Let's Encrypt wildcard per cluster · DNS-01 · auto-renewed",
"~140 pods running right now, reconciled by git",
] as const;
// Node roster for the constellation (positions live in the SVG component).
export const fleetNodes = {
hub: { name: "cave", caption: "139 apps · reconciling" },
satellites: ["alfred", "robin", "kate", "wgirl", "elfastc"],
standbyCaption: "standby · 33 apps known", // kate
} as const;
+7
View File
@@ -0,0 +1,7 @@
// Post series — numbered badges on blog cards. Keyed by post slug.
// The security series threads the zero-trust arc across posts as they publish.
export const series: Record<string, { name: string; number: number }> = {
"secret-zero": { name: "SECURITY SERIES", number: 1 },
"workload-least-privilege": { name: "SECURITY SERIES", number: 3 },
// broker-pattern → 02 when it publishes (currently a draft in the workshop)
};
+1 -1
View File
@@ -26,7 +26,7 @@ export const site = {
url: "https://www.bztmon.com",
// Public contact (matches the CV).
email: "jonnywright2011@live.com.au",
ogImage: "/og.png",
ogImage: "/og.png?v=2",
locale: "en",
// Content license for blog posts + pages. Edit here to relicense site-wide.
license: {
+33 -22
View File
@@ -6,24 +6,33 @@ export type SkillGroup = {
// Short framing line for the group.
blurb: string;
items: string[];
/** Bento tile size — "hero" = 2×2 flagship · "wide" = 2×1 · "full" = whole row · default 1×1. */
span?: "hero" | "wide" | "full";
/** Accent flavour — "magenta" marks security-flavoured tiles. */
flavor?: "magenta";
/** Eyebrow label rendered on the hero tile. */
flag?: string;
};
export const skills: SkillGroup[] = [
{
title: "Cloud & Identity",
blurb: "Hybrid estates across the major clouds, with identity done properly.",
title: "Edge AI & GPU",
span: "hero",
flag: "FLAGSHIP",
blurb: "Serving vision and language models on real hardware at the edge.",
items: [
"Azure",
"Entra ID",
"Intune",
"AWS",
"GCP",
"Microsoft 365",
"Azure Blob / S3",
"NVIDIA GPU Operator",
"GPU passthrough (ESXi, as code)",
"GPU readiness probing & watchdogs",
"Pod lifecycle management",
"YOLO / computer-vision inference",
"Local LLM serving (llama.cpp)",
"Edge Kubernetes",
],
},
{
title: "Platform & Virtualization",
span: "wide",
blurb: "Kubernetes platforms designed to be reasoned about and recovered.",
items: [
"Kubernetes",
@@ -51,6 +60,7 @@ export const skills: SkillGroup[] = [
},
{
title: "Networking & Security",
flavor: "magenta",
blurb: "Default-deny, segmentation, and a small attack surface.",
items: [
"SD-WAN",
@@ -62,8 +72,22 @@ export const skills: SkillGroup[] = [
"RBAC · 2FA / SSO",
],
},
{
title: "Cloud & Identity",
blurb: "Hybrid estates across the major clouds, with identity done properly.",
items: [
"Azure",
"Entra ID",
"Intune",
"AWS",
"GCP",
"Microsoft 365",
"Azure Blob / S3",
],
},
{
title: "Observability & Ops",
span: "full",
blurb: "Knowing the system is healthy — and the GPUs with it.",
items: [
"Prometheus",
@@ -75,17 +99,4 @@ export const skills: SkillGroup[] = [
"ITIL processes",
],
},
{
title: "Edge AI & GPU",
blurb: "Serving vision and language models on real hardware at the edge.",
items: [
"NVIDIA GPU Operator",
"GPU passthrough (ESXi, as code)",
"GPU readiness probing & watchdogs",
"Pod lifecycle management",
"YOLO / computer-vision inference",
"Local LLM serving (llama.cpp)",
"Edge Kubernetes",
],
},
];
+7 -1
View File
@@ -27,7 +27,7 @@ const ogImage = new URL(site.ogImage, site.url).href;
<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="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
<link rel="canonical" href={canonical} />
<title>{pageTitle}</title>
@@ -70,5 +70,11 @@ const ogImage = new URL(site.ogImage, site.url).href;
<slot />
</main>
<Footer />
<!-- Motion (motion.dev) animation layer. NOT is:inline → Astro/Vite bundles
it to a self-hosted /_astro/[hash].js (has src=) → strict CSP holds. -->
<script>
import "../scripts/anim.ts";
</script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
// Build-time GitOps receipt data — captured ONCE at build (SSG), zero runtime calls.
// The footer renders these as the "receipt" line; values are baked into the static HTML.
import { execSync } from "node:child_process";
function git(cmd: string): string {
try {
return execSync(cmd, { encoding: "utf8" }).trim();
} catch {
return "unknown";
}
}
export const buildInfo = {
/** Short commit sha of the source HEAD this build was cut from. */
sha: git("git rev-parse --short HEAD"),
/** ISO date (UTC, minute precision) the build ran. */
builtAt: new Date().toISOString().slice(0, 16).replace("T", " ") + "Z",
} as const;
+27
View File
@@ -33,6 +33,19 @@ const { Content } = await render(entry);
</ul>
</header>
{entry.data.hero && (
<figure class="post__hero" data-reveal>
<img
src={`${entry.data.hero}?v=2`}
alt={entry.data.heroAlt ?? entry.data.title}
width="1600"
height="1073"
loading="eager"
decoding="async"
/>
</figure>
)}
<div class="prose post__body" data-reveal>
<Content />
</div>
@@ -45,6 +58,20 @@ const { Content } = await render(entry);
max-width: 48rem;
padding-block: var(--space-7);
}
.post__hero {
margin: var(--space-6) 0 var(--space-7);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
box-shadow: var(--shadow), 0 0 30px var(--accent-glow);
background: var(--surface);
}
.post__hero img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1600 / 1073;
}
.post__back {
display: inline-block;
font-size: var(--step--1);
+164 -3
View File
@@ -2,14 +2,22 @@
import Layout from "../../layouts/Layout.astro";
import Section from "../../components/Section.astro";
import PostList from "../../components/PostList.astro";
import { getPosts, allTags } from "../../lib/blog";
import { getPosts, allTags, formatDate } from "../../lib/blog";
import { readingTime } from "../../lib/reading";
import { series } from "../../data/series";
import { site } from "../../data/site";
const posts = await getPosts();
const tags = allTags(posts);
// Featured = the security-series opener when present, else the newest post.
const featured = posts.find((p) => p.id === "secret-zero") ?? posts[0];
const rest = posts.filter((p) => p.id !== featured.id);
const featuredSeries = series[featured.id];
---
<Layout title="Blog" path="/blog" description="Notes on platform engineering, edge Kubernetes, GPUs and homelab infrastructure.">
<Section id="blog" eyebrow="Writing" index="*" title="Notes from the platform.">
<Section id="blog" eyebrow={`~/${site.handle}/blog`} index="*" title="Notes from the build.">
<p class="lead blog__intro" data-reveal>
Lessons from edge Kubernetes, GPUs, and running infrastructure like it matters.
</p>
@@ -23,7 +31,44 @@ const tags = allTags(posts);
</nav>
)}
<PostList posts={posts} />
{featured && (
<a class="featured" href={`/blog/${featured.id}/`} data-reveal>
<div class="featured__body">
<div class="featured__meta mono">
{featuredSeries && (
<span class="featured__series">
{featuredSeries.name} · {String(featuredSeries.number).padStart(2, "0")}
</span>
)}
<time datetime={featured.data.date.toISOString()}>{formatDate(featured.data.date)}</time>
<span class="featured__sep">·</span>
<span>{readingTime(featured.body)}</span>
</div>
<h3 class="featured__title">{featured.data.title}</h3>
<p class="featured__summary">{featured.data.summary}</p>
<ul class="featured__tags" role="list">
{featured.data.tags.map((t) => (
<li class="tag">{t}</li>
))}
</ul>
<span class="featured__read mono">Read the post →</span>
</div>
{featured.data.hero && (
<div class="featured__art" aria-hidden="true">
<img
src={`${featured.data.hero}?v=2`}
alt=""
width="1600"
height="1073"
loading="eager"
decoding="async"
/>
</div>
)}
</a>
)}
<PostList posts={rest} />
</Section>
</Layout>
@@ -49,4 +94,120 @@ const tags = allTags(posts);
color: var(--text);
text-decoration: none;
}
/* featured card — text left, hero art right, scrimmed into the panel */
.featured {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
background: var(--panel);
border: 1px solid var(--border-accent);
border-radius: 12px;
overflow: hidden;
color: inherit;
text-decoration: none;
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
margin-bottom: var(--space-6);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.featured:hover {
text-decoration: none;
border-color: var(--border-accent-hover);
box-shadow: var(--glow-card);
}
.featured__body {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
position: relative;
z-index: 1;
}
.featured__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.72rem;
color: var(--text-faint);
}
.featured__series {
color: var(--accent-2);
border: 1px solid var(--border-magenta);
border-radius: 3px;
padding: 2px 7px;
letter-spacing: 0.1em;
font-size: 0.66rem;
}
.featured__sep {
opacity: 0.6;
}
.featured__title {
margin: 0;
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 600;
color: var(--text-strong);
letter-spacing: -0.01em;
}
.featured__summary {
margin: 0;
color: var(--text-dim);
font-size: var(--step-0);
line-height: 1.65;
text-wrap: pretty;
}
.featured__tags {
list-style: none;
padding: 0;
margin: var(--space-2) 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.featured__read {
margin-top: auto;
padding-top: var(--space-3);
font-size: 0.8rem;
color: var(--accent);
}
.featured__art {
position: relative;
min-height: 280px;
}
.featured__art img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* scrim the art into the panel from the text side */
.featured__art::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(90deg, var(--panel-solid) 0%, transparent 45%);
}
:global([data-theme="light"]) .featured__art::before {
background: linear-gradient(90deg, var(--panel-solid) 0%, transparent 45%);
opacity: 0.85;
}
@media (max-width: 860px) {
.featured {
grid-template-columns: 1fr;
}
.featured__art {
order: -1;
min-height: 200px;
}
.featured__art::before {
background: linear-gradient(180deg, transparent 55%, var(--panel-solid) 100%);
}
}
</style>
+60 -3
View File
@@ -1,22 +1,30 @@
---
import Layout from "../layouts/Layout.astro";
import Hero from "../components/Hero.astro";
import CircuitDivider from "../components/CircuitDivider.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 PostList from "../components/PostList.astro";
import Contact from "../components/Contact.astro";
import { skills } from "../data/skills";
import { getPosts, allTags } from "../lib/blog";
import { getCollection } from "astro:content";
const featuredProjects = (await getCollection("projects", (p) => p.data.featured)).sort(
(a, b) => a.data.order - b.data.order,
);
const allPosts = await getPosts();
const recentPosts = allPosts.slice(0, 3);
const postTags = allTags(allPosts);
---
<Layout path="/">
<Hero />
<CircuitDivider />
<Section id="about" eyebrow="About" index="01" title="Reliability you can reason about.">
<About />
</Section>
@@ -29,14 +37,33 @@ const featuredProjects = (await getCollection("projects", (p) => p.data.featured
<Section id="projects" eyebrow="Selected work" index="03" title="Platforms built to keep working unattended.">
<div class="grid grid--projects">
{featuredProjects.map((entry) => <ProjectCard entry={entry} />)}
{featuredProjects.map((entry, i) => <ProjectCard entry={entry} index={i + 1} />)}
</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.">
<Section id="writing" eyebrow="Writing" index="04" title="Notes from the build.">
<p class="writing__intro" data-reveal>
Lessons from edge Kubernetes, GPUs, and running infrastructure like it matters —
written up as I go.
</p>
{postTags.length > 0 && (
<nav class="writing__tags" aria-label="Browse the blog by tag" data-reveal>
<span class="mono writing__tags-label">tags:</span>
{postTags.map((t) => (
<a class="tag" href={`/blog/tags/${t}/`}>{t}</a>
))}
</nav>
)}
<PostList posts={recentPosts} />
<p class="projects__more" data-reveal>
<a class="btn" href="/blog/">Read the blog →</a>
</p>
</Section>
<Section id="contact" eyebrow="Get in touch" index="05" title="Let's talk infrastructure.">
<Contact />
</Section>
</Layout>
@@ -46,14 +73,44 @@ const featuredProjects = (await getCollection("projects", (p) => p.data.featured
display: grid;
gap: var(--space-5);
}
/* capabilities bento: 4 columns, hero tile spans 2×2, min row height per the spec */
.grid--skills {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: minmax(190px, auto);
gap: 16px;
}
@media (max-width: 1000px) {
.grid--skills {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.grid--skills {
grid-template-columns: 1fr;
}
}
.grid--projects {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
gap: 20px;
}
.projects__more {
margin-top: var(--space-6);
text-align: center;
}
.writing__intro {
max-width: 52ch;
margin-bottom: var(--space-4);
color: var(--text-dim);
}
.writing__tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: var(--space-6);
}
.writing__tags-label {
font-size: var(--step--1);
color: var(--text-faint);
}
</style>
+5 -4
View File
@@ -10,13 +10,14 @@ const projects = (await getCollection("projects")).sort(
---
<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.">
<Section id="all-projects" eyebrow="Projects" index="*" title="Some bytes I'm proud of — a starting line, not a trophy shelf.">
<p class="lead projects__intro" data-reveal>
Edge Kubernetes, GPU inference, self-hosted AI, and the automation that ties it
together — each with the problem, the design, and the outcome.
Edge Kubernetes, GPU inference, self-hosted AI, productionised network automation, and
the infrastructure rigour that ties it together — each with the problem, the design, and
the outcome. More always cooking.
</p>
<div class="grid">
{projects.map((entry) => <ProjectCard entry={entry} />)}
{projects.map((entry, i) => <ProjectCard entry={entry} index={i + 1} />)}
</div>
</Section>
</Layout>
+174
View File
@@ -0,0 +1,174 @@
/* Motion (motion.dev) — the site's unified animation layer.
*
* Imported by an Astro-PROCESSED <script> in Layout.astro, so Vite bundles it
* into a self-hosted /_astro/[hash].js → stays within CSP `script-src 'self'`
* (no inline, no CDN). Motion sets styles via JS (WAAPI / element.style), which
* is allowed under `style-src` — so no `unsafe-inline` is introduced.
*
* Progressive enhancement: every animated element is VISIBLE with JS off. The
* pre-paint head script (site.js) adds `html.js`; only THEN does CSS hide the
* pre-reveal state (see global.css). Reduced-motion: we no-op and let the CSS
* reduced-motion rules show everything immediately.
*/
import { animate, inView, scroll, stagger } from "motion";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// easeOutExpo-ish — confident, decelerating; good for entrances/reveals.
const EASE = [0.16, 1, 0.3, 1] as const;
if (!reduce) {
// Fail open: if anything in the Motion layer throws, drop `html.js` so the CSS
// pre-reveal hide releases and ALL content shows. A broken animation must
// never leave the page blank.
try {
run();
} catch {
document.documentElement.classList.remove("js");
}
}
function run(): void {
/* 1. Hero — staggered fade-up on load. */
const heroEls = document.querySelectorAll<HTMLElement>(".hero__inner > *");
if (heroEls.length) {
animate(
heroEls,
{ opacity: [0, 1], y: [14, 0] },
{ duration: 0.6, delay: stagger(0.08), ease: EASE },
);
}
/* 2. Scroll reveals — section heads, skill cards (+ staggered chips), CTAs.
* inView fires as each [data-reveal] enters; re-fires on re-entry. */
inView(
"[data-reveal]",
(el) => {
animate(el, { opacity: [0, 1], y: [16, 0] }, { duration: 0.55, ease: EASE });
const chips = el.querySelectorAll<HTMLElement>(".chip");
if (chips.length) {
animate(
chips,
{ opacity: [0, 1], y: [8, 0] },
{ duration: 0.42, delay: stagger(0.04, { startDelay: 0.12 }), ease: EASE },
);
}
},
{ amount: 0.2 },
);
/* 3. Backdrop parallax — drive --bg-shift from scroll progress (replaces the
* hand-rolled rAF listener; Motion throttles via ScrollTimeline/rAF). */
const bg = document.querySelectorAll<HTMLElement>(".site-bg__img");
if (bg.length) {
scroll((progress: number) => {
const shift = ((0.5 - progress) * window.innerHeight * 0.7).toFixed(1);
bg.forEach((img) => img.style.setProperty("--bg-shift", `${shift}px`));
});
}
/* 4. Top scroll-progress bar — cross-browser (replaces the Chromium-only CSS
* animation-timeline). Drive scaleX STRAIGHT from scroll progress via the
* callback form: scroll(animate(...)) snapped the bar back to 0 at the very
* bottom (the progress=1 boundary); the callback maps 0→1 cleanly both ways. */
const bar = document.querySelector<HTMLElement>(".scroll-progress");
if (bar) {
// Compute progress straight from scroll position so it reliably spans 0→1
// (Motion's default scroll progress topped out ~0.89 at the true bottom).
const setBar = (): void => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
bar.style.transform = `scaleX(${p})`;
};
scroll(setBar);
setBar();
}
/* 5. Nav — animated active-section underline + tactile hover. */
initNav();
/* 6. Buttons — spring lift on hover, press on tap. */
initButtons();
}
function initNav(): void {
const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>(".nav__links a[data-nav-id]"),
);
if (!links.length) return;
const spring = { type: "spring", stiffness: 380, damping: 30 } as const;
let activeIdx = -1;
// `fromRight` flips the grow/collapse origin so the underline animates in the
// direction of travel: scrolling down it grows from the left (moves right);
// scrolling back up it grows from the right (moves left).
const under = (a: HTMLAnchorElement, on: boolean, fromRight = false): void => {
const ul = a.querySelector<HTMLElement>(".nav__ul");
if (!ul) return;
ul.style.transformOrigin = fromRight ? "100% 50%" : "0% 50%";
animate(ul, { scaleX: on ? 1 : 0 }, spring);
};
// Links that map to a section ON THIS PAGE. Projects/Blog point at the homepage
// #projects / #writing sections so every nav item gets the underline; on their
// own pages those sections don't exist and they simply drop out of the spy.
const sectioned = links.filter(
(a) => a.dataset.navId && document.getElementById(a.dataset.navId),
);
for (const a of links) {
a.addEventListener("pointerenter", () => under(a, true));
// On leave, keep the underline only if this link IS the active scroll-spy
// section. Guard on activeIdx >= 0 so that when nothing is active — e.g. the
// Projects/Blog pages, where none of these sections exist and activeIdx stays
// -1 — a hovered tab doesn't stick lit via the -1 === indexOf(non-member) -1.
a.addEventListener("pointerleave", () =>
under(a, activeIdx >= 0 && sectioned.indexOf(a) === activeIdx),
);
}
// Light up the link whose section is currently being read, recomputed from
// scroll position so it tracks BOTH directions. Every change re-asserts ALL
// underlines (active → 1, the rest → 0) so none can be left stranded, and the
// grow/collapse origin follows the scroll direction.
if (sectioned.length) {
const setActive = (): void => {
const vh = window.innerHeight;
const ref = vh * 0.45; // a section whose top has passed this line is "active"
let idx = -1;
sectioned.forEach((a, i) => {
const sec = document.getElementById(a.dataset.navId as string);
if (sec && sec.getBoundingClientRect().top <= ref) idx = i;
});
// At the very bottom the last (often short) section may never push its top
// above the line — force it so the final item lights up.
if (window.scrollY + vh >= document.documentElement.scrollHeight - 4) {
idx = sectioned.length - 1;
}
if (idx === activeIdx) return;
const backward = idx < activeIdx; // scrolling up → travel left
activeIdx = idx;
// Coherent directional slide: the activating underline grows from its
// trailing edge while the others collapse toward the leading edge, so the
// whole motion follows the scroll direction (down → right, up → left)
// instead of the active/old pair diverging.
sectioned.forEach((a, i) => {
const on = i === idx;
under(a, on, on ? backward : !backward);
});
};
scroll(setActive);
setActive();
}
}
function initButtons(): void {
const lift = { type: "spring", stiffness: 400, damping: 26 } as const;
const tap = { type: "spring", stiffness: 600, damping: 22 } as const;
for (const b of document.querySelectorAll<HTMLElement>(".btn")) {
b.addEventListener("pointerenter", () => animate(b, { y: -2, scale: 1 }, lift));
b.addEventListener("pointerleave", () => animate(b, { y: 0, scale: 1 }, lift));
b.addEventListener("pointerdown", () => animate(b, { scale: 0.95 }, tap));
b.addEventListener("pointerup", () => animate(b, { scale: 1, y: -2 }, tap));
}
}
+116 -57
View File
@@ -18,6 +18,36 @@
font-display: swap;
src: url("/fonts/chakra-petch-700.woff2") format("woff2");
}
/* Facelift: Chakra Petch becomes the site-wide face (300700), body included. */
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("/fonts/chakra-petch-300.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/chakra-petch-400.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/chakra-petch-500.woff2") format("woff2");
}
/* JetBrains Mono — one variable file covers 400700 (nav, chips, eyebrows, receipt). */
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/jetbrains-mono-var.woff2") format("woff2");
}
/* ---- Base ---------------------------------------------------------------- */
@@ -68,6 +98,10 @@ a:hover {
outline: 2px solid var(--accent-strong);
outline-offset: 3px;
border-radius: 4px;
/* Board-glow halo around the focus ring. The solid outline carries WCAG
visibility; the halo is on-brand polish (focusable els have no resting
box-shadow here, so nothing is clobbered). */
box-shadow: 0 0 0 4px var(--accent-glow);
}
::selection {
@@ -75,6 +109,30 @@ a:hover {
color: var(--accent-ink);
}
/* Themed scrollbar — thin, board-tinted. The track dissolves into the page; the
thumb carries a cyan→magenta accent (padding-boxed so it reads slim with
breathing room) and brightens on hover. Firefox + WebKit. */
html {
scrollbar-width: thin;
scrollbar-color: var(--accent-strong) transparent;
}
::-webkit-scrollbar {
width: 11px;
height: 11px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
background-color: color-mix(in srgb, var(--accent) 55%, var(--accent-2));
}
::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--accent) 72%, var(--accent-2));
}
img,
svg {
max-width: 100%;
@@ -128,14 +186,15 @@ svg {
border: 1px solid var(--border-strong);
color: var(--text);
background: var(--surface);
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease),
/* transform is owned by the Motion layer (spring hover/press) — kept out of
this transition so CSS and Motion don't fight over the same property. */
transition: border-color 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: linear-gradient(100deg, var(--accent), var(--accent-2));
@@ -159,6 +218,9 @@ svg {
-webkit-backdrop-filter: blur(14px) saturate(1.1);
border: 1px solid var(--border);
border-radius: var(--radius);
/* A 1px inner top highlight — light catching the glass edge — so cards read as
raised even at rest (depth layering). The lift+glow stack lands on hover. */
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
transition: border-color 0.25s var(--ease), transform 0.25s var(--ease),
box-shadow 0.25s var(--ease);
}
@@ -185,7 +247,8 @@ svg {
.card:hover {
border-color: var(--accent-line);
transform: translateY(-2px);
box-shadow: var(--shadow), 0 0 22px var(--accent-glow);
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.08), var(--shadow),
0 0 22px var(--accent-glow);
}
.card:hover::before {
opacity: 0.9;
@@ -308,23 +371,21 @@ svg {
color: var(--shiki-light);
}
/* ---- 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 {
/* ---- Pre-reveal state (progressive enhancement, Motion-driven) ----------- */
/* Default = visible (JS-off safe). Only once site.js sets `html.js` do we hide
the pre-reveal elements; the Motion layer (src/scripts/anim.ts) then fades
them up. Opacity alone gates the flash — Motion owns the y-transform. Under
reduced-motion the Motion layer no-ops, so we keep everything visible here. */
html.js [data-reveal],
html.js .hero__inner > *,
html.js [data-reveal] .chip {
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 {
html.js [data-reveal],
html.js .hero__inner > *,
html.js [data-reveal] .chip {
opacity: 1;
transform: none;
transition: none;
}
}
@@ -332,7 +393,9 @@ svg {
Native scroll-driven animations (animation-timeline). Degrades to a gentle
continuous drift where unsupported; fully disabled under reduced-motion. */
/* A thin royal line at the very top that fills as the page scrolls. */
/* A thin royal line at the very top that fills as the page scrolls. Its scaleX
is scrubbed by scroll progress in the Motion layer (scroll() — cross-browser,
unlike the Chromium-only CSS animation-timeline this replaced). */
.scroll-progress {
position: fixed;
inset: 0 0 auto 0;
@@ -344,17 +407,6 @@ svg {
transform-origin: 0 50%;
pointer-events: none;
}
@supports (animation-timeline: scroll()) {
.scroll-progress {
animation: progressGrow linear both;
animation-timeline: scroll(root block);
}
}
@keyframes progressGrow {
to {
transform: scaleX(1);
}
}
/* ---- Cinematic backing: the Bat-Computer board, panned by scroll -----------
A fixed full-screen image (dark/light per theme) that pans S → E → SE → NE as
@@ -372,12 +424,15 @@ svg {
top: 50%;
left: 50%;
/* Zoomed in — larger than viewport so the board reads big/immersive and the
scroll-pan has room to travel. */
scroll-pan has room to travel without ever revealing an edge. */
width: 215%;
height: 215%;
object-fit: cover;
transform: translate(-50%, -50%);
/* --bg-shift is driven by the scroll handler in site.js (scroll-coupled pan:
moves only as the page scrolls). Defaults to 0 → centred when JS is off. */
transform: translate(-50%, calc(-50% + var(--bg-shift, 0px)));
will-change: transform;
transition: transform 0.15s linear;
}
.site-bg__img--light {
display: none;
@@ -406,41 +461,32 @@ svg {
);
}
[data-theme="light"] .site-bg__scrim {
/* Moderate wash — the board stays clearly visible; text legibility comes from
a light halo behind the type (see below), not from hiding the backdrop. */
background:
linear-gradient(
90deg,
rgba(246, 248, 251, 0.86) 0%,
rgba(246, 248, 251, 0.56) 44%,
rgba(246, 248, 251, 0.4) 100%
rgba(246, 248, 251, 0.8) 0%,
rgba(246, 248, 251, 0.54) 48%,
rgba(246, 248, 251, 0.44) 100%
),
linear-gradient(
180deg,
rgba(246, 248, 251, 0.22) 0%,
rgba(246, 248, 251, 0.54) 58%,
rgba(246, 248, 251, 0.82) 100%
rgba(246, 248, 251, 0.28) 0%,
rgba(246, 248, 251, 0.48) 55%,
rgba(246, 248, 251, 0.78) 100%
);
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.site-bg__img {
animation: bgPan linear both;
animation-timeline: scroll(root block);
}
}
}
@keyframes bgPan {
0% {
transform: translate(-50%, -38%);
}
35% {
transform: translate(-50%, -62%);
}
68% {
transform: translate(-62%, -62%);
}
100% {
transform: translate(-62%, -38%);
}
/* Light-mode legibility: a soft light halo behind running text lifts it off the
busy board WITHOUT washing the board out. Subtle enough to be invisible on the
solid card surfaces; only earns its keep over the image. Dark mode needs none
(light text on a dark board already has the contrast). */
[data-theme="light"]
main
:where(h1, h2, h3, h4, p, li, dt, dd, figcaption, .eyebrow) {
text-shadow:
0 0 4px rgba(246, 248, 251, 0.92),
0 1px 2px rgba(246, 248, 251, 0.75);
}
@media (prefers-reduced-motion: reduce) {
@@ -448,3 +494,16 @@ svg {
display: none;
}
}
/* ---- Cross-document view transitions (facelift polish) --------------------
Native CSS only: Chromium/Safari get a soft crossfade between pages; Firefox
ignores. Zero JS, zero CSP surface, and reduced-motion turns it off. */
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
}
}
+47 -11
View File
@@ -29,13 +29,13 @@
/* Grid / topology motif */
--grid-line: rgba(148, 163, 184, 0.06);
/* Fonts — system stacks (zero external requests) + one self-hosted display face. */
--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",
/* Fonts — ALL self-hosted (zero external requests). Facelift pairing:
Chakra Petch (300700) is the site-wide face, display AND body;
JetBrains Mono carries the terminal register (nav, chips, eyebrows, receipt). */
--font-sans: "Chakra Petch", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "SF Mono",
Menlo, Consolas, "Liberation Mono", monospace;
/* Display face — Chakra Petch (self-hosted woff2, OFL). Used ONLY on the hero
name + section titles; body stays in --font-sans for readability. */
--font-display: "Chakra Petch", var(--font-sans);
/* Fluid type scale */
@@ -58,13 +58,33 @@
--space-8: 4.5rem;
--space-9: 7rem;
/* Layout */
/* Layout — facelift geometry: tighter radii (34 chips, 6 buttons, 812 cards) */
--measure: 68rem;
--radius: 14px;
--radius-sm: 9px;
--radius: 12px;
--radius-sm: 8px;
--radius-btn: 6px;
--radius-chip: 4px;
--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);
/* Facelift: text tiers (mock-exact) */
--text-strong: #eef5fd; /* headings */
--text-body-strong: #dbe6f4;
/* --text (#e7edf4) / --text-dim / --text-faint keep existing consumers working */
--text-secondary: #8fa2bd;
--text-label: #7e8ba1;
/* Facelift: glass panels + cyan-tinted borders + glows */
--panel: rgba(13, 19, 34, 0.72);
--panel-solid: #0d1322;
--panel-blur: blur(10px);
--border-accent: rgba(63, 186, 245, 0.14);
--border-accent-hover: rgba(63, 186, 245, 0.5);
--border-magenta: rgba(232, 121, 249, 0.18);
--glow-num: 0 0 12px rgba(63, 186, 245, 0.6);
--glow-card: 0 0 32px rgba(63, 186, 245, 0.14);
--glow-card-magenta: 0 0 32px rgba(232, 121, 249, 0.12);
}
[data-theme="light"] {
@@ -78,8 +98,10 @@
--border-strong: rgba(2, 6, 23, 0.18);
--text: #0e1726;
--text-dim: #475467;
--text-faint: #6b7689;
/* Darker secondary/tertiary text — the body sits over the (light) board image,
so these need more contrast than on a flat page to stay readable. */
--text-dim: #2c3a4d;
--text-faint: #46546a;
/* Accent — same electric cyan→magenta as dark, dropped in value for contrast
on the light sandstone board. */
@@ -94,4 +116,18 @@
--grid-line: rgba(15, 23, 42, 0.05);
--shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
/* Facelift tiers, blueprint-adjusted for light */
--text-strong: #0b1422;
--text-body-strong: #1d2a3d;
--text-secondary: #3d4d63;
--text-label: #4a5a72;
--panel: rgba(255, 255, 255, 0.78);
--panel-solid: #ffffff;
--border-accent: rgba(12, 143, 206, 0.2);
--border-accent-hover: rgba(12, 143, 206, 0.55);
--border-magenta: rgba(192, 38, 211, 0.22);
--glow-num: 0 0 12px rgba(12, 143, 206, 0.35);
--glow-card: 0 0 32px rgba(12, 143, 206, 0.1);
--glow-card-magenta: 0 0 32px rgba(192, 38, 211, 0.08);
}