Typography: - The codebase contained exactly one font-family declaration (LoadingIndicator), naming 'Cinzel Decorative' and 'Trajan Pro' — neither loaded, bundled, or present in static/. So every word on the site, in every component, rendered in the browser's default serif. For a site whose whole premise is atmosphere, that was the largest craft gap. - Now a display/body pairing: self-hosted Cinzel Variable for the name, headings, welcome title and tooltip titles; a system stack for body and UI copy, which costs nothing to load and is far more legible than Times at the 13-14px the modal and stats panel use. - @fontsource-variable/cinzel ships two subsets with unicode-range, so only the 25.3KB latin file is fetched for this content (verified — latin-ext is emitted but never requested). font-display: swap, so text never blocks. Contrast (WCAG 1.4.3): - Measured every grey against the panel background (#1a1a2e) rather than eyeballing it. #666 is 2.97:1 and fails AA for normal text; it was used for the modal close glyph, the tooltip hint, the branch count and the skill years. Replaced with --text-subtle (#9a9aa8, 6.1:1). - Correction to the audit: #888 was listed as failing. It measures 4.81:1 and passes. Tokenised for consistency, not because it was broken. --text-muted (#a8a8b8) is 7.3:1, not the ~9:1 I claimed. Tokens: - z-index was nine ad-hoc literals across seven files, with LoadingIndicator and the modal backdrop both on 1000. Now a named scale, and no raw z-index literal survives anywhere in src/ (verified against the live stylesheet). - Added spacing, radius, motion and type tokens alongside the existing colour ones, so the system covers more than colour. Note: package-lock.json in this commit also carries pre-existing churn from before this work; adding the font dependency entangled the two. Verified in the production build: Cinzel loads and applies to headings, computed body font resolves to system-ui, one font file fetched, zero raw z-index literals in the rendered CSS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
8.5 KiB
Svelte
251 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
// Self-hosted display face. Ships two subsets with unicode-range, so only
|
|
// the 26KB latin file is fetched for this content; font-display: swap is
|
|
// already set by the package, so text never blocks on it.
|
|
import '@fontsource-variable/cinzel';
|
|
import { onMount } from 'svelte';
|
|
import favicon from '$lib/assets/favicon.ico';
|
|
import '../app.css';
|
|
import { treeNodes, type OriginNode, type SkillNode, type EducationNode } from '$lib/data';
|
|
|
|
const { children } = $props();
|
|
|
|
// schema.org Person structured data, generated from data.ts (single source of
|
|
// truth) so it stays in sync. Ties this domain to the LinkedIn/GitHub identity.
|
|
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
|
const knowsAbout = treeNodes
|
|
.filter((n): n is SkillNode => n.category === 'skills' && !!n.proficiency)
|
|
.map((n) => n.label);
|
|
const edu = treeNodes.find(
|
|
(n): n is EducationNode => n.category === 'education' && !!n.institution
|
|
);
|
|
|
|
const personJsonLd = JSON.stringify({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Person',
|
|
name: origin?.fullName,
|
|
jobTitle: origin?.jobTitle,
|
|
description: origin?.aboutMe,
|
|
url: 'https://tehriehldeal.com',
|
|
image: 'https://tehriehldeal.com/headshot.jpg',
|
|
email: origin?.email,
|
|
address: {
|
|
'@type': 'PostalAddress',
|
|
addressLocality: 'Kent',
|
|
addressRegion: 'WA',
|
|
addressCountry: 'US'
|
|
},
|
|
alumniOf: edu ? { '@type': 'CollegeOrUniversity', name: edu.institution } : undefined,
|
|
knowsAbout,
|
|
sameAs: [origin?.linkedIn, origin?.github].filter(Boolean)
|
|
// Escape '<' so the JSON can't break out of the <script> element.
|
|
}).replace(/</g, '\\u003c');
|
|
|
|
// Wrap in the <script> tag here (close tag split across a concat) so the
|
|
// literal closing tag never appears verbatim in source — that would either
|
|
// terminate this block early or trip the Svelte ESLint parser.
|
|
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
|
|
|
// Set when a new worker is installed and waiting; drives the refresh prompt.
|
|
let updateReady = $state<(() => void) | null>(null);
|
|
|
|
onMount(() => {
|
|
if (!('serviceWorker' in navigator)) return;
|
|
|
|
let reloading = false;
|
|
// The worker no longer calls skipWaiting() on install, so a new version
|
|
// waits instead of taking over a page running the previous deploy's JS.
|
|
// When it does take over, reload once so page and worker agree.
|
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
if (reloading) return;
|
|
reloading = true;
|
|
location.reload();
|
|
});
|
|
|
|
navigator.serviceWorker
|
|
.register('/service-worker.js', { type: 'module' })
|
|
.then((registration) => {
|
|
// The old code's comment claimed updates were checked periodically, but
|
|
// nothing ever called update(). Without this a long-lived tab only
|
|
// re-checks on navigation.
|
|
const hourly = setInterval(
|
|
() => {
|
|
registration.update().catch(() => {});
|
|
},
|
|
60 * 60 * 1000
|
|
);
|
|
window.addEventListener('pagehide', () => clearInterval(hourly));
|
|
|
|
registration.addEventListener('updatefound', () => {
|
|
const newWorker = registration.installing;
|
|
if (!newWorker) return;
|
|
|
|
newWorker.addEventListener('statechange', () => {
|
|
if (newWorker.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
|
// A new version is ready and waiting. Previously this only logged,
|
|
// and in DEV only, so the SKIP_WAITING handler was dead code.
|
|
updateReady = () => newWorker.postMessage({ type: 'SKIP_WAITING' });
|
|
});
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.warn('Service Worker registration failed:', error);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<!--
|
|
With JS disabled the canvas never mounts and SeoContent stays clipped to
|
|
1x1px, so the page renders pure black. This reveals the text version with
|
|
no JS involved.
|
|
-->
|
|
<noscript>
|
|
<style>
|
|
.sr-only {
|
|
position: static !important;
|
|
width: auto !important;
|
|
height: auto !important;
|
|
margin: 0 !important;
|
|
overflow: visible !important;
|
|
clip: auto !important;
|
|
white-space: normal !important;
|
|
max-width: 60rem;
|
|
padding: 2rem 1.5rem;
|
|
color: #e8e8f0;
|
|
line-height: 1.6;
|
|
}
|
|
html,
|
|
body {
|
|
overflow: auto !important;
|
|
}
|
|
.sr-only a {
|
|
color: #ffd700;
|
|
}
|
|
.sr-only h1,
|
|
.sr-only h2,
|
|
.sr-only h3 {
|
|
color: #ffd700;
|
|
margin: 1.25rem 0 0.5rem;
|
|
}
|
|
.sr-only ul {
|
|
padding-left: 1.25rem;
|
|
}
|
|
main {
|
|
display: none;
|
|
}
|
|
</style>
|
|
</noscript>
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" href={favicon} />
|
|
|
|
<!-- PWA Meta Tags -->
|
|
<meta name="application-name" content="Atlas of Skills" />
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
<meta name="apple-mobile-web-app-title" content="Atlas of Skills" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="theme-color" content="#ffd700" />
|
|
|
|
<!-- Web App Manifest -->
|
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
|
|
<!-- Apple Touch Icons -->
|
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
|
|
|
<!-- PWA Icons -->
|
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/pwa-192x192.png" />
|
|
<link rel="icon" type="image/png" sizes="512x512" href="/icons/pwa-512x512.png" />
|
|
|
|
<!-- SEO Meta Tags -->
|
|
<link rel="canonical" href="https://tehriehldeal.com/" />
|
|
<meta
|
|
name="description"
|
|
content="Kevin Riehl — Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
|
|
/>
|
|
<meta
|
|
name="keywords"
|
|
content="Kevin Riehl, software engineer, full-stack developer, React, Node.js, TypeScript, portfolio, interactive resume, skill tree"
|
|
/>
|
|
<meta name="author" content="Kevin Riehl" />
|
|
|
|
<!-- Open Graph Meta Tags for social sharing -->
|
|
<meta property="og:type" content="website" />
|
|
<meta
|
|
property="og:title"
|
|
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
|
|
/>
|
|
<meta
|
|
property="og:description"
|
|
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
|
|
/>
|
|
<meta property="og:url" content="https://tehriehldeal.com" />
|
|
<meta property="og:site_name" content="Atlas of Skills" />
|
|
<meta property="og:image" content="https://tehriehldeal.com/og-preview.jpg" />
|
|
<meta property="og:image:secure_url" content="https://tehriehldeal.com/og-preview.jpg" />
|
|
<meta property="og:image:type" content="image/jpeg" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta property="og:image:alt" content="Kevin Riehl - The Atlas of Skills interactive portfolio" />
|
|
|
|
<!-- Twitter Card Meta Tags -->
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta
|
|
name="twitter:title"
|
|
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
|
|
/>
|
|
<meta
|
|
name="twitter:description"
|
|
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
|
|
/>
|
|
<meta name="twitter:image" content="https://tehriehldeal.com/og-preview.jpg" />
|
|
<meta
|
|
name="twitter:image:alt"
|
|
content="Kevin Riehl - The Atlas of Skills interactive portfolio"
|
|
/>
|
|
|
|
<!-- Structured data (schema.org Person). Safe: built from our own data.ts
|
|
with '<' escaped; not user input. -->
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
{@html personLdScript}
|
|
</svelte:head>
|
|
|
|
{@render children()}
|
|
|
|
{#if updateReady}
|
|
<div class="update-toast" role="status">
|
|
<span>A new version is available.</span>
|
|
<button onclick={() => updateReady?.()}>Refresh</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.update-toast {
|
|
position: fixed;
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
z-index: var(--z-toast);
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
border: 1px solid var(--color-gold);
|
|
border-radius: 8px;
|
|
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
color: #e8e8f0;
|
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
.update-toast button {
|
|
padding: 0.35rem 0.75rem;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
background: var(--color-gold);
|
|
color: #12121a;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|