Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e13bf596c | |||
| 8a7ca88c40 | |||
| de12f028eb | |||
| 8d955aa802 | |||
| 65ba1ec7dc | |||
| 08bab080b1 | |||
| dae9bbc91a | |||
| 7c300faec8 | |||
| 257af5d22e | |||
| 880b9daeac | |||
| a685789322 | |||
| 61642e8db8 | |||
| bed2dbaa1f | |||
| 354475a453 | |||
| d8a147aabe | |||
| f6d92e5c87 | |||
| 5d439752de | |||
| 0c93328a2d | |||
| ef1719c84e | |||
| e264f673d7 | |||
| 8702c8a293 | |||
| b7c91081b2 | |||
| af7e0287d4 | |||
| 2aae7c287e | |||
| 9e177dcc41 | |||
| 99455d0168 |
@@ -18,7 +18,8 @@
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^6.4.7",
|
||||
"astro": "^7.0.3",
|
||||
"motion": "^12.42.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
@@ -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 — 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 — 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 · 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 · 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 → 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 ×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 — 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,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 → 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 → 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 — 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 · uid 1000 · 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 · LoadBalancer · secrets operator</text>
|
||||
<text x="78" y="350" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">→ 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 |
@@ -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 |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 120 KiB |
@@ -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.
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 & INFRASTRUCTURE ENGINEER</text>
|
||||
fill="#3fbaf5">~/ PLATFORM & 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">· GPU-backed edge systems</text>
|
||||
|
||||
<text x="80" y="560" font-family="monospace" font-size="20" fill="#8aa0b8">RHEL <tspan fill="#5eead4">▸</tspan> Kubernetes <tspan fill="#5eead4">▸</tspan> GitOps <tspan fill="#5eead4">▸</tspan> Ansible <tspan fill="#5eead4">▸</tspan> Observability <tspan fill="#5eead4">▸</tspan> Edge AI</text>
|
||||
<text x="80" y="560" font-family="monospace" font-size="20" fill="#8aa0b8">RHEL <tspan fill="#3fbaf5">▸</tspan> Kubernetes <tspan fill="#3fbaf5">▸</tspan> GitOps <tspan fill="#3fbaf5">▸</tspan> Ansible <tspan fill="#3fbaf5">▸</tspan> Observability <tspan fill="#3fbaf5">▸</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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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,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*.
|
||||
|
||||

|
||||

|
||||
|
||||
## The tunnel dials out
|
||||
|
||||
|
||||
@@ -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 -->
|
||||

|
||||
|
||||
## 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,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
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
---
|
||||
title: "The most secure credential is the one you never mount"
|
||||
title: "Every pod holds a key to a door it never opens"
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,36 @@
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch-700.woff2") format("woff2");
|
||||
}
|
||||
/* Facelift: Chakra Petch becomes the site-wide face (300–700), 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 400–700 (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
|
||||
@@ -371,11 +423,16 @@ svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 168%;
|
||||
height: 168%;
|
||||
/* Zoomed in — larger than viewport so the board reads big/immersive and the
|
||||
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;
|
||||
@@ -404,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) {
|
||||
@@ -446,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (300–700) 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 (3–4 chips, 6 buttons, 8–12 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);
|
||||
}
|
||||
|
||||