45 Commits

Author SHA1 Message Date
lerko 173af2df8d feat(theme): implement console.dark + console.light design system
Replace macOS Classic theme with console-inspired palette. Amber accent,
warm off-white text hierarchy, desaturated green status pips, VLAN-grouped
home services, git-log tabular journey, identity key-value grid with
contact links, active pane (studying/shipping/maintaining). Wider 960px
container, pane headers, responsive mobile fallbacks.
2026-05-18 22:09:59 -04:00
lerko 0c5d9e03b1 feat(site): migrate from Next.js to Astro
Build and Deploy / deploy (push) Successful in 1m42s
Replace Next.js 16 + React 19 with Astro 5. Same visual design,
same deploy pipeline, zero client-side framework.

- All components rewritten as .astro files
- Dark mode via inline scripts (no React context)
- Timeline animation via IntersectionObserver script
- Nav active state computed at build time
- Self-hosted Source Code Pro woff2 fonts
- Drop Font Awesome (icons were never loaded)
- Drop unused headshot PNG (1MB, unreferenced)
- Fix pfSense hardware refs (Netgate 1100, not N100)
- Output: 212KB static HTML vs 2.6MB before
- JS shipped: ~700 bytes inline vs ~130KB React runtime
2026-05-18 20:07:24 -04:00
tyler d34f9f136c 'feat(site): /projects consolidation, homelab copy pass, theme fix' (#6)
Build and Deploy / deploy (push) Successful in 1m0s
Reviewed-on: #6
2026-04-27 06:18:24 +00:00
lerko96 22660bed7a fix(theme): unblock light mode in dev
Hardcoded class="dark" on <html> meant React owned it via JSX. HMR
re-renders and reconciliation kept restoring the class after
classList.toggle removed it, so light toggle never stuck.

ThemeScript already handles initial paint; suppressHydrationWarning
covers the post-script class mismatch.
2026-04-27 00:49:57 -04:00
lerko96 7f614d28b5 feat(projects): consolidate /projects, hide skills, redirect /archive
- /projects: merged page with featured (top) + archive (bottom)
- titles mirror homelab pattern: projects/featured, projects/archive
- nav: archive → projects
- home: drop Skills section and featured grid
- /archive → /projects via meta-refresh + JS redirect stub
2026-04-27 00:49:52 -04:00
lerko96 e9d7a994c7 chore(content): refresh projects + timeline
- featured: swap helm → nib, drop claude-vault, reorder
- helm → archive
- homelab description: 7 VLANs + Wireguard VPN (matches access-tier framing)
- timeline: add Proxmox Backup Server 2025; pfSense entry corrected to Netgate 1100
2026-04-27 00:49:47 -04:00
lerko96 f6118aa7a4 refactor(homelab): rename VLAN col to Segment for VPN row
VPN is an L3 tunnel, not an 802.1Q VLAN. Reframes the table as
network segments / access tiers so the VPN row is consistent with
the others.
2026-04-27 00:49:42 -04:00
lerko96 5dea6121a3 docs(homelab): trim operational detail from network table and ADRs
Pure copy edit. Page now publishes the reasoning behind decisions, not
the operational specifics (IPs, subnets, ports, hardware fingerprints,
build pipeline mechanics). Reasoning preserved in every ADR.

- VLAN table: drop Subnet column; replace numeric VLAN IDs with tier names
- ISP gateway ADR: drop carrier and gateway model
- Caddy ADR: tighten DNS-01 framing to internal-services exposure; SSL → TLS
- WireGuard ADR: drop port, VPN subnet, throughput numbers, tier enumeration
- Pi-hole ADR: drop host IP and VLAN ID; sharpen trade-off
- N100 ADR: drop core/clock and precise throughput; rename to "Mini-PC"
- Postgres+Redis ADR: drop apps LXC IP
- Gitea CI/CD ADR: drop runner version, build image, host IPs, deploy mechanics
- Authentik ADR: unchanged
2026-04-26 23:19:16 -04:00
tyler 4e51dd4a83 feat/polish (#5)
Build and Deploy / deploy (push) Successful in 51s
Reorder homepage sections (journey above projects), refine component styles, update copy and data across projects, timeline, and homelab.

Reviewed-on: #5
2026-04-20 00:34:50 +00:00
tyler 8e9fcfaeeb Merge pull request 'feat(content): reposition for security engineer, expand services' (#4) from feat/timeline into dev
Build and Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #4
2026-04-17 01:24:22 +00:00
lerko96 2946801517 ci: auto-tag CalVer after deploy if commit untagged 2026-04-16 21:16:01 -04:00
lerko96 49028e7783 feat(content): reposition for security engineer, expand services to 37
Hero subtitle and blurb rewritten to lead with security operations
and homelab credentials over generic "builder" framing.

Projects: archive plAIground, service-monitor, ThoughtSpace (2025);
add open-pact and helm as featured; add risk-ops to archive (2026).
Add statusBadge + externalUrl to Project type; wire golf-book-mobile.

Services: 24 → 37 — split grouped arr/media entries, add mail relay,
gluetun, Home Assistant, Glance, Filebrowser, Prowlarr, Bazarr,
nzbget, qBittorrent, Kavita, Openshelf. Drop Calibre-Web.

Skills: add Go to Languages. Timeline: update monitoring stack.
Homelab ADRs: add Authentik over Authelia.
2026-04-16 21:06:18 -04:00
lerko96 c36cc94437 feat(ui): add timeline component; complete terminal-noir design system
- introduce Timeline component with scroll-in animation and type-colored
  spine dots (career/edu/cert/project/homelab)
- swap terminal-noir palette for macOS Classic dark/light with matching
  timeline color tokens in globals.css
- add light mode overrides, cursor blink keyframe, font-size 14px base
- update Widget header: prefix/name split, bracket badge, no divider rule
- align archive and homelab page headers to ❯ prompt style
- convert all font-sans prose in homelab/archive to font-mono
- rename widget titles to namespaced paths (homelab/network, etc.)
- skills label: uppercase tracking → plain text-sm; remove row borders
2026-04-16 18:03:33 -04:00
lerko96 6d0b4e29d8 refactor(ui): enforce terminal metaphor, unify secondary opacity
- drop headshot photo (coherence break vs. full terminal aesthetic)
- replace FA icons with plain-text brackets ([github], [linkedin], [email])
- remove Font Awesome CDN dependency
- nav logo tk → ~/; theme toggle fa-sun/fa-moon → [light]/[dark]
- reorder home sections: projects before skills/journey
- add font-mono + opacity-70 to timeline descriptions (#2 bug + #8 polish)
- uniform opacity-70 across hero bio, project desc, timeline desc
- add hover:bg-surface-raised to ProjectCard article
- drop journey badge count (noise)
- change status ● online → ● available
2026-04-16 18:01:19 -04:00
lerko96 7d9b300d84 ci: add GitHub Actions deploy workflow for GitHub Pages
Build and Deploy / deploy (push) Successful in 1m1s
2026-04-12 22:11:57 -04:00
tyler 4718d5df31 Merge pull request 'fix(docs): update Gitea repo URL to lerko/portfolio' (#3) from docs/update-readme-gitea-primary into dev
Build and Deploy / deploy (push) Successful in 1m4s
Reviewed-on: #3
2026-04-13 01:49:21 +00:00
lerko96 153bdf502c fix(docs): update Gitea repo URL to lerko/portfolio 2026-04-12 21:47:52 -04:00
lerko a7edf0b22a Merge pull request 'docs/update-readme-gitea-primary' (#2) from docs/update-readme-gitea-primary into dev
Build and Deploy / deploy (push) Successful in 50s
Reviewed-on: lerko/Portfolio#2
2026-04-13 01:43:51 +00:00
lerko96 a9b73fd08e fix(docs): correct Gitea repo link in README 2026-04-12 21:38:17 -04:00
lerko96 01f012fc26 docs: update README for Gitea/self-hosted setup
drop GitHub Pages references, document actual deploy flow via
Gitea Actions + rsync to portfolio LXC
2026-04-12 21:23:57 -04:00
lerko96 94cb2da996 feat: terminal-noir redesign with centering fix and readability improvements
Build and Deploy / deploy (push) Successful in 55s
2026-04-12 20:01:26 -04:00
lerko96 79d3fb142e style: improve readability across all pages
Bump body/description text from text-xs to text-sm. Lighten body copy
from color-text-dim/label (#444/#666) to near-white with opacity.
Increase row padding, card padding, and inter-section spacing to match
GitHub Changelog-style breathing room.
2026-04-12 20:00:05 -04:00
lerko96 b3fc7b2114 fix(layout): restore mx-auto centering by scoping CSS reset to @layer base
Unlayered CSS always wins over Tailwind's @layer utilities, so the
bare * { margin: 0 } reset was overriding mx-auto everywhere. Moving
it into @layer base restores correct cascade order.
2026-04-12 19:59:59 -04:00
lerko96 a58fafc563 fix(layout): narrow content column to 740px for visible centering 2026-04-12 19:36:41 -04:00
lerko96 bf0910a8fe style: narrow content column to max-w-3xl for centered layout 2026-04-12 19:33:40 -04:00
lerko96 088a06a51c feat: terminal-noir redesign — widget system + design token overhaul
Replace cyan-green modern theme with terminal-noir aesthetic aligned to
style-guide.md. Hard edges, monospace-first, linear transitions, no gradients.

Introduce Widget component as the single repeatable section primitive:
title bar with horizontal rule, optional badge/meta — all pages and
sections now use this pattern (Glance-inspired data-driven layout).

Design system changes (globals.css):
- Palette: #0a0a0a bg, #111111 surface, #00cc44 status green, #cc2200 alert red
- Drop Montserrat; Source Code Pro primary, system sans for prose only
- Transitions: linear 120ms; no eased animations, no border-radius

Component changes:
- Nav: flat, border-bottom only, lowercase links
- Hero: 56px square photo, status dot, @ email glyph
- ProjectCard: flat bordered card, 2-col grid, no gradient tile
- Skills: key-value rows with dot-separated values
- Footer: minimal text links

Pages: all sections wrapped in Widget; homelab uses gap-px grid for
at-a-glance, services, and ADRs sections. Archive uses flat list layout.

Data: remove gradient field from Project type; add optional year field
2026-04-12 19:23:50 -04:00
lerko96 05a32492ac rebuild portfolio: Next.js 16, React 19, Tailwind v4, homelab page, CI/CD
Build and Deploy / deploy (push) Successful in 1m0s
2026-04-12 18:52:54 -04:00
lerko96 798027bb9d remove accidentally committed node_modules submodule 2026-04-01 17:21:41 -04:00
Tyler Koenig e7cece47f1 i'm lost adding more map scss files 2021-09-20 20:51:45 -04:00
Tyler Koenig 34f06b7cfb eslint change when switching between master 2021-09-20 20:46:51 -04:00
Tyler Koenig bf7e23b972 change title back in index.html 2021-09-20 20:36:06 -04:00
Tyler Koenig 9dbea9fff5 deploy react-app to gh-pages, dwnld dependencies 2021-09-20 20:29:28 -04:00
Tyler Koenig b50ff60540 deleted unused images 2021-09-20 20:18:40 -04:00
Tyler Koenig 9f9d41c9ab rest of react file 2021-09-20 17:03:38 -04:00
Tyler Koenig c612b7d702 applying transfer to react app 2021-09-20 16:54:47 -04:00
Tyler Koenig 8819f31dd0 Merge pull request #3 from lerko96/project-design
needs code removed, but functional and cleaner look.
2021-09-13 17:03:26 -04:00
Tyler Koenig b1faab1f4e mobile styling 2021-09-13 17:01:07 -04:00
Tyler Koenig 7e20081f0d project styling finished 2021-09-13 15:49:56 -04:00
Tyler Koenig 6b484b76b2 lerko repo each project 2021-09-13 13:12:53 -04:00
Tyler Koenig f152434785 font-size 2021-09-13 12:50:59 -04:00
Tyler Koenig ae8775a902 project mobile layout 2021-09-13 12:39:39 -04:00
Tyler Koenig 79b4bf43be project card 2021-09-10 21:21:47 -04:00
Tyler Koenig cffa6db131 card layout 2021-09-10 20:58:13 -04:00
Tyler Koenig 8753311f5c added scss, but wont move forward 2021-09-10 18:11:32 -04:00
Tyler Koenig afbf55aa62 add another font 2021-09-10 15:14:15 -04:00
43 changed files with 7328 additions and 827 deletions
+70
View File
@@ -0,0 +1,70 @@
name: Build and Deploy
on:
push:
branches:
- dev
jobs:
deploy:
runs-on: ubuntu-latest
container: node:22-alpine
steps:
- name: Install SSH and rsync
run: apk add --no-cache openssh-client rsync git
- name: Checkout
uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 10.99.0.23 >> ~/.ssh/known_hosts
- name: Sync out/ to Portfolio LXC
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
out/ root@10.99.0.23:/opt/lerkolabs/out/
- name: Rebuild and restart container
run: |
ssh -i ~/.ssh/deploy_key root@10.99.0.23 \
"cd /opt/lerkolabs && \
docker build -t portfolio . && \
docker stop portfolio 2>/dev/null || true && \
docker rm portfolio 2>/dev/null || true && \
docker run -d --name portfolio -p 80:80 --restart unless-stopped portfolio"
- name: Tag release (CalVer)
run: |
git fetch --tags
if git describe --exact-match --tags HEAD 2>/dev/null; then
echo "Commit already tagged, skipping."
else
YEAR=$(date +%Y)
MONTH=$(date +%m)
LATEST=$(git tag --sort=-v:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]+$' | head -1)
if [ -n "$LATEST" ]; then
LATEST_YEAR=$(echo "$LATEST" | cut -d. -f1)
LATEST_MONTH=$(echo "$LATEST" | cut -d. -f2)
LATEST_MICRO=$(echo "$LATEST" | cut -d. -f3)
if [ "$YEAR" = "$LATEST_YEAR" ] && [ "$MONTH" = "$LATEST_MONTH" ]; then
MICRO=$((LATEST_MICRO + 1))
else
MICRO=1
fi
else
MICRO=1
fi
NEW_TAG="${YEAR}.$(printf '%02d' $MONTH).${MICRO}"
git tag "$NEW_TAG"
git push origin "$NEW_TAG"
echo "Tagged $NEW_TAG"
fi
+44
View File
@@ -0,0 +1,44 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- dev
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Build
run: npm ci && npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: out/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+17
View File
@@ -0,0 +1,17 @@
# dependencies
/node_modules
# astro
/out/
/.astro/
# misc
.DS_Store
.env
.env.*
# typescript
*.tsbuildinfo
# docs
/docs
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY out/ /usr/share/nginx/html
EXPOSE 80
+63
View File
@@ -0,0 +1,63 @@
# Tyler Koenig portfolio
Personal portfolio site. Live at [lerkolabs.com](https://lerkolabs.com) — self-hosted.
Source: [gitea.lerkolabs.com/lerko/portfolio](https://gitea.lerkolabs.com/lerko/portfolio)
**Stack:** Astro 5 · TypeScript · Tailwind v4
---
## Branches
- `dev` — source code; pushing here updates lerkolabs.com
- `master` — reserved for future GitHub mirror; don't touch manually
---
## Commands
```bash
npm run dev # dev server at localhost:4321
npm run build # static export into out/
npm run preview # preview production build
```
---
## Deploy
```bash
git checkout dev && git merge <branch> && git push gitea dev
```
Push to `dev` triggers Gitea Actions (`.gitea/workflows/deploy.yml`):
1. Builds the static site (`npm run build`)
2. rsyncs `out/` to the portfolio LXC
3. Rebuilds and restarts the Docker container serving lerkolabs.com
---
## Project layout
```
src/
layouts/
Base.astro # root layout, fonts, theme script, nav/footer
pages/
index.astro # home: hero, timeline
projects.astro # featured + archive projects
homelab.astro # VLANs, services, ADRs
archive.astro # redirect to /projects/
components/ # Nav, Footer, Hero, Timeline, Widget, ProjectCard, Skills
data/
projects.ts # all projects, featured + archive split
services.ts # homelab services with categories
timeline.ts # career/project timeline
styles/
globals.css # full design system (Tailwind v4 CSS-first, all tokens here)
public/
fonts/ # self-hosted Source Code Pro woff2
```
> Tailwind v4 is CSS-first — no `tailwind.config.ts`. All custom tokens live in `globals.css` under `@theme {}`.
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static',
trailingSlash: 'always',
outDir: 'out',
build: {
format: 'directory',
},
});
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

-428
View File
@@ -1,428 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200;0,400;0,600;1,200;1,400;1,600&display=swap"
rel="stylesheet"
/>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;700&display=swap');
</style>
<script src="https://use.fontawesome.com/e427b1b41a.js"></script>
<link rel="stylesheet" type="text/css" href="style.css" />
<title>Tyler Koenig</title>
</head>
<body>
<div id="wrapper">
<nav>
<a href="index.html">TYLER KOENIG</a>
<ul>
<li><a href="#bio" target="_self">ABOUT</a></li>
<li><a href="#contact" target="_self">CONTACT</a></li>
<li>
<a href="#project-wrapper" target="_self">PROJECTS</a>
</li>
<li><a href="#skills" target="_self">SKILLS</a></li>
</ul>
</nav>
<header>
<img src="/images/headshot-tyler_koenig.png" alt="tyler" />
<h1>
<a href="index.html">Tyler Koenig</a>
</h1>
<h2>
Warehouse Associate at Amazon
<br />
Student at We Can Code IT, Web Developer in the making!
</h2>
</header>
<main>
<section id="aboutMe">
<div id="bio">
<h2>about me</h2>
<p>
I'm Tyler Koenig, a lifelong student. Graduated with
an Associate of Arts from Lorain County Community
College in 2018. Inbound associate at Amazon CLE-3
since December 2019. Currently learning Java and
front-end web development through We Can Code IT
Bootcamp. Always interested in a challenge!
</p>
</div>
<div id="contact">
<h3>contact</h3>
<ul class="contactLinks">
<li>
<a
href="https://github.com/lerko96"
target="_blank"
title="github"
>
github
<i
class="fa fa-github fa-lg"
aria-hidden="true"
></i>
</a>
</li>
<li>
<a
href="https://www.linkedin.com/in/tyler-koenig-72607a18b/"
target="_blank"
title="LinkedIn"
>
linkedin
<i
class="fa fa-linkedin-square fa-lg"
aria-hidden="true"
></i>
</a>
</li>
<li>
<a
href="mailto:tylerkng96@icloud.com"
target="_blank"
title="tylerkng96@icloud.com"
>email
<i
class="fa fa-envelope-o fa-lg"
aria-hidden="true"
></i>
</a>
</li>
</ul>
</div>
</section>
<section id="skills">
<h4>skills</h4>
<ul class="skills">
<li>Java</li>
<li>Spring</li>
<li>Thymeleaf</li>
<li>JavaScript</li>
<li>MVC</li>
<li>HTML</li>
<li>CSS</li>
<li>Test Driven Development</li>
<li>Agile (Scrum)</li>
<li>Object Oriented Programming</li>
<li>JSON</li>
<li>React</li>
<li>REST APIs</li>
<li>Responsive Design</li>
<li>Relational Databases</li>
<li>Source Control/ Github</li>
</ul>
</section>
<section id="project-wrapper">
<h2>projects</h2>
<div>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/pt-spring-2021-team-project-spa/team-2-spa-repo"
target="_blank"
>Mystery Educator</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/mystery-educator.png"
alt="virtual-pet-amok-console"
/>
<div class="project-desc">
<p>
Designed a single page application
that brings back unique data from
MET Museum and NASA APIs.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>Java</li>
<li>JavaScript</li>
<li>HTML</li>
<li>CSS</li>
<li>VS Code</li>
<li>Github</li>
<li>TDD</li>
<li>OOP</li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/2021-Spring-Part-Time/donut-clicker-lerko96"
target="_blank"
>Donut Clicker</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/donut-clicker.png"
alt="virtual-pet-amok-console"
/>
<div class="project-desc">
<p>
Designed a single page application
that lets users make virtual donuts
and buy upgrades for the game.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>JavaScript</li>
<li>HTML</li>
<li>CSS</li>
<li>VS Code</li>
<li>Github</li>
<li>TDD</li>
<li>OOP</li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/pt-spring-2021-team-project-fullstack/team-4-trek"
target="_blank"
>Trekking Website</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/trek.png"
alt="virtual-pet-amok-console"
/>
<div class="project-desc">
<p>
Designed an MVC website that lets
users find treks located by region
and continent.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>Java</li>
<li>SpringJPA</li>
<li>HTML</li>
<li>CSS</li>
<li>IntelliJ</li>
<li>VS Code</li>
<li>Github</li>
<li>TDD</li>
<li>OOP</li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/2021-Spring-Part-Time/reviews-mvc-lerko96"
target="_blank"
>Reviews Site</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img src="/images/review-site.png" alt="" />
<div class="project-desc">
<p>
Designed a movie reviews website
using thymeleaf templates.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>Java</li>
<li>Thymeleaf</li>
<li>HTML</li>
<li>CSS</li>
<li>IntelliJ</li>
<li>Github</li>
<li>TDD</li>
<li>OOP</li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/2021-Spring-Part-Time/virtual-pets-amok-lerko96"
target="_blank"
>Virtual Pets Amok</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/amok.png"
alt="virtual-pet-amok-console"
/>
<div class="project-desc">
<p>
Console App that builds off of
virtual-pet-shelter. This add's
robotic pets to the shelter, with
their own robotic needs.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>TDD</li>
<li>polymorphism</li>
<li>encapsulation</li>
<li>interface (implements)</li>
<li>abstract class (extends)</li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/2021-Spring-Part-Time/virtual-pet-shelter-lerko96"
target="_blank"
>
Virtual Pet Shelter</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/shelter.png"
alt="virtual-pet-shelter-console"
/>
<div class="project-desc">
<p>
Built a console application allowing
users to take care of multiple pets.
Added the ability to admit pets, or
adopt.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>TDD</li>
<li>constructor() {...}</li>
<li>getter() {...}</li>
<li>map<> = hashmap<>()></li>
<li>collections<></li>
</ul>
</div>
</div>
</article>
<article class="project-item">
<div>
<h3>
<a
href="https://github.com/2021-Spring-Part-Time/virtual-pet-lerko96"
target="_blank"
>
Virtual Pet</a
>
</h3>
</div>
<div id="project-container">
<div class="container-right">
<img
src="/images/pet.png"
alt="virtual-pet-console"
/>
<div class="project-desc">
<p>
Built a console application in Java
that allows the user to take care of
one virtual pet. This includes
keeping track of the pets' hunger,
thirst and bordedom.
</p>
</div>
</div>
<div class="project-skills">
<h4>skills</h4>
<ul>
<li>game loop</li>
<li>user input</li>
<li>classes</li>
<li>instance variables</li>
<li>methods</li>
<li>clean code</li>
</ul>
</div>
</div>
</article>
</div>
</section>
</main>
<footer>
<span id="copyright">&copy; 2021 Tyler Koenig</span>
<div id="footLinks">
<a
href="https://github.com/lerko96"
target="_blank"
title="github"
><i class="fa fa-github fa-2x" aria-hidden="true"></i
></a>
<a
href="https://www.linkedin.com/in/tyler-koenig-72607a18b/"
target="_blank"
title="LinkedIn"
><i
class="fa fa-linkedin-square fa-2x"
aria-hidden="true"
></i
></a>
<a
href="mailto:tylerkng96@icloud.com"
target="_blank"
title="email"
><i
class="fa fa-envelope-o fa-2x"
aria-hidden="true"
></i
></a>
</div>
</footer>
</div>
<script src="index.js" type="text/java"></script>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
console.log('hello nurse');
var slideIndex = 1;
showDivs(slideIndex);
function plusDivs(n) {
showDivs((slideIndex += n));
}
function showDivs(n) {
var i;
var x = document.getElementsByClassName('mySlides');
if (n > x.length) {
slideIndex = 1;
}
if (n < 1) {
slideIndex = x.length;
}
for (i = 0; i < x.length; i++) {
x[i].style.display = 'none';
}
x[slideIndex - 1].style.display = 'block';
}
+5687
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "lerko96-portfolio",
"version": "0.1.0",
"private": true,
"homepage": "https://www.lerko96.com",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
---
const items = [
{
label: "studying",
title: "comptia network+",
meta: "ch. 7 · ipv6 addressing",
progress: 0.62,
progressText: "62%",
},
{
label: "shipping",
title: "portfolio site v2",
meta: "astro 5 · gitea actions ci · dmz lxc",
},
{
label: "maintaining",
title: "32 services across 4 vlans",
meta: "last alert 11d ago · backups green",
},
];
---
<section class="border-r border-[var(--color-border)]">
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">active</span>
<span class="text-[var(--color-text-label)] text-[11px]">updated 4m ago</span>
</div>
<div>
{items.map((it, i) => (
<div class:list={[
"px-5 py-4",
i < items.length - 1 && "border-b border-[var(--color-border)]",
]}>
<div class="flex items-baseline justify-between gap-2.5">
<span class="text-[var(--color-text-label)] text-[10px] tracking-[0.16em] uppercase">{it.label}</span>
{it.progress != null && (
<span class="text-[var(--color-text-fg)] text-[11px] tabular-nums">{it.progressText}</span>
)}
</div>
<div class="text-[var(--color-text-heading)] text-[15px] mt-1">{it.title}</div>
<div class="text-[var(--color-text)] text-xs mt-0.5">{it.meta}</div>
{it.progress != null && (
<div class="mt-2.5 h-[3px] bg-[var(--color-border-bright)]">
<div class="h-full bg-[var(--color-accent)]" style={`width: ${it.progress * 100}%`} />
</div>
)}
</div>
))}
</div>
</section>
+39
View File
@@ -0,0 +1,39 @@
---
const year = new Date().getFullYear();
---
<footer class="flex flex-col sm:flex-row justify-between items-center gap-2 px-6 py-3.5 border-t border-[var(--color-border)] bg-[var(--color-surface-raised)] text-[var(--color-text-label)] text-xs">
<span>&copy; {year} tyler koenig &middot; self-hosted in a dmz lxc</span>
<div class="flex items-center gap-5">
<a
href="https://github.com/lerko96"
target="_blank"
rel="noopener noreferrer"
class="hover:text-[var(--color-text-fg)]"
>
github
</a>
<a
href="https://gitea.lerkolabs.com/lerko"
target="_blank"
rel="noopener noreferrer"
class="hover:text-[var(--color-text-fg)]"
>
gitea
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
class="hover:text-[var(--color-text-fg)]"
>
linkedin
</a>
<a
href="mailto:tyler@lerkolabs.com"
class="hover:text-[var(--color-text-fg)]"
>
email
</a>
</div>
</footer>
+62
View File
@@ -0,0 +1,62 @@
---
const identity = [
["role", "soc analyst i · fortress srm"],
["based", "cleveland, oh · est. 2021"],
["stack", "go · react · typescript · linux"],
["infra", "proxmox · pfsense · authentik · nginx"],
["observ", "victoriametrics · grafana · beszel · ntfy"],
["certs", "comptia a+ · network+ (in progress)"],
];
const contact = [
{ key: "github", value: "lerko96", href: "https://github.com/lerko96", glyph: "↗" },
{ key: "gitea", value: "gitea.lerkolabs.com/lerko", href: "https://gitea.lerkolabs.com/lerko", glyph: "↗" },
{ key: "linkedin", value: "tyler-koenig", href: "https://www.linkedin.com/in/tyler-koenig", glyph: "↗" },
{ key: "email", value: "tyler@lerkolabs.com", href: "mailto:tyler@lerkolabs.com", glyph: "✉" },
];
---
<section class="border-b border-[var(--color-border)]">
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">identity</span>
<span class="text-[var(--color-text-label)] text-[11px]">~/identity.toml</span>
</div>
<div class="px-6 py-6 grid grid-cols-1 md:grid-cols-[1.4fr_1fr] gap-9">
<div>
<h1 class="text-[var(--color-text-heading)] text-[38px] leading-[1.05] tracking-tight font-semibold">
tyler koenig
</h1>
<p class="text-[var(--color-text)] text-sm mt-2 leading-relaxed max-w-[540px]">
security operations, self-hosted infrastructure, and the software that holds it together.
</p>
<dl class="mt-6 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-[13px] tabular-nums">
{identity.map(([k, v]) => (
<Fragment>
<dt class="text-[var(--color-text-label)] tracking-wide">{k}</dt>
<dd class="text-[var(--color-text-fg)]">{v}</dd>
</Fragment>
))}
</dl>
</div>
<aside>
<div class="text-[var(--color-text-label)] text-[11px] tracking-[0.12em] mb-2.5">CONTACT</div>
<div class="grid gap-px">
{contact.map(({ key, value, href, glyph }) => (
<a
href={href}
target={href.startsWith("mailto") ? undefined : "_blank"}
rel={href.startsWith("mailto") ? undefined : "noopener noreferrer"}
class="grid grid-cols-[90px_1fr_16px] gap-x-2.5 items-baseline py-[7px] border-b border-[var(--color-border)] text-[13px] no-underline hover:bg-[var(--color-surface)]"
>
<span class="text-[var(--color-text-label)] tracking-wide">{key}</span>
<span class="text-[var(--color-text-fg)] overflow-hidden text-ellipsis whitespace-nowrap">{value}</span>
<span class="text-[var(--color-accent)] text-right">{glyph}</span>
</a>
))}
</div>
</aside>
</div>
</section>
+67
View File
@@ -0,0 +1,67 @@
---
const vlans = [
{
id: "vlan10",
label: "management",
services: [
{ name: "pfsense", kind: "firewall" },
{ name: "authentik", kind: "sso" },
{ name: "proxmox", kind: "hypervisor" },
{ name: "pbs", kind: "backup" },
],
},
{
id: "vlan20",
label: "public-facing",
services: [
{ name: "gitea", kind: "scm/ci" },
{ name: "caddy", kind: "proxy" },
{ name: "lerkolabs.com", kind: "web" },
],
},
{
id: "vlan30",
label: "internal services",
services: [
{ name: "victoriametrics", kind: "tsdb" },
{ name: "grafana", kind: "dashboards" },
{ name: "beszel", kind: "host-mon" },
{ name: "ntfy", kind: "alerts" },
{ name: "jellyfin", kind: "media" },
],
},
];
const totalSvc = vlans.reduce((n, v) => n + v.services.length, 0);
---
<section>
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">infrastructure</span>
<span class="text-[var(--color-text-label)] text-[11px]">{totalSvc} up · 0 failed</span>
</div>
<div>
{vlans.map((vlan, gi) => (
<div class:list={[gi < vlans.length - 1 && "border-b border-[var(--color-border)]"]}>
<div class="flex items-baseline gap-2.5 px-5 pt-2.5 pb-1 text-[var(--color-text-label)] text-[10px] tracking-[0.14em]">
<span class="text-[var(--color-text-fg)]">{vlan.id}</span>
<span>·</span>
<span class="uppercase">{vlan.label}</span>
<span class="ml-auto text-[var(--color-text-faint)]">{vlan.services.length} svc</span>
</div>
{vlan.services.map((s) => (
<div class="grid grid-cols-[12px_1fr_110px_60px] gap-x-2.5 px-5 py-1.5 text-[13px] items-baseline tabular-nums">
<span class="w-1.5 h-1.5 rounded-full bg-[var(--color-accent-green)] self-center" />
<span class="text-[var(--color-text-fg)]">{s.name}</span>
<span class="text-[var(--color-text-label)] text-[11px]">{s.kind}</span>
<span class="text-[var(--color-text-label)] text-[11px] text-right">up</span>
</div>
))}
<div class="h-2" />
</div>
))}
</div>
</section>
+85
View File
@@ -0,0 +1,85 @@
---
const pathname = Astro.url.pathname;
const links = [
{ href: "/", label: "tyler" },
{ href: "/homelab/", label: "homelab" },
{ href: "/projects/", label: "projects" },
];
---
<header class="sticky top-0 z-50 bg-[var(--color-surface-raised)] border-b border-[var(--color-border)]">
<nav class="max-w-[960px] mx-auto px-6 h-11 flex items-center justify-between text-xs tabular-nums">
<div class="flex items-center gap-4">
<a href="/" class="text-[var(--color-text-fg)] tracking-wide hover:text-[var(--color-text-heading)]">
lerkolabs
</a>
<span class="text-[var(--color-text-faint)]">/</span>
<span class="text-[var(--color-text-label)]">tyler</span>
<span class="text-[var(--color-text-faint)]">/</span>
<span class="text-[var(--color-text-label)]">~</span>
</div>
<div class="flex items-center gap-5">
<span class="hidden sm:inline-flex items-center gap-1.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-[var(--color-accent-green)] status-pip" />
<span class="text-[var(--color-text-fg)]">available</span>
</span>
{links.map(({ href, label }) => {
const active = pathname === href || pathname === href.replace(/\/$/, "");
return (
<a
href={href}
aria-current={active ? "page" : undefined}
class:list={[
active
? "text-[var(--color-text-fg)]"
: "text-[var(--color-text-label)] hover:text-[var(--color-text-fg)]",
]}
>
{label}
</a>
);
})}
<button
data-theme-toggle
aria-label="Switch to light mode"
class="text-[var(--color-text-fg)] hover:text-[var(--color-accent)] cursor-pointer"
>
light
</button>
</div>
</nav>
</header>
<style>
.status-pip {
box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent-green) 67%, transparent);
}
:root:not(.dark) .status-pip {
box-shadow: none;
}
</style>
<script>
const btn = document.querySelector("[data-theme-toggle]") as HTMLButtonElement;
function update() {
const isDark = document.documentElement.classList.contains("dark");
btn.textContent = isDark ? "light" : "dark";
btn.setAttribute(
"aria-label",
isDark ? "Switch to light mode" : "Switch to dark mode",
);
}
btn.addEventListener("click", () => {
const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("lerko96-dark-mode", String(next));
update();
});
update();
</script>
+56
View File
@@ -0,0 +1,56 @@
---
import type { Project } from "@/data/projects";
interface Props {
project: Project;
}
const { project } = Astro.props;
---
<article class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex flex-col gap-3 p-5">
<div class="flex items-start justify-between gap-3">
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-semibold text-[var(--color-text-fg)] hover:text-[var(--color-accent)]"
>
{project.title}
</a>
<div class="flex items-center gap-3 shrink-0">
{project.stats && (
<span class="text-xs text-[var(--color-text-label)]">
{project.stats}
</span>
)}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${project.title} on GitHub`}
class="text-sm text-[var(--color-text-label)] hover:text-[var(--color-accent)]"
>
</a>
</div>
</div>
{project.statusBadge && (
<span class="text-xs text-[var(--color-accent)] border border-[var(--color-accent)] px-2 py-0.5 w-fit opacity-80">
{project.statusBadge}
</span>
)}
<p class="text-sm text-[var(--color-text)] leading-relaxed flex-1">
{project.description}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{project.tags.map((tag) => (
<span class="text-xs text-[var(--color-text-label)]">
{tag}
</span>
))}
</div>
</article>
+43
View File
@@ -0,0 +1,43 @@
---
import Widget from "./Widget.astro";
const skillGroups = [
{
label: "Infrastructure",
skills: ["Proxmox", "pfSense", "VLANs", "WireGuard", "Linux", "Caddy"],
},
{
label: "Desktop & Tools",
skills: ["Git", "Docker", "TDD", "Node.js", "REST APIs"],
},
{
label: "Practices",
skills: ["Agile / Scrum", "Relational Databases", "Self-hosting"],
},
{
label: "Languages",
skills: ["Go", "JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Frontend",
skills: ["React", "React Native", "Expo", "Next.js", "Three.js"],
},
];
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
---
<Widget title="tyler/skills" badge={totalCount} as="section">
<div class="flex flex-col">
{skillGroups.map(({ label, skills }) => (
<div class="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh">
<span class="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
{label}
</span>
<span class="font-mono text-sm text-[var(--color-text)]">
{skills.join(" · ")}
</span>
</div>
))}
</div>
</Widget>
+101
View File
@@ -0,0 +1,101 @@
---
import { timeline } from "@/data/timeline";
const branchColor: Record<string, string> = {
career: "var(--color-timeline-career)",
education: "var(--color-timeline-education)",
cert: "var(--color-timeline-cert)",
project: "var(--color-timeline-project)",
homelab: "var(--color-timeline-homelab)",
};
const hashes = [
"a14c2e1", "8f9b3d2", "7a02b41", "4c1f0aa", "3e9d8c0",
"2b6a51f", "9d4c12a", "6e0b8a3", "1c7a4ff", "d5b3e92",
"5a8f1ee", "e2b71ac", "f0a39db", "0000000",
];
---
<section class="border-t border-[var(--color-border)]">
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">journey</span>
<span class="text-[var(--color-text-label)] text-[11px]">{timeline.length} commits · 5 branches · git log --all</span>
</div>
<!-- Desktop: tabular grid -->
<div class="hidden md:grid grid-cols-[92px_86px_92px_1fr] text-[13px] tabular-nums">
{["date", "commit", "scope", "message"].map((h) => (
<div class:list={[
"text-[var(--color-text-label)] text-[10px] tracking-[0.16em] uppercase py-3 px-3 border-b border-[var(--color-border)]",
h === "date" && "pl-6",
]}>
{h}
</div>
))}
{timeline.map((entry, i) => {
const color = branchColor[entry.type] || "var(--color-text-fg)";
const hash = hashes[i] || "0000000";
const last = i === timeline.length - 1;
const borderClass = last ? "" : "border-b border-[var(--color-border)]";
return (
<Fragment>
<div class:list={["py-3.5 px-3 pl-6", borderClass]}>
<span class="text-[var(--color-text-fg)]">{entry.date}</span>
</div>
<div class:list={["py-3.5 px-3", borderClass]}>
<span class="text-[var(--color-accent)]">{hash}</span>
</div>
<div class:list={["py-3.5 px-3", borderClass]}>
<span class="text-[11px] border-b border-dotted pb-px" style={`color: ${color}; border-color: ${color}`}>
{entry.type}
</span>
</div>
<div class:list={["py-3.5 px-3 pr-6", borderClass]}>
<span class="text-[var(--color-text-heading)]">{entry.title}</span>
<div class="text-[var(--color-text)] text-xs mt-0.5 leading-relaxed">{entry.description}</div>
{entry.tags && entry.tags.length > 0 && (
<div class="mt-1.5 flex gap-3.5 flex-wrap text-[var(--color-text-label)] text-[10px]">
{entry.tags.map((t) => (
<span>· {t}</span>
))}
</div>
)}
</div>
</Fragment>
);
})}
</div>
<!-- Mobile: stacked cards -->
<div class="md:hidden">
{timeline.map((entry, i) => {
const color = branchColor[entry.type] || "var(--color-text-fg)";
const hash = hashes[i] || "0000000";
const last = i === timeline.length - 1;
return (
<div class:list={[
"px-5 py-4",
!last && "border-b border-[var(--color-border)]",
]}>
<div class="flex items-center gap-3 mb-1.5 text-xs">
<span class="text-[var(--color-text-fg)]">{entry.date}</span>
<span class="text-[var(--color-accent)]">{hash}</span>
<span class="border-b border-dotted pb-px text-[11px]" style={`color: ${color}; border-color: ${color}`}>
{entry.type}
</span>
</div>
<div class="text-[var(--color-text-heading)] text-sm">{entry.title}</div>
<div class="text-[var(--color-text)] text-xs mt-0.5 leading-relaxed">{entry.description}</div>
{entry.tags && entry.tags.length > 0 && (
<div class="mt-1.5 flex gap-3 flex-wrap text-[var(--color-text-label)] text-[10px]">
{entry.tags.map((t) => (
<span>· {t}</span>
))}
</div>
)}
</div>
);
})}
</div>
</section>
+22
View File
@@ -0,0 +1,22 @@
---
interface Props {
title: string;
badge?: string | number;
meta?: string;
as?: "section" | "div" | "article";
class?: string;
}
const { title, badge, meta, as: Tag = "section", class: className } = Astro.props;
---
<Tag class:list={["mb-0", className]}>
<div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">{title}</span>
<span class="text-[var(--color-text-label)] text-[11px]">
{badge !== undefined && <span>[{badge}]</span>}
{meta && <span>{meta}</span>}
</span>
</div>
<slot />
</Tag>
+162
View File
@@ -0,0 +1,162 @@
export type Project = {
slug: string;
title: string;
description: string;
tags: string[];
githubUrl: string;
tier: "featured" | "archive";
stats?: string;
year?: number;
statusBadge?: string;
externalUrl?: string;
};
export const projects: Project[] = [
// --- Featured ---
{
slug: "homelab",
title: "homelab",
description:
"7-VLAN segmented network, Wireguard VPN, Proxmox VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).",
tags: ["Markdown", "Mermaid", "Proxmox", "Monitor", "Backup"],
githubUrl: "https://gitea.lerkolabs.com/lerko/homelab",
tier: "featured",
year: 2026,
},
{
slug: "portfolio",
title: "portfolio",
description:
"Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"],
githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio",
tier: "featured",
year: 2021,
},
{
slug: "nib",
title: "nib",
description:
"Capture-first personal journal built with Go + React + SQLite. Currently developing in private when I have spare time.",
tags: ["Go", "React", "SQLite", "Journal", "Stream-of-Thought"],
githubUrl: "https://github.com/lerko96/nib",
tier: "featured",
year: 2026,
},
{
slug: "open-pact",
title: "open-pact",
description:
"Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation warrants, portable signed memory facts. No central registry.",
tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"],
githubUrl: "https://github.com/lerko96/open-pact",
tier: "featured",
year: 2026,
},
// --- Archive ---
{
slug: "helm",
title: "helm",
description:
"Full-stack personal productivity dashboard. Go backend with chi router and SQLite, React + TypeScript frontend. Notes, todos, calendar (CalDAV), clipboard, bookmarks, memos. Self-hosted, single-user, daily use.",
tags: ["Go", "React", "TypeScript", "SQLite", "CalDAV"],
githubUrl: "https://github.com/lerko96/helm",
tier: "archive",
year: 2026,
},
{
slug: "risk-ops",
title: "risk-ops",
description:
"Browser-based strategy dashboard for Risk: Global Domination (SMG Studio). Open one HTML file — no install needed.",
tags: ["HTML", "JavaScript"],
githubUrl: "#",
tier: "archive",
year: 2026,
},
{
slug: "golf-book-mobile",
title: "golf-book-mobile",
description:
"Offline-first mobile app for tracking golf rounds, managing your 14-club bag, and getting AI-powered club recommendations from a Smart Caddie. Covers 7 shot types per hole with full scorecard history.",
tags: ["React Native", "Expo", "Zustand", "AI", "Mobile"],
githubUrl: "https://github.com/lerko96/golf-book-mobile",
tier: "archive",
stats: "200+ commits",
statusBadge: "Pending App Store Approval",
year: 2025,
},
{
slug: "twitter-thread-ext",
title: "twitter-thread-ext",
description:
"Chrome extension (Manifest V3) that captures entire Twitter/X threads and exports them as HTML, Markdown, PDF, or image — with metadata preservation and preview before export.",
tags: ["Chrome Extension", "Manifest V3", "JavaScript", "jsPDF"],
githubUrl: "https://github.com/lerko96/twitter-thread-ext",
tier: "archive",
year: 2025,
},
{
slug: "notes-app-1.0",
title: "notes-app-1.0",
description:
"Lightweight canvas drawing app with color picker, adjustable brush size, and PNG export. Runs in the browser, no dependencies.",
tags: ["HTML5 Canvas", "JavaScript", "CSS"],
githubUrl: "https://github.com/lerko96/notes-app-1.0",
tier: "archive",
year: 2025,
},
{
slug: "plaiground",
title: "plAIground",
description:
"Cross-platform desktop AI chat app for developers. Supports OpenAI, Anthropic Claude, and Google Gemini in a single interface with real-time cost tracking, conversation export, and automatic code explanation.",
tags: ["Electron", "Node.js", "OpenAI", "Claude", "Gemini"],
githubUrl: "https://github.com/lerko96/plaiground",
tier: "archive",
year: 2025,
},
{
slug: "service-monitor",
title: "service-monitor",
description:
"Web dashboard for tracking uptime across multiple services with 30-second polling, status history visualization, JWT-authenticated API, and Docker + nginx deployment.",
tags: ["React 18", "Vite", "Express", "SQLite", "Docker", "JWT"],
githubUrl: "https://github.com/lerko96/service-monitor",
tier: "archive",
year: 2025,
},
{
slug: "tht-1.2",
title: "ThoughtSpace",
description:
"3D visualization platform for exploring and organizing thoughts using a radio-tuning metaphor. Filter ideas by frequency and bandwidth in an instanced Three.js scene with persistent local storage.",
tags: ["React", "TypeScript", "Three.js", "React Three Fiber", "Zustand"],
githubUrl: "https://github.com/lerko96/tht-1.2",
tier: "archive",
year: 2025,
},
{
slug: "were-hooked",
title: "We're Hooked",
description:
"Fishing location discovery app built as a team of 5 during bootcamp. Java/Spring MVC backend with Thymeleaf templates.",
tags: ["Java", "Spring", "Thymeleaf", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/were-hooked-repo",
tier: "archive",
year: 2021,
},
{
slug: "mystery-educator",
title: "Mystery Educator",
description:
"Single-page app mashup of the MET Museum and NASA public APIs. Built as a team of 4 during bootcamp.",
tags: ["JavaScript", "REST APIs", "HTML", "CSS"],
githubUrl: "https://github.com/lerko96/mystery-educator",
tier: "archive",
year: 2021,
},
];
export const featuredProjects = projects.filter((p) => p.tier === "featured");
export const archiveProjects = projects.filter((p) => p.tier === "archive");
+70
View File
@@ -0,0 +1,70 @@
export type Service = {
name: string;
description: string;
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
};
export const services: Service[] = [
// Infrastructure
{ name: "pfSense", description: "Firewall, DHCP, routing gateway on Netgate 1100", category: "infrastructure" },
{ name: "Caddy", description: "Reverse proxy with automatic wildcard TLS via Cloudflare DNS-01", category: "infrastructure" },
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure" },
{ name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure" },
{ name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" },
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure" },
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" },
// Security / Auth
{ name: "Authentik", description: "SSO provider — OIDC + forward auth across all services", category: "security" },
{ name: "Vaultwarden", description: "Self-hosted password manager, isolated in its own LXC", category: "security" },
// Monitoring
{ name: "Victoria Metrics", description: "Long-term metrics storage and querying", category: "monitoring" },
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring" },
{ name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" },
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" },
// Productivity
{ name: "Gitea", description: "Personal Git server", category: "productivity" },
{ name: "Outline", description: "Team wiki and knowledge base", category: "productivity" },
{ name: "Vikunja", description: "Task management", category: "productivity" },
{ name: "Actual Budget", description: "Personal budgeting", category: "productivity" },
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity" },
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity" },
{ name: "FreshRSS", description: "RSS reader", category: "productivity" },
{ name: "Memos", description: "Quick notes and journal", category: "productivity" },
{ name: "Traggo", description: "Time tracking", category: "productivity" },
{ name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" },
{ name: "Grist", description: "Spreadsheets and structured data", category: "productivity" },
{ name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity" },
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity" },
// Media
{ name: "Plex", description: "Media streaming — movies, TV, music", category: "media" },
{ name: "Jellyfin", description: "Open-source media streaming", category: "media" },
{ name: "Sonarr", description: "Automated TV show management", category: "media" },
{ name: "Radarr", description: "Automated movie management", category: "media" },
{ name: "Lidarr", description: "Automated music management", category: "media" },
{ name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" },
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media" },
{ name: "nzbget", description: "Usenet downloader", category: "media" },
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media" },
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media" },
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media" },
];
export const categoryOrder: Service["category"][] = [
"infrastructure",
"security",
"monitoring",
"productivity",
"media",
];
export const categoryLabels: Record<Service["category"], string> = {
infrastructure: "Core Infrastructure",
security: "Security & Auth",
monitoring: "Monitoring",
productivity: "Productivity",
media: "Media",
};
+127
View File
@@ -0,0 +1,127 @@
export type TimelineType =
| "career"
| "cert"
| "project"
| "homelab"
| "education";
export interface TimelineEntry {
date: string;
title: string;
type: TimelineType;
description: string;
tags?: string[];
}
export const timeline: TimelineEntry[] = [
{
date: "WIP",
title: "CompTIA Network+ — in progress",
type: "cert",
description:
"Studying for Network+ to formalize networking knowledge built through the homelab.",
tags: ["networking", "certification"],
},
{
date: "2026-04",
title: "Portfolio Site v2",
type: "project",
description:
"Astro portfolio site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["astro", "tailwind", "self-hosted"],
},
{
date: "2026-04",
title: "lerkolabs.com",
type: "homelab",
description:
"Self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["LXC", "DMZ", "self-hosted"],
},
{
date: "2026-03",
title: "Helm",
type: "project",
description:
"Full-stack task and project management tool built in Go + React.",
tags: ["go", "react", "typescript"],
},
{
date: "2025",
title: "Proxmox Backup Server",
type: "homelab",
description: "Deployed PBS on used desktop hardware for disaster recovery.",
tags: ["backup", "recovery", "retention"],
},
{
date: "2025",
title: "Proxmox Cluster",
type: "homelab",
description:
"Proxmox installed on dedicated server and the fun begins. VMs/LXCs, SSO via Authentik, full monitoring stack (VictoriaMetrics + Grafana + Beszel + ntfy).",
tags: ["proxmox", "containers", "VMs", "linux"],
},
{
date: "2024-06",
title: "CompTIA A+",
type: "cert",
description:
"Earned A+ certification, formalizing hardware and OS fundamentals.",
tags: ["certification"],
},
{
date: "2024-03",
title: "pfSense",
type: "homelab",
description:
"Netgate 1100 (Marvell ARMADA 3720) picked up on eBay — hands-on networking configuration, VLANs, firewall rules, and troubleshooting.",
tags: ["network", "firewall", "vlan", "dhcp"],
},
{
date: "2023-10",
title: "SOC Analyst I — Fortress SRM",
type: "career",
description:
"Threat monitoring, incident triage, and client-facing security operations in a managed SOC.",
tags: ["soc", "security"],
},
{
date: "2023-03",
title: "Config Tech II — MCPc",
type: "career",
description:
"Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.",
tags: ["sysadmin", "scripting"],
},
{
date: "2022-11",
title: "PC Build",
type: "homelab",
description: "Sourced parts online and built a personal computer.",
tags: ["amd", "windows 10", "configure", "desktop"],
},
{
date: "2022-05",
title: "Config Tech I — MCPc",
type: "career",
description:
"Hardware configuration, OS imaging, and deployment at scale for enterprise clients.",
tags: ["sysadmin", "hardware"],
},
{
date: "2021-10",
title: "Portfolio Site v1",
type: "project",
description:
"React portfolio deployed to www.lerko96.github.io using github pages.",
tags: ["React", "CSS", "github pages"],
},
{
date: "2021-01",
title: "We Can Code IT — Java Bootcamp",
type: "education",
description:
"9-month intensive bootcamp covering Java, OOP, SQL, REST APIs, and Agile development practices.",
tags: ["java", "sql", "agile"],
},
];
+67
View File
@@ -0,0 +1,67 @@
---
interface Props {
title?: string;
description?: string;
}
const {
title = "Tyler Koenig",
description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>{title}</title>
<meta name="description" content={description} />
<script is:inline>
(function () {
var stored = localStorage.getItem("lerko96-dark-mode");
var dark = stored === null ? true : stored === "true";
if (dark) document.documentElement.classList.add("dark");
})();
</script>
</head>
<body
class="bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen"
>
<slot name="nav" />
<div class="max-w-[960px] mx-auto">
<main>
<slot />
</main>
<slot name="footer" />
</div>
</body>
</html>
<style is:global>
@import "../styles/globals.css";
@font-face {
font-family: "Source Code Pro";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/SourceCodePro-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: "Source Code Pro";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/SourceCodePro-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=/projects/" />
<link rel="canonical" href="/projects/" />
<title>Redirecting...</title>
</head>
<body>
<p>This page moved. <a href="/projects/">/projects/</a></p>
</body>
</html>
+226
View File
@@ -0,0 +1,226 @@
---
import Base from "@/layouts/Base.astro";
import Nav from "@/components/Nav.astro";
import Footer from "@/components/Footer.astro";
import Widget from "@/components/Widget.astro";
import { services, categoryOrder, categoryLabels } from "@/data/services";
const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Netgate 1100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" },
{ label: "VPN", value: "WireGuard (pfSense)" },
{ label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" },
{ label: "Auth", value: "Authentik SSO" },
{ label: "DNS", value: "Pi-hole → Unbound → Cloudflare" },
{ label: "Containers", value: "9 LXC + 2 VMs" },
];
const vlans = [
{ id: "MGMT", name: "MGMT", purpose: "Network equipment only" },
{ id: "LAN", name: "LAN", purpose: "Trusted personal devices" },
{ id: "Lab", name: "Homelab", purpose: "All self-hosted services" },
{ id: "Guest", name: "Guests", purpose: "Internet only, RFC1918 blocked" },
{ id: "IoT", name: "IoT", purpose: "Smart home, isolated" },
{ id: "WFH", name: "WFH", purpose: "Work devices, no personal access" },
{ id: "DMZ", name: "DMZ", purpose: "Public-facing, hard-blocked internally" },
{ id: "VPN", name: "VPN", purpose: "WireGuard clients, LAN-equivalent access" },
];
const adrs = [
{
title: "ISP gateway: passthrough mode",
decision:
"ISP gateway stays in-line in passthrough mode, pfSense gets the public IP directly. Gateway WiFi disabled.",
why: "Carrier locks 802.1X auth to their own gateway hardware, and bypassing it is brittle — breaks on firmware updates and only saves a millisecond or two. True bridge mode isn't supported. Passthrough is the cleanest option that keeps pfSense as the actual perimeter.",
},
{
title: "Caddy over NGINX Proxy Manager",
decision:
"Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates TLS and proxies to backends.",
why: "Single Caddyfile, automatic certs without ever needing to expose internal services to the internet for an HTTP-01 challenge. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit at this scale.",
},
{
title: "WireGuard over OpenVPN",
decision:
"WireGuard on pfSense as the only remote-access path. Clients get the access tier documented in the access model — same as LAN, plus the admin surfaces that aren't reachable any other way.",
why: "Faster, simpler config, better battery life on mobile. Throughput on the firewall hardware comfortably exceeds the WAN link. OpenVPN has no advantage here. Tailscale would add an external relay dependency for a problem WireGuard already solves.",
},
{
title: "Pi-hole in Homelab VLAN, not MGMT",
decision:
"Pi-hole runs in the Homelab VLAN. Firewall allows port 53 inbound from VLANs that need local resolution. MGMT uses pfSense Unbound as its primary resolver instead.",
why: "Putting Pi-hole in MGMT would mean opening MGMT to all the VLANs that need DNS — much bigger attack surface for the most sensitive tier. DNS traffic crossing into the Homelab VLAN is the lesser risk, and Homelab is already where service traffic terminates anyway.",
},
{
title: "Netgate 1100 for pfSense",
decision:
"Netgate 1100 (Marvell ARMADA 3720, dual-core ARM) as the firewall appliance. ~6W idle, line-rate NAT at 1 Gbps, WireGuard at ~100150 Mbps.",
why: "Purpose-built for pfSense. Right-sized for 1 Gbps fiber — NAT saturates the link, WireGuard is fast enough for remote access. A full rack server wastes power for this role. Configs and version tracked in private repo.",
},
{
title: "Shared Postgres + Redis in apps LXC",
decision:
"One Postgres instance hosting multiple databases. One Redis instance. A single init script provisions schemas on first run.",
why: "Avoids ~15 separate DB containers. Big RAM savings. Productivity apps colocate in one LXC anyway, so a shared backing store there is the natural shape.",
},
{
title: "Gitea CI/CD: self-hosted runner, internal pipeline, static deploy",
decision:
"Self-hosted Gitea Actions runner builds the portfolio on push, then deploys pre-built static files to the public-facing host. Build runs in an isolated container so the runner host stays clean. Public host serves static files only — no build toolchain on it.",
why: "Keeps the whole pipeline internal. No external runners, no GitHub Actions. The build/serve split means the public-facing host has the smallest possible footprint — static file server, nothing more.",
},
{
title: "Authentik over Authelia",
decision: "Authentik as the SSO provider across all self-hosted services.",
why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.",
},
];
---
<Base
title="Homelab | Tyler Koenig"
description="Production-grade personal homelab: Proxmox, pfSense, 8 VLANs, WireGuard, Caddy, Authentik SSO, and 20+ self-hosted services."
>
<Nav slot="nav" />
<div class="px-6 py-6 border-b border-[var(--color-border)]">
<h1 class="text-[var(--color-text-heading)] text-lg font-semibold mb-2">homelab</h1>
<p class="text-[var(--color-text)] text-sm leading-relaxed max-w-2xl">
Personal infrastructure environment for learning, self-hosting, and
operational practice. Running 24/7 on production-grade hardware with
real network segmentation, SSO, monitoring, and IaC-style
documentation.
</p>
</div>
<Widget title="homelab/overview" badge={glanceStats.length} as="section">
<div class="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{glanceStats.map(({ label, value }) => (
<div class="bg-[var(--color-surface)] px-5 py-3">
<p class="text-sm text-[var(--color-text-label)] mb-1">
{label}
</p>
<p class="text-sm text-[var(--color-text-fg)]">
{value}
</p>
</div>
))}
</div>
</Widget>
<Widget
title="homelab/network"
meta="8 network segments · default deny"
as="section"
>
<div class="overflow-x-auto px-5">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="text-[var(--color-text-label)] text-left py-2 pr-8 text-[10px] tracking-[0.16em] uppercase">
Segment
</th>
<th class="text-[var(--color-text-label)] text-left py-2 pr-8 text-[10px] tracking-[0.16em] uppercase">
Name
</th>
<th class="text-[var(--color-text-label)] text-left py-2 text-[10px] tracking-[0.16em] uppercase">
Purpose
</th>
</tr>
</thead>
<tbody>
{vlans.map((v) => (
<tr class="border-b border-[var(--color-border)] hover:bg-[var(--color-surface)]">
<td class="text-[var(--color-accent)] py-2.5 pr-8 text-sm">
{v.id}
</td>
<td class="text-[var(--color-text-fg)] py-2.5 pr-8 text-sm">
{v.name}
</td>
<td class="text-[var(--color-text)] py-2.5 text-sm">
{v.purpose}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Widget>
<Widget title="homelab/services" badge={services.length} as="section">
<div class="flex flex-col gap-6 px-5 py-4">
{categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat);
return (
<div>
<p class="text-[var(--color-text-label)] text-[10px] tracking-[0.14em] uppercase mb-2 px-1">
{categoryLabels[cat]}
</p>
<div class="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-px bg-[var(--color-border)]">
{catServices.map((svc) => (
<div class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-5 py-3">
<p class="text-sm text-[var(--color-text-fg)] mb-0.5">
{svc.name}
</p>
<p class="text-xs text-[var(--color-text)] leading-relaxed">
{svc.description}
</p>
</div>
))}
</div>
</div>
);
})}
</div>
</Widget>
<Widget
title="homelab/ADRs"
meta="why things are configured the way they are"
badge={adrs.length}
as="section"
>
<div class="flex flex-col gap-px bg-[var(--color-border)] mx-5 my-4">
{adrs.map((adr) => (
<div class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] px-5 py-4">
<p class="text-sm text-[var(--color-text-fg)] mb-3">
{adr.title}
</p>
<p class="text-sm text-[var(--color-text)] leading-relaxed mb-2">
<span class="text-[var(--color-text-label)]">
decision:{" "}
</span>
{adr.decision}
</p>
<p class="text-sm text-[var(--color-text)] leading-relaxed">
<span class="text-[var(--color-text-label)]">
why:{" "}
</span>
{adr.why}
</p>
</div>
))}
</div>
</Widget>
<section class="px-5 py-6">
<p class="text-[var(--color-text-label)] text-[10px] tracking-[0.14em] uppercase mb-2">
homelab/docs
</p>
<p class="text-sm text-[var(--color-text)] mb-3">
VLAN maps, runbooks, service registry, config exports, and setup guides.
</p>
<a
href="https://gitea.lerkolabs.com/lerko/homelab"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-[var(--color-text-label)] hover:text-[var(--color-accent)]"
>
<span class="text-[var(--color-accent)]">↗</span> gitea.lerkolabs.com/lerko/homelab
</a>
</section>
<Footer slot="footer" />
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import Base from "@/layouts/Base.astro";
import Nav from "@/components/Nav.astro";
import Footer from "@/components/Footer.astro";
import Hero from "@/components/Hero.astro";
import Active from "@/components/Active.astro";
import HomeServices from "@/components/HomeServices.astro";
import Timeline from "@/components/Timeline.astro";
---
<Base>
<Nav slot="nav" />
<Hero />
<div class="grid grid-cols-1 md:grid-cols-[1fr_1.2fr] border-b border-[var(--color-border)]">
<Active />
<HomeServices />
</div>
<Timeline />
<Footer slot="footer" />
</Base>
+74
View File
@@ -0,0 +1,74 @@
---
import Base from "@/layouts/Base.astro";
import Nav from "@/components/Nav.astro";
import Footer from "@/components/Footer.astro";
import Widget from "@/components/Widget.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { featuredProjects, archiveProjects } from "@/data/projects";
---
<Base
title="Projects | Tyler Koenig"
description="Featured projects and earlier work — homelab, open-pact, helm, and bootcamp/experiment archive."
>
<Nav slot="nav" />
<div class="px-6 py-6 border-b border-[var(--color-border)]">
<h1 class="text-[var(--color-text-heading)] text-lg font-semibold mb-2">projects</h1>
<p class="text-[var(--color-text)] text-sm leading-relaxed max-w-xl">
Featured work first. Earlier experiments, browser extensions, and bootcamp projects below — kept for context.
</p>
</div>
<Widget title="projects/featured" badge={featuredProjects.length} as="section">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-px bg-[var(--color-border)] m-5">
{featuredProjects.map((project) => (
<ProjectCard project={project} />
))}
</div>
</Widget>
<Widget title="projects/archive" badge={archiveProjects.length} as="section">
<div class="flex flex-col gap-px bg-[var(--color-border)] m-5">
{archiveProjects.map((project) => (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex items-start justify-between gap-5 px-5 py-4 group"
>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<div class="flex items-center gap-3">
{project.year && (
<span class="text-sm text-[var(--color-text-label)] shrink-0">
{project.year}
</span>
)}
<span class="text-sm text-[var(--color-text-fg)] group-hover:text-[var(--color-accent)] truncate">
{project.title}
</span>
</div>
<p class="text-sm text-[var(--color-text)] leading-relaxed">
{project.description}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5">
{project.tags.map((tag) => (
<span class="text-xs text-[var(--color-text-label)]">
{tag}
</span>
))}
</div>
</div>
<span
class="text-sm text-[var(--color-text-label)] group-hover:text-[var(--color-accent)] shrink-0 mt-0.5"
aria-hidden="true"
>
</span>
</a>
))}
</div>
</Widget>
<Footer slot="footer" />
</Base>
+115
View File
@@ -0,0 +1,115 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
/* Console Dark (default) */
--color-bg: #0a0c0f;
--color-surface: #0e1116;
--color-surface-raised: #11151b;
--color-border: #1c2128;
--color-border-bright: #2a3038;
--color-text: #c1bdb3;
--color-text-fg: #e8e4d8;
--color-text-heading: #ffffff;
--color-text-label: #6a675f;
--color-text-dim: #6a675f;
--color-text-faint: #3a3830;
--color-accent: #d4a259;
--color-accent-green: #7b9e7b;
--color-accent-red: #c74028;
/* Timeline branch colors — dark */
--color-timeline-career: #b390a8;
--color-timeline-education: #999999;
--color-timeline-cert: #cba76c;
--color-timeline-project: #8ba8c0;
--color-timeline-homelab: #9ab494;
/* Typography */
--font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */
--breakpoint-xs: 576px;
/* Character-grid spacing — horizontal (ch) */
--spacing-1ch: 1ch;
--spacing-2ch: 2ch;
--spacing-3ch: 3ch;
--spacing-4ch: 4ch;
/* Character-grid spacing — vertical (lh, requires line-height:1.5 on html) */
--spacing-qtr-lh: 0.25lh;
--spacing-half-lh: 0.5lh;
--spacing-1lh: 1lh;
--spacing-2lh: 2lh;
--spacing-3lh: 3lh;
--spacing-4lh: 4lh;
/* Animations */
--animate-fade-in: fadeIn 120ms linear forwards;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
/* Base */
html {
scroll-behavior: smooth;
font-size: 15px;
line-height: 1.5;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
font-variant-ligatures: none;
}
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
/* Console Light overrides */
:root:not(.dark) {
--color-bg: #f3f0e8;
--color-surface: #faf6ec;
--color-surface-raised: #f6f2e7;
--color-border: #d8d2c2;
--color-border-bright: #b8b2a2;
--color-text: #3a3c40;
--color-text-fg: #1c1e22;
--color-text-heading: #000000;
--color-text-label: #8a857a;
--color-text-dim: #8a857a;
--color-text-faint: #c0bba8;
--color-accent: #b1631c;
--color-accent-green: #3d7048;
--color-accent-red: #d21f07;
--color-timeline-career: #6e3d7a;
--color-timeline-education: #595959;
--color-timeline-cert: #9c6620;
--color-timeline-project: #355d8a;
--color-timeline-homelab: #3d7048;
}
/* Default transitions — linear, fast */
a,
button {
transition: color 120ms linear, border-color 120ms linear,
background-color 120ms linear, opacity 120ms linear;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
-376
View File
@@ -1,376 +0,0 @@
html {
scroll-behavior: smooth;
}
body {
box-sizing: border-box;
color: #c9cacc;
background-color: #272727;
font-size: 16px;
font-family: 'Source Code Pro', monospace, Arial, Helvetica, sans-serif;
font-weight: 400;
line-height: 1.725;
text-rendering: geometricPrecision;
min-height: 100vh;
margin: 0 auto;
}
#wrapper {
display: flex;
flex-direction: column;
width: 100%;
max-width: 560px;
margin: 0 auto;
}
nav ul {
list-style-type: none;
margin: auto;
padding: 0em;
}
nav a {
text-decoration: none;
border-radius: 500px;
padding: 0.2rem;
font-weight: 600;
display: flex;
justify-content: center;
color: #c9cacc;
letter-spacing: 0.09rem;
}
nav a:hover {
background-color: #29f3c3;
color: #1b1b1b;
transition: 150ms ease;
}
header {
color: #c9cacc;
margin: 0 auto;
text-align: center;
padding: 2rem;
line-height: 1.75rem;
border-top: 1.5px solid #666;
border-bottom: 1.5px solid #666;
}
header img {
width: 100px;
border: 5px solid #666;
border-radius: 165px;
}
h1 {
line-height: 2rem;
letter-spacing: 0.01rem;
}
h1 a {
font-size: 3rem;
color: #c9cacc;
text-decoration: none;
}
h1 a:hover {
color: #29f3c3;
}
header h2 {
font-weight: 200;
font-size: 1.2rem;
letter-spacing: 0.01rem;
}
h2,
h3,
h4 {
letter-spacing: 0.01rem;
}
#aboutMe {
letter-spacing: 0.01rem;
}
#bio,
#skills {
padding: 1.5rem;
text-align: start;
}
#bio h2 {
font-size: 2.2rem;
}
#skills h4 {
padding: 1.5rem;
color: #666;
font-size: 1.5rem;
margin: 0 auto 20px;
}
#skills ul {
line-height: 1.35rem;
font-weight: 200;
text-align: center;
align-items: center;
margin: 0;
padding: 0;
list-style-type: none;
}
.skills {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
gap: 10px 75px;
grid-auto-flow: row;
grid-template-areas:
'. .'
'. .'
'. .'
'. .'
'. .'
'. .'
'. .'
'. .';
}
#contact {
margin-top: 1rem;
padding: 1.5rem;
}
#contact h3 {
font-size: 1.5rem;
}
#contact ul {
display: flex;
list-style-type: none;
margin: 0;
padding: 0;
text-align: center;
}
.contactLinks li {
display: flex;
flex-direction: column;
flex: 1;
}
.contactLinks a {
display: flex;
flex-direction: column;
color: #c9cacc;
text-decoration: none;
}
.contactLinks a:hover {
font-weight: 600;
}
#project-wrapper {
margin-top: 2.5rem;
}
#project-wrapper h2 {
margin: 0;
padding: 1.5rem 1.5rem 0 1.5rem;
font-size: 2rem;
}
#project-wrapper h3 {
font-size: 1.5rem;
text-align: left;
margin: 0 auto;
padding: 1.5rem;
letter-spacing: 0.01rem;
}
.project-item {
display: flex;
flex-direction: column;
margin: 1.5rem 0;
text-align: center;
font-weight: 200;
letter-spacing: 0.01rem;
}
.project-item a {
color: #c9cacc;
text-decoration: none;
}
.project-item a:hover {
color: #29f3c3;
}
.project-desc {
margin: 0;
padding: 0 0.8rem;
}
.project-skills {
color: #666;
list-style: none;
margin: 0 auto;
line-height: 1.25rem;
text-align: start;
padding: 0 1.5rem;
display: flex;
align-items: baseline;
}
.project-skills h4 {
font-size: 1.5rem;
/* flex: 1; */
}
.project-skills li {
list-style-type: none;
color: #c9cacc;
}
.project-skills ul {
/* flex: 9; */
line-height: 2rem;
text-align: start;
}
.project-item img {
max-width: 375px;
/* width: 90%; */
box-shadow: 10px 10px 11px 0px rgba(0, 0, 0, 0.75);
-webkit-box-shadow: 10px 10px 11px 0px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 10px 10px 11px 0px rgba(0, 0, 0, 0.75);
}
footer {
color: #666;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
margin: 0 auto;
padding: 1rem;
}
#copyright {
margin: 0.4rem;
letter-spacing: 0.1rem;
}
#footLinks {
margin: 0.4rem;
letter-spacing: 0.8rem;
}
#footLinks a {
color: #666;
}
#footLinks a:hover {
color: #c9cacc;
transition: 160ms ease-in;
}
/* color: #1b1b1b; */
/* color: #2a9d8f */
@media (min-width: 576px) {
#wrapper {
width: 560px;
}
nav {
display: flex;
}
nav a {
margin: auto;
padding: 0.5rem;
flex: 4;
justify-content: flex-start;
}
nav li {
display: inline-block;
}
nav ul {
display: flex;
}
#aboutMe {
display: flex;
}
#bio {
flex: 2;
}
#contact {
flex: 1;
text-align: end;
}
.contactLinks {
display: flex;
flex-direction: column;
}
.contactLinks li {
align-items: flex-end;
}
.contactLinks a {
flex-direction: row;
}
.contactLinks i {
margin: auto 5px;
}
#skills {
padding: 0;
}
#project-container {
display: flex;
flex-direction: row-reverse;
}
.project-item img {
flex: 2;
}
.project-skills {
flex: 1;
margin: 0;
padding: 0;
text-align: end;
flex-direction: column;
align-items: flex-end;
}
.project-skills h4 {
margin: 1.5rem;
align-self: flex-start;
}
.project-skills ul {
margin: 1.5rem;
padding: 0;
}
.project-item a {
align-self: start;
}
footer {
margin: 0 auto;
width: 87%;
padding: 2rem;
flex-direction: row;
justify-content: space-between;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}