5 Commits

Author SHA1 Message Date
lerko da61cbba5d feat(content): add uptop, reorder projects, archive open-pact, fix VLAN count
Build and Deploy / deploy (push) Successful in 1m18s
Add uptop (uptime monitoring TUI) as featured project. Reorder nib
above homelab, update nib stack and URL. Move open-pact to archive.
Update portfolio description (Nginx → Caddy). Fix VLAN count 8 → 7.
Trim Hero bio.
2026-05-31 18:17:44 -04:00
lerko 9037461d27 fix(content): consolidate homelab spacing, dynamic service count in hero
Collapse homelab into single Widget to match Projects/Journey spacing.
Hero service count now reads from services data instead of hardcoded 30+.
2026-05-27 17:33:16 -04:00
lerko 4a2ea3c32d Merge pull request 'feat(design): external link colors, section dividers, nav scroll-spy' (#10) from feat/polish-reader-view into staging
Reviewed-on: #10
2026-05-27 18:31:06 +00:00
lerko 6db5b36989 feat(design): external link colors, section dividers, nav scroll-spy
Dusty blue/violet link colors for external links (target="_blank") with
visited state. Both dark and light theme tokens. Mailto links included.

Section dividers via border-top on anchored sections. Scroll-margin-top
clears sticky nav on anchor jumps.

Nav scroll-spy highlights active section using IntersectionObserver +
scroll listener fallback for bottom-of-page edge case.
2026-05-26 20:43:56 -04:00
lerko 38326cc7ec refactor(content): slim homelab to highlight, trim redundant timeline entries
Homelab: drop VLAN table, services list, and ADRs — full docs live in
the homelab repo. Keep condensed at-a-glance stats, category counts,
and Gitea link.

Timeline: drop project entries (covered by Projects section),
lerkolabs.com (duplicate of portfolio), and PC Build. 14 → 9 entries.

Also: hide nav anchor links on mobile, drop Hero section anchors,
update meta description, add print stylesheet.
2026-05-26 17:39:19 -04:00
9 changed files with 148 additions and 227 deletions
+5 -4
View File
@@ -10,7 +10,7 @@ const year = new Date().getFullYear();
href="https://github.com/lerko96" href="https://github.com/lerko96"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline hover:text-[var(--color-text)]" class="underline"
> >
GitHub GitHub
</a> </a>
@@ -18,7 +18,7 @@ const year = new Date().getFullYear();
href="https://gitea.lerkolabs.com/lerko" href="https://gitea.lerkolabs.com/lerko"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline hover:text-[var(--color-text)]" class="underline"
> >
Gitea Gitea
</a> </a>
@@ -26,13 +26,14 @@ const year = new Date().getFullYear();
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 hover:text-[var(--color-text)]" class="underline"
> >
LinkedIn LinkedIn
</a> </a>
<a <a
href="mailto:tyler@lerkolabs.com" href="mailto:tyler@lerkolabs.com"
class="underline hover:text-[var(--color-text)]" target="_blank"
class="underline"
> >
Email Email
</a> </a>
+11 -12
View File
@@ -1,3 +1,7 @@
---
import { services } from "@/data/services";
---
<section class="mb-3lh"> <section class="mb-3lh">
<h1 class="text-xl font-bold mb-half-lh">Tyler Koenig</h1> <h1 class="text-xl font-bold mb-half-lh">Tyler Koenig</h1>
<p class="text-[var(--color-text-label)] mb-1lh"> <p class="text-[var(--color-text-label)] mb-1lh">
@@ -5,9 +9,9 @@
</p> </p>
<p class="text-[var(--color-text-label)] leading-relaxed mb-1lh"> <p class="text-[var(--color-text-label)] leading-relaxed mb-1lh">
Homelab runs 30+ services across segmented VLANs — pfSense, Authentik SSO, Homelab runs {services.length} services across segmented VLANs — pfSense, Authentik SSO,
full observability stack. Write software too: mobile apps, Go backends, open full observability stack. Write software too: mobile apps, Go backends, open
protocols. Daily drivers, all of it. protocols.
</p> </p>
<nav class="flex flex-wrap items-center gap-x-2ch gap-y-half-lh text-[var(--color-text-label)]"> <nav class="flex flex-wrap items-center gap-x-2ch gap-y-half-lh text-[var(--color-text-label)]">
@@ -15,7 +19,7 @@
href="https://github.com/lerko96" href="https://github.com/lerko96"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline hover:text-[var(--color-text)]" class="underline"
> >
GitHub GitHub
</a> </a>
@@ -23,7 +27,7 @@
href="https://gitea.lerkolabs.com/lerko" href="https://gitea.lerkolabs.com/lerko"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline hover:text-[var(--color-text)]" class="underline"
> >
Gitea Gitea
</a> </a>
@@ -31,21 +35,16 @@
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 hover:text-[var(--color-text)]" class="underline"
> >
LinkedIn LinkedIn
</a> </a>
<a <a
href="mailto:tyler@lerkolabs.com" href="mailto:tyler@lerkolabs.com"
class="underline hover:text-[var(--color-text)]" target="_blank"
class="underline"
> >
Email Email
</a> </a>
</nav> </nav>
<nav class="flex flex-wrap items-center gap-x-2ch gap-y-half-lh text-[var(--color-text-dim)] mt-half-lh">
<a href="#projects" class="underline hover:text-[var(--color-text)]">Projects</a>
<a href="#journey" class="underline hover:text-[var(--color-text)]">Journey</a>
<a href="#homelab" class="underline hover:text-[var(--color-text)]">Homelab</a>
</nav>
</section> </section>
+51 -3
View File
@@ -3,9 +3,9 @@
<a href="/" class="font-semibold">Tyler Koenig</a> <a href="/" class="font-semibold">Tyler Koenig</a>
<div class="flex items-center gap-2ch"> <div class="flex items-center gap-2ch">
<a href="#projects" class="text-[var(--color-text-label)] hover:text-[var(--color-text)]">projects</a> <a href="#projects" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">projects</a>
<a href="#journey" class="text-[var(--color-text-label)] hover:text-[var(--color-text)]">journey</a> <a href="#journey" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">journey</a>
<a href="#homelab" class="text-[var(--color-text-label)] hover:text-[var(--color-text)]">homelab</a> <a href="#homelab" data-nav-link class="hidden xs:inline text-[var(--color-text-label)] hover:text-[var(--color-text)]">homelab</a>
<button <button
data-theme-toggle data-theme-toggle
@@ -38,4 +38,52 @@
}); });
updateTheme(); updateTheme();
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>
+1 -1
View File
@@ -15,7 +15,7 @@ const { project } = Astro.props;
href={project.githubUrl} href={project.githubUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-semibold underline hover:text-[var(--color-text-label)]" class="font-semibold underline"
> >
{project.title} {project.title}
</a> </a>
+24 -16
View File
@@ -13,6 +13,25 @@ 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",
@@ -27,33 +46,22 @@ export const projects: Project[] = [
slug: "portfolio", slug: "portfolio",
title: "portfolio", title: "portfolio",
description: description:
"Astro static site, self-hosted in a DMZ LXC behind Nginx, deployed via Gitea Actions CI.", "Astro static site, self-hosted in a DMZ LXC behind Caddy, deployed via Gitea Actions CI.",
tags: ["Astro", "Dockerfile", "Tailwind", "nginx", "Caddy"], tags: ["Astro", "Typescript", "Dockerfile", "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: description: "Open protocol for AI agent identity, delegation, and portable memory. Ed25519 keypair identity, signed delegation",
"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: "featured", tier: "archive",
year: 2026, year: 2026,
}, },
// --- Archive ---
{ {
slug: "helm", slug: "helm",
title: "helm", title: "helm",
-39
View File
@@ -22,30 +22,6 @@ 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",
@@ -93,13 +69,6 @@ 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",
@@ -108,14 +77,6 @@ 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",
+1 -1
View File
@@ -6,7 +6,7 @@ interface Props {
const { const {
title = "Tyler Koenig", title = "Tyler Koenig",
description = "SOC Helpdesk I by day, building beyond the title. Projects in AI tooling, mobile apps, infrastructure, and more.", description = "Security operations, self-hosted infrastructure, and software projects. Homelab, Go, TypeScript, and more.",
} = Astro.props; } = Astro.props;
--- ---
+15 -151
View File
@@ -12,74 +12,8 @@ import { services, categoryOrder, categoryLabels } from "@/data/services";
const glanceStats = [ const glanceStats = [
{ label: "Hypervisor", value: "Proxmox VE" }, { label: "Hypervisor", value: "Proxmox VE" },
{ label: "Firewall", value: "pfSense (Netgate 1100)" }, { label: "Firewall", value: "pfSense (Netgate 1100)" },
{ label: "Switching", value: "TP-Link Omada (managed)" }, { label: "Network", value: "7 VLANs, default deny, managed switching" },
{ label: "ISP", value: "AT&T Fiber 1 Gbps" }, { label: "Services", value: `${services.length} self-hosted across ${categoryOrder.length} categories` },
{ 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.",
},
]; ];
--- ---
@@ -103,18 +37,15 @@ const adrs = [
</section> </section>
<section id="homelab"> <section id="homelab">
<div class="mb-4lh"> <Widget title="Homelab" as="div">
<h2 class="text-xl font-bold mb-half-lh">Homelab</h2> <p class="text-[var(--color-text-label)] leading-relaxed max-w-2xl mb-2lh">
<p class="text-[var(--color-text-label)] leading-relaxed max-w-2xl">
Personal infrastructure environment for learning, self-hosting, and Personal infrastructure environment for learning, self-hosting, and
operational practice. Running 24/7 on production-grade hardware with operational practice. Running 24/7 on production-grade hardware with
real network segmentation, SSO, monitoring, and IaC-style real network segmentation, SSO, monitoring, and IaC-style
documentation. documentation.
</p> </p>
</div>
<Widget title="Overview" as="div"> <dl class="flex flex-col mb-2lh">
<dl class="flex flex-col">
{glanceStats.map(({ label, value }) => ( {glanceStats.map(({ label, value }) => (
<div class="flex gap-2ch py-qtr-lh"> <div class="flex gap-2ch py-qtr-lh">
<dt class="text-[var(--color-text-dim)] w-[16ch] shrink-0">{label}</dt> <dt class="text-[var(--color-text-dim)] w-[16ch] shrink-0">{label}</dt>
@@ -122,98 +53,31 @@ const adrs = [
</div> </div>
))} ))}
</dl> </dl>
</Widget>
<Widget <div class="flex flex-wrap gap-x-[3ch] gap-y-qtr-lh mb-2lh">
title="Network"
meta="8 segments, default deny"
as="div"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-[var(--color-border)]">
<th class="text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch]">
Segment
</th>
<th class="text-[var(--color-text-dim)] text-left py-qtr-lh pr-[3ch]">
Name
</th>
<th class="text-[var(--color-text-dim)] text-left py-qtr-lh">
Purpose
</th>
</tr>
</thead>
<tbody>
{vlans.map((v) => (
<tr class="border-b border-[var(--color-border)]">
<td class="py-half-lh pr-[3ch]">{v.id}</td>
<td class="py-half-lh pr-[3ch]">{v.name}</td>
<td class="text-[var(--color-text-label)] py-half-lh">{v.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</Widget>
<Widget title="Services" as="div">
<div class="flex flex-col gap-2lh">
{categoryOrder.map((cat) => { {categoryOrder.map((cat) => {
const catServices = services.filter((s) => s.category === cat && !s.hidden); const count = services.filter((s) => s.category === cat).length;
return ( return (
<div> <span>
<h3 class="text-[var(--color-text-dim)] font-semibold mb-half-lh"> <span class="text-[var(--color-text-dim)]">{categoryLabels[cat]}</span>
{categoryLabels[cat]} <span class="text-[var(--color-text-label)]"> ({count})</span>
</h3> </span>
<ul class="flex flex-col">
{catServices.map((svc) => (
<li class="py-qtr-lh">
<span class="font-semibold">{svc.name}</span>
<span class="text-[var(--color-text-label)]"> — {svc.description}</span>
</li>
))}
</ul>
</div>
); );
})} })}
</div> </div>
</Widget>
<Widget
title="ADRs"
meta="why things are configured the way they are"
as="div"
>
<div class="flex flex-col gap-2lh">
{adrs.map((adr) => (
<div>
<h3 class="font-semibold mb-half-lh">{adr.title}</h3>
<p class="text-[var(--color-text-label)] leading-relaxed mb-half-lh">
<strong>Decision:</strong> {adr.decision}
</p>
<p class="text-[var(--color-text-label)] leading-relaxed">
<strong>Why:</strong> {adr.why}
</p>
</div>
))}
</div>
</Widget>
<div class="mb-4lh">
<h2 class="text-lg font-semibold mb-half-lh">Docs</h2>
<p class="text-[var(--color-text-label)] mb-half-lh"> <p class="text-[var(--color-text-label)] mb-half-lh">
VLAN maps, runbooks, service registry, config exports, and setup guides. Full documentation — network maps, ADRs, runbooks, and service configs.
</p> </p>
<a <a
href="https://gitea.lerkolabs.com/lerko/homelab" href="https://gitea.lerkolabs.com/lerko/homelab"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="underline hover:text-[var(--color-text-label)]" class="underline"
> >
gitea.lerkolabs.com/lerko/homelab gitea.lerkolabs.com/lerko/homelab
</a> </a>
</div> </Widget>
</section> </section>
<Footer slot="footer" /> <Footer slot="footer" />
+40
View File
@@ -12,6 +12,8 @@
--color-text: #D4D4D8; --color-text: #D4D4D8;
--color-text-label: #9CA0AA; --color-text-label: #9CA0AA;
--color-text-dim: #888D9B; --color-text-dim: #888D9B;
--color-link: #8CAFC8;
--color-link-visited: #9A94AB;
/* Typography */ /* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -63,6 +65,8 @@ html {
--color-text: #2C2C2C; --color-text: #2C2C2C;
--color-text-label: #6B6560; --color-text-label: #6B6560;
--color-text-dim: #787068; --color-text-dim: #787068;
--color-link: #4A6B8A;
--color-link-visited: #6B6080;
} }
/* Link underlines */ /* Link underlines */
@@ -74,6 +78,16 @@ a {
a:hover { a:hover {
text-decoration-color: currentColor; 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 */ /* Focus states */
a:focus-visible { a:focus-visible {
@@ -94,6 +108,32 @@ button {
outline-color 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) {
*, *::before, *::after { *, *::before, *::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;