1 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
18 changed files with 855 additions and 439 deletions
+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>
+11 -14
View File
@@ -2,41 +2,38 @@
const year = new Date().getFullYear(); const year = new Date().getFullYear();
--- ---
<footer class="border-t border-[var(--color-border)] py-1lh mt-2lh"> <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">
<div class="px-4ch flex items-center justify-between text-[var(--color-text-dim)]"> <span>&copy; {year} tyler koenig &middot; self-hosted in a dmz lxc</span>
<span>&copy; {year} Tyler Koenig</span> <div class="flex items-center gap-5">
<nav class="flex items-center gap-2ch">
<a <a
href="https://github.com/lerko96" href="https://github.com/lerko96"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline" class="hover:text-[var(--color-text-fg)]"
> >
GitHub github
</a> </a>
<a <a
href="https://gitea.lerkolabs.com/lerko" href="https://gitea.lerkolabs.com/lerko"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline" class="hover:text-[var(--color-text-fg)]"
> >
Gitea gitea
</a> </a>
<a <a
href="https://www.linkedin.com/in/tyler-koenig" href="https://www.linkedin.com/in/tyler-koenig"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline" class="hover:text-[var(--color-text-fg)]"
> >
LinkedIn linkedin
</a> </a>
<a <a
href="mailto:tyler@lerkolabs.com" href="mailto:tyler@lerkolabs.com"
target="_blank" class="hover:text-[var(--color-text-fg)]"
class="underline"
> >
Email email
</a> </a>
</nav>
</div> </div>
</footer> </footer>
+52 -40
View File
@@ -1,50 +1,62 @@
--- ---
import { services } from "@/data/services"; 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="mb-3lh"> <section class="border-b border-[var(--color-border)]">
<h1 class="text-xl font-bold mb-half-lh">Tyler Koenig</h1> <div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<p class="text-[var(--color-text-label)] mb-1lh"> <span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">identity</span>
Security Operations · Self-Hosted Infrastructure <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> </p>
<p class="text-[var(--color-text-label)] leading-relaxed mb-1lh"> <dl class="mt-6 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-[13px] tabular-nums">
Homelab runs {services.length} services across segmented VLANs — pfSense, Authentik SSO, {identity.map(([k, v]) => (
full observability stack. Write software too: mobile apps, Go backends, open <Fragment>
protocols. <dt class="text-[var(--color-text-label)] tracking-wide">{k}</dt>
</p> <dd class="text-[var(--color-text-fg)]">{v}</dd>
</Fragment>
))}
</dl>
</div>
<nav class="flex flex-wrap items-center gap-x-2ch gap-y-half-lh text-[var(--color-text-label)]"> <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 <a
href="https://github.com/lerko96" href={href}
target="_blank" target={href.startsWith("mailto") ? undefined : "_blank"}
rel="noopener noreferrer" rel={href.startsWith("mailto") ? undefined : "noopener noreferrer"}
class="underline" 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)]"
> >
GitHub <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> </a>
<a ))}
href="https://gitea.lerkolabs.com/lerko" </div>
target="_blank" </aside>
rel="noopener noreferrer" </div>
class="underline"
>
Gitea
</a>
<a
href="https://www.linkedin.com/in/tyler-koenig"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
LinkedIn
</a>
<a
href="mailto:tyler@lerkolabs.com"
target="_blank"
class="underline"
>
Email
</a>
</nav>
</section> </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>
+60 -64
View File
@@ -1,16 +1,51 @@
<header class="sticky top-0 z-50 bg-[var(--color-bg)] border-b border-[var(--color-border)]"> ---
<nav class="max-w-[740px] mx-auto px-4ch h-11 flex items-center justify-between"> const pathname = Astro.url.pathname;
<a href="/" class="font-semibold">Tyler Koenig</a>
<div class="flex items-center gap-2ch"> const links = [
<a href="#projects" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">projects</a> { href: "/", label: "tyler" },
<a href="#journey" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">journey</a> { href: "/homelab/", label: "homelab" },
<a href="#homelab" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">homelab</a> { 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 <button
data-theme-toggle data-theme-toggle
aria-label="Switch to light mode" aria-label="Switch to light mode"
class="text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer" class="text-[var(--color-text-fg)] hover:text-[var(--color-accent)] cursor-pointer"
> >
light light
</button> </button>
@@ -18,72 +53,33 @@
</nav> </nav>
</header> </header>
<script> <style>
const themeBtn = document.querySelector("[data-theme-toggle]") as HTMLButtonElement; .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>
function updateTheme() { <script>
const btn = document.querySelector("[data-theme-toggle]") as HTMLButtonElement;
function update() {
const isDark = document.documentElement.classList.contains("dark"); const isDark = document.documentElement.classList.contains("dark");
themeBtn.textContent = isDark ? "light" : "dark"; btn.textContent = isDark ? "light" : "dark";
themeBtn.setAttribute( btn.setAttribute(
"aria-label", "aria-label",
isDark ? "Switch to light mode" : "Switch to dark mode", isDark ? "Switch to light mode" : "Switch to dark mode",
); );
} }
themeBtn.addEventListener("click", () => { btn.addEventListener("click", () => {
const next = !document.documentElement.classList.contains("dark"); const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next); document.documentElement.classList.toggle("dark", next);
localStorage.setItem("lerko96-dark-mode", String(next)); localStorage.setItem("lerko96-dark-mode", String(next));
updateTheme(); update();
}); });
updateTheme(); update();
const navLinks = document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]");
const sections = Array.from(navLinks).map((link) => ({
link,
section: document.querySelector(link.hash) as HTMLElement,
}));
const atBottom = () =>
window.innerHeight + window.scrollY >= document.body.offsetHeight - 2;
const observer = new IntersectionObserver(
() => {
let active: HTMLAnchorElement | null = null;
if (atBottom()) {
active = sections[sections.length - 1].link;
} else {
for (const { link, section } of sections) {
if (section.getBoundingClientRect().top <= 80) active = link;
}
}
navLinks.forEach((l) => {
l.style.color = l === active ? "var(--color-text)" : "";
});
},
{ rootMargin: "-56px 0px 0px 0px", threshold: 0 },
);
sections.forEach(({ section }) => observer.observe(section));
let ticking = false;
window.addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
let active: HTMLAnchorElement | null = null;
if (atBottom()) {
active = sections[sections.length - 1].link;
} else {
for (const { link, section } of sections) {
if (section.getBoundingClientRect().top <= 80) active = link;
}
}
navLinks.forEach((l) => {
l.style.color = l === active ? "var(--color-text)" : "";
});
ticking = false;
});
}, { passive: true });
</script> </script>
+32 -11
View File
@@ -8,28 +8,49 @@ interface Props {
const { project } = Astro.props; const { project } = Astro.props;
--- ---
<article class="mb-2lh"> <article class="bg-[var(--color-surface)] hover:bg-[var(--color-surface-raised)] flex flex-col gap-3 p-5">
<div class="flex items-baseline gap-1ch mb-half-lh"> <div class="flex items-start justify-between gap-3">
<h3>
<a <a
href={project.githubUrl} href={project.githubUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-semibold underline" class="text-sm font-semibold text-[var(--color-text-fg)] hover:text-[var(--color-accent)]"
> >
{project.title} {project.title}
</a> </a>
</h3> <div class="flex items-center gap-3 shrink-0">
{project.statusBadge && ( {project.stats && (
<span class="text-[var(--color-text-dim)]">({project.statusBadge})</span> <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> </div>
<p class="text-[var(--color-text-label)] leading-relaxed mb-half-lh"> {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} {project.description}
</p> </p>
<p class="text-[var(--color-text-dim)]"> <div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{project.tags.join(" · ")} {project.tags.map((tag) => (
</p> <span class="text-xs text-[var(--color-text-label)]">
{tag}
</span>
))}
</div>
</article> </article>
+3 -3
View File
@@ -27,14 +27,14 @@ const skillGroups = [
const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0); const totalCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
--- ---
<Widget title="Skills" badge={totalCount} as="section"> <Widget title="tyler/skills" badge={totalCount} as="section">
<div class="flex flex-col"> <div class="flex flex-col">
{skillGroups.map(({ label, skills }) => ( {skillGroups.map(({ label, skills }) => (
<div class="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh"> <div class="flex flex-col xs:flex-row gap-1ch xs:gap-2ch py-half-lh">
<span class="text-[var(--color-text-dim)] w-28 shrink-0"> <span class="font-mono text-sm text-[var(--color-text-dim)] w-28 shrink-0">
{label} {label}
</span> </span>
<span> <span class="font-mono text-sm text-[var(--color-text)]">
{skills.join(" · ")} {skills.join(" · ")}
</span> </span>
</div> </div>
+93 -30
View File
@@ -1,38 +1,101 @@
--- ---
import Widget from "./Widget.astro"; import { timeline } from "@/data/timeline";
import { timeline, type TimelineType } from "@/data/timeline";
const isDate = (d: string) => /^\d{4}/.test(d); const branchColor: Record<string, string> = {
career: "var(--color-timeline-career)",
const typeLabel: Record<TimelineType, string> = { education: "var(--color-timeline-education)",
career: "career", cert: "var(--color-timeline-cert)",
education: "education", project: "var(--color-timeline-project)",
cert: "cert", homelab: "var(--color-timeline-homelab)",
project: "project",
homelab: "homelab",
}; };
const hashes = [
"a14c2e1", "8f9b3d2", "7a02b41", "4c1f0aa", "3e9d8c0",
"2b6a51f", "9d4c12a", "6e0b8a3", "1c7a4ff", "d5b3e92",
"5a8f1ee", "e2b71ac", "f0a39db", "0000000",
];
--- ---
<Widget title="Journey"> <section class="border-t border-[var(--color-border)]">
<ol class="flex flex-col"> <div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
{timeline.map((entry) => ( <span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">journey</span>
<li class="grid grid-cols-[10ch_1fr] gap-2ch py-half-lh border-b border-[var(--color-border)] last:border-b-0"> <span class="text-[var(--color-text-label)] text-[11px]">{timeline.length} commits · 5 branches · git log --all</span>
<div class="text-[var(--color-text-dim)] pt-[0.1em]">
{isDate(entry.date)
? <time datetime={entry.date}>{entry.date}</time>
: <span>{entry.date}</span>
}
</div> </div>
<div>
<h3 class="font-semibold"> <!-- Desktop: tabular grid -->
{entry.title} <div class="hidden md:grid grid-cols-[92px_86px_92px_1fr] text-[13px] tabular-nums">
<span class="font-normal text-[var(--color-text-dim)]"> · {typeLabel[entry.type]}</span> {["date", "commit", "scope", "message"].map((h) => (
</h3> <div class:list={[
<p class="text-[var(--color-text-label)] leading-relaxed"> "text-[var(--color-text-label)] text-[10px] tracking-[0.16em] uppercase py-3 px-3 border-b border-[var(--color-border)]",
{entry.description} h === "date" && "pl-6",
</p> ]}>
{h}
</div> </div>
</li>
))} ))}
</ol>
</Widget> {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>
+7 -11
View File
@@ -10,17 +10,13 @@ interface Props {
const { title, badge, meta, as: Tag = "section", class: className } = Astro.props; const { title, badge, meta, as: Tag = "section", class: className } = Astro.props;
--- ---
<Tag class:list={["mb-4lh", className]}> <Tag class:list={["mb-0", className]}>
<div class="mb-2lh"> <div class="flex items-baseline justify-between px-5 py-3.5 border-b border-[var(--color-border)]">
<h2 class="text-lg font-semibold"> <span class="text-[var(--color-text-fg)] text-[11px] tracking-[0.16em] uppercase">{title}</span>
{title} <span class="text-[var(--color-text-label)] text-[11px]">
{badge !== undefined && ( {badge !== undefined && <span>[{badge}]</span>}
<span class="text-[var(--color-text-dim)] font-normal"> ({badge})</span> {meta && <span>{meta}</span>}
)} </span>
</h2>
{meta && (
<p class="text-[var(--color-text-dim)]">{meta}</p>
)}
</div> </div>
<slot /> <slot />
</Tag> </Tag>
+16 -24
View File
@@ -13,25 +13,6 @@ export type Project = {
export const projects: Project[] = [ export const projects: Project[] = [
// --- Featured --- // --- Featured ---
{
slug: "uptop",
title: "uptop",
description: "Live uptime monitoring dashboard for your terminal. SSH-accessible. HTTP, ping, TCP, DNS, push checks with alerts, clustering, and Prometheus metrics.",
tags: ["Go", "Bubbletea", "Monitoring", "Uptime"],
githubUrl: "https://github.com/lerkolabs/uptop",
tier: "featured",
year: 2026,
},
{
slug: "nib",
title: "nib",
description:
"Capture-first personal journal built with Go + SQLite. Currently developing in private when I have spare time.",
tags: ["Go", "JavaScript", "SQLite", "Stream-of-Thought"],
githubUrl: "https://gitea.lerkolabs.com/lerko/nib-v1",
tier: "featured",
year: 2026,
},
{ {
slug: "homelab", slug: "homelab",
title: "homelab", title: "homelab",
@@ -46,22 +27,33 @@ export const projects: Project[] = [
slug: "portfolio", slug: "portfolio",
title: "portfolio", title: "portfolio",
description: description:
"Astro static site, self-hosted in a DMZ LXC behind Caddy, deployed via Gitea Actions CI.", "Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.",
tags: ["Astro", "Typescript", "Dockerfile", "Caddy"], tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"],
githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio", githubUrl: "https://gitea.lerkolabs.com/lerko/portfolio",
tier: "featured", tier: "featured",
year: 2021, year: 2021,
}, },
// --- Archive --- {
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", slug: "open-pact",
title: "open-pact", title: "open-pact",
description: "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation", 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"], tags: ["TypeScript", "Ed25519", "DID", "npm", "CC0"],
githubUrl: "https://github.com/lerko96/open-pact", githubUrl: "https://github.com/lerko96/open-pact",
tier: "archive", tier: "featured",
year: 2026, year: 2026,
}, },
// --- Archive ---
{ {
slug: "helm", slug: "helm",
title: "helm", title: "helm",
+16 -19
View File
@@ -2,7 +2,6 @@ export type Service = {
name: string; name: string;
description: string; description: string;
category: "infrastructure" | "security" | "monitoring" | "productivity" | "media"; category: "infrastructure" | "security" | "monitoring" | "productivity" | "media";
hidden?: boolean;
}; };
export const services: Service[] = [ export const services: Service[] = [
@@ -12,7 +11,7 @@ export const services: Service[] = [
{ name: "Pi-hole", description: "Network-wide DNS + ad blocking", 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: "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: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" },
{ name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", hidden: true }, { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure" },
{ name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" }, { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" },
// Security / Auth // Security / Auth
@@ -24,36 +23,34 @@ export const services: Service[] = [
{ name: "Grafana", description: "Dashboards and alerting across all hosts and services", 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: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" },
{ name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" }, { name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" },
{ name: "Uptime Kuma", description: "Self-hosted monitoring tool (GUI)", category: "monitoring" },
// Productivity // Productivity
{ name: "Gitea", description: "Personal Git server", category: "productivity" }, { name: "Gitea", description: "Personal Git server", category: "productivity" },
{ name: "Outline", description: "Team wiki and knowledge base", 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: "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: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" },
{ name: "Grist", description: "Spreadsheets and structured data", 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: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity" },
{ name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", hidden: true }, { name: "Filebrowser", description: "Web-based file manager", category: "productivity" },
{ name: "FreshRSS", description: "RSS reader", category: "productivity", hidden: true },
{ name: "Memos", description: "Quick notes and journal", category: "productivity", hidden: true },
{ name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", hidden: true },
{ name: "Vikunja", description: "Task management", category: "productivity", hidden: true },
{ name: "Traggo", description: "Time tracking", category: "productivity", hidden: true },
{ name: "Filebrowser", description: "Web-based file manager", category: "productivity", hidden: true },
// Media // Media
{ name: "Immich", description: "Open-source photo library", category: "media"}, { name: "Plex", description: "Media streaming — movies, TV, music", category: "media" },
{ name: "Kavita", description: "Self-hosted manga and book reader", category: "media" },
{ name: "Jellyfin", description: "Open-source media streaming", 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: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" },
{ name: "*Arr stack", description: "Automated media management for streaming", category: "media" },
{ name: "Bazarr", description: "Automatic subtitle download and management", category: "media" }, { name: "Bazarr", description: "Automatic subtitle download and management", category: "media" },
{ name: "Lidarr", description: "Automated music management", category: "media", hidden: true }, { name: "nzbget", description: "Usenet downloader", category: "media" },
{ name: "Radarr", description: "Automated movie management", category: "media", hidden: true }, { name: "qBittorrent", description: "Torrent client with web UI", category: "media" },
{ name: "Plex", description: "Media streaming — movies, TV, music", category: "media", hidden: true }, { name: "Kavita", description: "Self-hosted manga and book reader", category: "media" },
{ name: "nzbget", description: "Usenet downloader", category: "media", hidden: true }, { name: "Openshelf", description: "Book library with auto-ingest", category: "media" },
{ name: "qBittorrent", description: "Torrent client with web UI", category: "media", hidden: true },
{ name: "Openshelf", description: "Book library with auto-ingest", category: "media", hidden: true },
]; ];
export const categoryOrder: Service["category"][] = [ export const categoryOrder: Service["category"][] = [
+39
View File
@@ -22,6 +22,30 @@ export const timeline: TimelineEntry[] = [
"Studying for Network+ to formalize networking knowledge built through the homelab.", "Studying for Network+ to formalize networking knowledge built through the homelab.",
tags: ["networking", "certification"], 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", date: "2025",
title: "Proxmox Backup Server", title: "Proxmox Backup Server",
@@ -69,6 +93,13 @@ export const timeline: TimelineEntry[] = [
"Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.", "Promoted to Config Tech II. Led imaging workflows and expanded into scripting for endpoint provisioning.",
tags: ["sysadmin", "scripting"], 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", date: "2022-05",
title: "Config Tech I — MCPc", title: "Config Tech I — MCPc",
@@ -77,6 +108,14 @@ export const timeline: TimelineEntry[] = [
"Hardware configuration, OS imaging, and deployment at scale for enterprise clients.", "Hardware configuration, OS imaging, and deployment at scale for enterprise clients.",
tags: ["sysadmin", "hardware"], 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", date: "2021-01",
title: "We Can Code IT — Java Bootcamp", title: "We Can Code IT — Java Bootcamp",
+4 -4
View File
@@ -6,7 +6,7 @@ interface Props {
const { const {
title = "Tyler Koenig", title = "Tyler Koenig",
description = "Security operations, self-hosted infrastructure, and software projects. Homelab, Go, TypeScript, and more.", description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.",
} = Astro.props; } = Astro.props;
--- ---
@@ -27,11 +27,11 @@ const {
</script> </script>
</head> </head>
<body <body
class="bg-[var(--color-bg)] text-[var(--color-text)] min-h-screen" class="bg-[var(--color-bg)] text-[var(--color-text)] font-mono min-h-screen"
> >
<slot name="nav" /> <slot name="nav" />
<div class="max-w-[740px] mx-auto"> <div class="max-w-[960px] mx-auto">
<main class="px-4ch py-3lh"> <main>
<slot /> <slot />
</main> </main>
<slot name="footer" /> <slot name="footer" />
+3 -3
View File
@@ -2,11 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=/#projects" /> <meta http-equiv="refresh" content="0; url=/projects/" />
<link rel="canonical" href="/" /> <link rel="canonical" href="/projects/" />
<title>Redirecting...</title> <title>Redirecting...</title>
</head> </head>
<body> <body>
<p>This page moved. <a href="/#projects">/#projects</a></p> <p>This page moved. <a href="/projects/">/projects/</a></p>
</body> </body>
</html> </html>
+226 -12
View File
@@ -1,12 +1,226 @@
<!doctype html> ---
<html lang="en"> import Base from "@/layouts/Base.astro";
<head> import Nav from "@/components/Nav.astro";
<meta charset="utf-8" /> import Footer from "@/components/Footer.astro";
<meta http-equiv="refresh" content="0; url=/#homelab" /> import Widget from "@/components/Widget.astro";
<link rel="canonical" href="/" /> import { services, categoryOrder, categoryLabels } from "@/data/services";
<title>Redirecting...</title>
</head> const glanceStats = [
<body> { label: "Hypervisor", value: "Proxmox VE" },
<p>This page moved. <a href="/#homelab">/#homelab</a></p> { label: "Firewall", value: "pfSense (Netgate 1100)" },
</body> { label: "Switching", value: "TP-Link Omada (managed)" },
</html> { 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>
+5 -69
View File
@@ -3,82 +3,18 @@ import Base from "@/layouts/Base.astro";
import Nav from "@/components/Nav.astro"; import Nav from "@/components/Nav.astro";
import Footer from "@/components/Footer.astro"; import Footer from "@/components/Footer.astro";
import Hero from "@/components/Hero.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"; import Timeline from "@/components/Timeline.astro";
import Widget from "@/components/Widget.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { featuredProjects } from "@/data/projects";
import { services, categoryOrder, categoryLabels } from "@/data/services";
const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Netgate 1100)" },
{ label: "Network", value: "7 VLANs, default deny, managed switching" },
{ label: "Services", value: `${services.length} self-hosted across ${categoryOrder.length} categories` },
];
--- ---
<Base> <Base>
<Nav slot="nav" /> <Nav slot="nav" />
<Hero /> <Hero />
<div class="grid grid-cols-1 md:grid-cols-[1fr_1.2fr] border-b border-[var(--color-border)]">
<section id="projects"> <Active />
<Widget title="Projects" as="div"> <HomeServices />
<div class="flex flex-col">
{featuredProjects.map((project) => (
<ProjectCard project={project} />
))}
</div> </div>
</Widget>
</section>
<section id="journey">
<Timeline /> <Timeline />
</section>
<section id="homelab">
<Widget title="Homelab" as="div">
<p class="text-[var(--color-text-label)] leading-relaxed max-w-2xl mb-2lh">
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>
<dl class="flex flex-col mb-2lh">
{glanceStats.map(({ label, value }) => (
<div class="flex gap-2ch py-qtr-lh">
<dt class="text-[var(--color-text-dim)] w-[16ch] shrink-0">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<div class="flex flex-wrap gap-x-[3ch] gap-y-qtr-lh mb-2lh">
{categoryOrder.map((cat) => {
const count = services.filter((s) => s.category === cat).length;
return (
<span>
<span class="text-[var(--color-text-dim)]">{categoryLabels[cat]}</span>
<span class="text-[var(--color-text-label)]"> ({count})</span>
</span>
);
})}
</div>
<p class="text-[var(--color-text-label)] mb-half-lh">
Full documentation — network maps, ADRs, runbooks, and service configs.
</p>
<a
href="https://gitea.lerkolabs.com/lerko/homelab"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
gitea.lerkolabs.com/lerko/homelab →
</a>
</Widget>
</section>
<Footer slot="footer" /> <Footer slot="footer" />
</Base> </Base>
+74 -12
View File
@@ -1,12 +1,74 @@
<!doctype html> ---
<html lang="en"> import Base from "@/layouts/Base.astro";
<head> import Nav from "@/components/Nav.astro";
<meta charset="utf-8" /> import Footer from "@/components/Footer.astro";
<meta http-equiv="refresh" content="0; url=/#projects" /> import Widget from "@/components/Widget.astro";
<link rel="canonical" href="/" /> import ProjectCard from "@/components/ProjectCard.astro";
<title>Redirecting...</title> import { featuredProjects, archiveProjects } from "@/data/projects";
</head> ---
<body>
<p>This page moved. <a href="/#projects">/#projects</a></p> <Base
</body> title="Projects | Tyler Koenig"
</html> 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>
+59 -86
View File
@@ -3,22 +3,33 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@theme { @theme {
/* Slate (dark, default) */ /* Console Dark (default) */
--color-bg: #1A1B1E; --color-bg: #0a0c0f;
--color-surface: #22242A; --color-surface: #0e1116;
--color-surface-raised: #2A2D34; --color-surface-raised: #11151b;
--color-border: #33363E; --color-border: #1c2128;
--color-border-bright: #3E4148; --color-border-bright: #2a3038;
--color-text: #D4D4D8; --color-text: #c1bdb3;
--color-text-label: #9CA0AA; --color-text-fg: #e8e4d8;
--color-text-dim: #888D9B; --color-text-heading: #ffffff;
--color-link: #8CAFC8; --color-text-label: #6a675f;
--color-link-visited: #9A94AB; --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 */ /* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif;
--font-mono: "Source Code Pro", ui-monospace, monospace; --font-mono: "Source Code Pro", ui-monospace, monospace;
--font-sans: ui-sans-serif, system-ui, sans-serif;
/* Breakpoints */ /* Breakpoints */
--breakpoint-xs: 576px; --breakpoint-xs: 576px;
@@ -35,18 +46,28 @@
--spacing-2lh: 2lh; --spacing-2lh: 2lh;
--spacing-3lh: 3lh; --spacing-3lh: 3lh;
--spacing-4lh: 4lh; --spacing-4lh: 4lh;
/* Animations */
--animate-fade-in: fadeIn 120ms linear forwards;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
} }
/* Base */ /* Base */
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
font-size: 16px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: var(--font-sans); font-family: var(--font-mono);
font-variant-ligatures: none;
} }
@layer base { @layer base {
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -55,83 +76,35 @@ html {
} }
} }
/* Bone (light) overrides */ /* Console Light overrides */
:root:not(.dark) { :root:not(.dark) {
--color-bg: #FAF8F5; --color-bg: #f3f0e8;
--color-surface: #F3F0EB; --color-surface: #faf6ec;
--color-surface-raised: #EBE8E3; --color-surface-raised: #f6f2e7;
--color-border: #E0DCD5; --color-border: #d8d2c2;
--color-border-bright: #D4D0C8; --color-border-bright: #b8b2a2;
--color-text: #2C2C2C; --color-text: #3a3c40;
--color-text-label: #6B6560; --color-text-fg: #1c1e22;
--color-text-dim: #787068; --color-text-heading: #000000;
--color-link: #4A6B8A; --color-text-label: #8a857a;
--color-link-visited: #6B6080; --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;
} }
/* Link underlines */ /* Default transitions — linear, fast */
a {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: var(--color-border-bright);
}
a:hover {
text-decoration-color: currentColor;
}
a[target="_blank"] {
color: var(--color-link);
}
a[target="_blank"]:visited {
color: var(--color-link-visited);
}
a[target="_blank"]:hover {
color: var(--color-link);
text-decoration-color: currentColor;
}
/* Focus states */
a:focus-visible {
text-decoration-color: currentColor;
outline: 2px solid var(--color-border-bright);
outline-offset: 2px;
}
button:focus-visible {
outline: 2px solid var(--color-border-bright);
outline-offset: 2px;
}
/* Default transitions */
a, a,
button { button {
transition: color 120ms linear, border-color 120ms linear, transition: color 120ms linear, border-color 120ms linear,
opacity 120ms linear, text-decoration-color 120ms linear, background-color 120ms linear, opacity 120ms linear;
outline-color 120ms linear;
}
/* Section anchors — clear sticky nav, visual rhythm */
section[id] {
scroll-margin-top: 3.5rem;
padding-top: 2lh;
border-top: 1px solid var(--color-border);
}
section[id]:first-of-type {
border-top: none;
padding-top: 0;
}
@media print {
html {
font-family: var(--font-serif);
font-size: 11pt;
line-height: 1.3;
color: #000;
background: #fff;
}
header, footer, button { display: none; }
a { color: inherit; text-decoration: underline; }
main { max-width: none; padding: 0; }
section, div, li { margin-bottom: 0.25em; padding-bottom: 0; }
h1, h2, h3 { margin-bottom: 0.15em; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {