Start the rebuild: note schema, vault kit and data migration

Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* MobileMenu.tsx
* ---------------------------------------------------------------------------
* React island that powers the hamburger menu on small screens. Renders the
* same two-group nav structure as the desktop bar (imported via a data prop so
* the source of truth stays in site.ts).
*
* Accessibility: the button is aria-expanded/aria-controls wired to the panel,
* Escape closes, clicking a link closes, and focus is returned to the toggle.
* Animations respect prefers-reduced-motion (Framer Motion reads the media query
* automatically when we pass the transition).
*/
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import BrandIcon from '@components/brand/BrandIcon';
export interface MobileNavItem {
label: string;
href: string;
icon: string;
}
export interface MobileNavGroup {
items: MobileNavItem[];
}
interface Props {
groups: MobileNavGroup[];
/** Current path for active link highlighting. */
currentPath: string;
}
// Patiun brand icons, resolved by the same names the nav config uses.
function Icon({ name, className }: { name: string; className?: string }) {
return <BrandIcon name={name} className={className} />;
}
function isActive(href: string, path: string): boolean {
if (href === '/') return path === '/';
return path === href || path.startsWith(href + '/');
}
export default function MobileMenu({ groups, currentPath }: Props) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
// Escape closes the menu; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open]);
return (
<div className="md:hidden">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? 'Close menu' : 'Open menu'}
className="relative z-[60] inline-flex h-10 w-10 items-center justify-center rounded-lg border"
style={{ color: 'var(--color-starlight)', borderColor: 'color-mix(in srgb, var(--color-gold) 45%, transparent)' }}
>
{open ? (
<BrandIcon name="close" size={22} />
) : (
<BrandIcon name="menu" size={22} />
)}
</button>
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
/>
{/* Panel */}
<motion.nav
id="mobile-menu"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'tween', duration: 0.28 }}
className="fixed right-0 top-0 z-50 flex h-[100dvh] w-[min(82vw,22rem)] flex-col gap-6 overflow-y-auto p-6 pt-20 shadow-2xl"
style={{
// Night-sky drawer to match the nav it opens from.
backgroundColor: 'var(--color-midnight)',
borderLeft: '1px solid color-mix(in srgb, var(--color-gold) 45%, transparent)',
}}
>
{groups.map((group, gi) => (
<ul key={gi} className="flex flex-col gap-1">
{group.items.map((item) => {
const active = isActive(item.href, currentPath);
return (
<li key={item.href}>
<a
href={item.href}
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-3 text-lg font-medium transition-colors"
style={{
color: active ? 'var(--color-teal-bright)' : 'var(--color-starlight)',
backgroundColor: active
? 'color-mix(in srgb, var(--color-teal-bright) 14%, transparent)'
: 'transparent',
}}
>
<Icon name={item.icon} />
{item.label}
</a>
</li>
);
})}
</ul>
))}
</motion.nav>
</>
)}
</AnimatePresence>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
---
/**
* NavBar.astro
* ---------------------------------------------------------------------------
* Fixed top navigation. Two visual groups separated by a subtle divider, per
* the spec: left = "who I am" (Home, About), right = "what I do" (Professional,
* Interests, Contact). The logo block shows the name + tagline.
*
* On scroll a tiny inline script adds a `scrolled` class -> backdrop blur + a
* hairline border + subtle shadow, so content stays readable under the bar.
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
*/
import Icon from "@components/Icon.astro";
import MobileMenu from "./MobileMenu.tsx";
import { navGroups, siteIdentity } from "@lib/site";
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === "/" ? path === "/" : path === href || path.startsWith(href + "/");
interface Props {
/** True on the Home page. When false, the nav carries the hero-sky transition
* name so the Home hero can morph into / out of it (expand/collapse). */
isHome?: boolean;
}
const { isHome = false } = Astro.props;
// On non-Home pages the NavBar is the morph target for the Home hero.
const heroSkyClass = isHome ? "" : "vt-hero-sky";
---
<header
id="site-nav"
data-nav
class:list={[
"on-sky fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300",
heroSkyClass,
]}
>
<div
class="border-b border-transparent transition-colors duration-300"
data-nav-inner
>
<nav
class="container-prose flex h-20 items-center justify-between gap-4"
aria-label="Primary"
>
<!-- Logo / identity: the Patiun seal (glyph in the gold degree ring) beside
the Cinzel name and the site tagline. The "a Patiun LLC company" line
lives in the footer with the copyright. -->
<a
href="/"
class="group flex items-center gap-3 leading-tight"
aria-label={`${siteIdentity.name} home`}
>
<img
src="/brand/patiun-seal.svg"
alt=""
width="72"
height="72"
class="h-[72px] w-[72px] shrink-0 transition-transform duration-700 group-hover:rotate-[30deg]"
/>
<span class="flex flex-col">
<span
class="font-display text-base font-bold uppercase tracking-[0.14em]"
style="color: var(--color-starlight);"
>
{siteIdentity.name}
</span>
<span
class="hidden font-eyebrow text-[0.62rem] uppercase tracking-[0.18em] sm:block"
style="color: var(--color-gold-bright);"
>
{siteIdentity.tagline}
</span>
</span>
</a>
<!-- Desktop nav: two groups with a divider -->
<div class="hidden items-center gap-5 md:flex">
{
navGroups.map((group, gi) => (
<div
class:list={[
"flex items-center gap-1",
{ "border-l pl-5": gi > 0 },
]}
style={gi > 0 ? "border-color: color-mix(in srgb, var(--color-gold) 45%, transparent);" : ""}
>
{group.items.map((item) => {
const active = isActive(item.href);
return (
<a
href={item.href}
class:list={[
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
]}
style={
active
? "color: var(--color-accent);"
: "color: var(--color-starlight); opacity: 0.85;"
}
onmouseover="this.style.opacity='1'"
onmouseout="this.style.opacity='0.85'"
aria-current={active ? "page" : undefined}
>
<Icon name={item.icon} class="h-4 w-4" />
{item.label}
</a>
);
})}
</div>
))
}
<div class="ml-1"></div>
</div>
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
<div class="flex items-center gap-2 md:hidden">
<MobileMenu client:idle groups={navGroups} currentPath={path} />
</div>
</nav>
</div>
</header>
<style>
/* The nav bar ALWAYS carries the dark purple/blue nebula treatment + lighter
text, so it reads as a continuous band of night sky across the top of every
page (matching the hero fields). The scrolled state just adds blur + a
hairline border + shadow for definition over content. */
[data-nav-inner] {
background: linear-gradient(
180deg,
rgba(14, 21, 34, 0.92) 0%,
rgba(19, 28, 43, 0.86) 100%
);
}
/* Scrolled state: stronger blur + hairline border + shadow. */
[data-nav].scrolled [data-nav-inner] {
backdrop-filter: saturate(150%) blur(12px);
-webkit-backdrop-filter: saturate(150%) blur(12px);
border-bottom-color: color-mix(in srgb, var(--color-gold) 35%, transparent);
box-shadow: 0 8px 30px -20px rgba(8, 12, 20, 0.7);
}
/* A subtle violet/gold nebula glow behind the nav, fixed to the top. */
[data-nav]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(
60% 120% at 85% -20%,
rgba(106, 47, 224, 0.22),
transparent 70%
),
radial-gradient(
50% 120% at 10% -10%,
rgba(201, 162, 74, 0.1),
transparent 70%
);
}
</style>
<script>
/**
* Add the `scrolled` class once the page has scrolled a few px. Re-bind on
* View Transitions navigations so it keeps working across page changes.
*/
function setupNavScroll() {
const nav = document.getElementById("site-nav");
if (!nav) return;
const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
}
setupNavScroll();
document.addEventListener("astro:page-load", setupNavScroll);
</script>
+73
View File
@@ -0,0 +1,73 @@
/**
* ThemeToggle.tsx
* ---------------------------------------------------------------------------
* React island that toggles `data-theme` on <html> between 'light' and 'dark'
* and persists the choice to localStorage (key matches theme-init.ts).
*
* Renders mdi sun/moon SVGs inline (no extra requests). The initial state is
* read from the DOM at mount so it always matches what theme-init set, even
* across View Transitions navigations.
*/
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'gk-theme';
type Theme = 'light' | 'dark';
function currentTheme(): Theme {
if (typeof document === 'undefined') return 'light';
return (document.documentElement.getAttribute('data-theme') as Theme) ?? 'light';
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
// Read the real current theme on mount and after View Transitions navigations.
useEffect(() => {
setTheme(currentTheme());
setMounted(true);
}, []);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* ignore private-mode write failures */
}
setTheme(next);
}
const isDark = theme === 'dark';
return (
<button
type="button"
onClick={toggle}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
title={isDark ? 'Light mode' : 'Dark mode'}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border transition-colors hover:bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)]"
style={{
color: 'var(--color-text)',
borderColor: 'var(--color-border)',
}}
>
{/* Sun (shown in dark mode -> click to go light) */}
{mounted && isDark && (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0-5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1zm0 17a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1zM3 11h2a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2zm16 0h2a1 1 0 1 1 0 2h-2a1 1 0 1 1 0-2zM5.64 5.64a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 1 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm9.9 9.9a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 0 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm1.42-9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 1 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0zm-9.9 9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 0 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0z" />
</svg>
)}
{/* Moon (shown in light mode -> click to go dark) */}
{mounted && !isDark && (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
)}
{/* Placeholder keeps button sized before mount to avoid layout shift. */}
{!mounted && <span style={{ width: 20, height: 20, display: 'inline-block' }} />}
</button>
);
}