mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user