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,55 @@
|
||||
---
|
||||
/**
|
||||
* Icon.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inline icon renderer. Names resolve to the Patiun brand icon set first
|
||||
* (src/lib/brand-icons.ts: stroke icons with a gold star accent), via either a
|
||||
* brand name ("dice") or the mdi name the call sites already use ("dice-multiple").
|
||||
* Anything not in the brand set, which is only third-party logos like GitHub or
|
||||
* LinkedIn, falls back to mdi (Material Design Icons) path data. Either way the
|
||||
* output is one inline <svg> that inherits `currentColor`.
|
||||
*
|
||||
* Usage: <Icon name="briefcase" class="h-6 w-6" />
|
||||
*/
|
||||
import mdi from '@iconify-json/mdi/icons.json';
|
||||
import { brandIconBody } from '@lib/brand-icons';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon name WITHOUT prefix, e.g. "briefcase". */
|
||||
name: string;
|
||||
/** Accessible label. Set when the icon conveys meaning alone (not decorative). */
|
||||
title?: string;
|
||||
class?: string;
|
||||
/** Override default 1em sizing (rarely needed; prefer class utilities). */
|
||||
size?: number | string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { name, title, class: className, size, style } = Astro.props;
|
||||
|
||||
const brand = brandIconBody(name);
|
||||
const icon = brand
|
||||
? undefined
|
||||
: (mdi as { icons: Record<string, { body: string }> }).icons[name];
|
||||
if (!brand && !icon) {
|
||||
// Surface missing-icon names during dev so typos are caught immediately.
|
||||
console.warn(`[Icon] unknown mdi icon: "${name}"`);
|
||||
}
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size ?? '1em'}
|
||||
height={size ?? '1em'}
|
||||
viewBox="0 0 24 24"
|
||||
fill={brand ? 'none' : undefined}
|
||||
stroke={brand ? 'currentColor' : undefined}
|
||||
stroke-width={brand ? 1.6 : undefined}
|
||||
stroke-linecap={brand ? 'round' : undefined}
|
||||
stroke-linejoin={brand ? 'round' : undefined}
|
||||
class={className}
|
||||
style={style}
|
||||
aria-hidden={title ? undefined : 'true'}
|
||||
role={title ? 'img' : undefined}
|
||||
set:html={(title ? `<title>${title}</title>` : '') + (brand ?? icon?.body ?? '')}
|
||||
/>
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
/**
|
||||
* ObfuscatedEmail.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders an email address as a clickable link without ever putting the
|
||||
* plaintext address into the HTML source. The address is base64-encoded at
|
||||
* render time and stored in a data attribute; a tiny inline script decodes it
|
||||
* on click and builds the mailto: href at that moment. Scrapers that simply
|
||||
* grep the HTML for `@` or `mailto:` see nothing useful.
|
||||
*
|
||||
* This is not cryptographically secure obfuscation (base64 is trivially
|
||||
* reversible), but it defeats the vast majority of dumb harvesters that don't
|
||||
* run JS. Combined with robots.txt + noindex on /contact, it raises the cost
|
||||
* of harvesting enough to be worthwhile.
|
||||
*
|
||||
* <noscript> fallback shows a non-clickable prompt directing visitors to the
|
||||
* contact form, so the page isn't broken for the rare no-JS visitor.
|
||||
*
|
||||
* Usage: <ObfuscatedEmail address="contact@gregkilmer.com" />
|
||||
*/
|
||||
interface Props {
|
||||
address: string;
|
||||
/** Optional visible label; defaults to the address (decoded client-side). */
|
||||
label?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { address, label, class: className, style } = Astro.props;
|
||||
|
||||
// Encode at render time. btoa needs a binary-safe string; email addresses are
|
||||
// ASCII so this is fine. We also reverse the string first so even a base64
|
||||
// decoder doesn't immediately yield the address — a small extra speed bump.
|
||||
const encoded = btoa(address.split("").reverse().join(""));
|
||||
const displayLabel = label ?? " "; // placeholder; JS fills the real address
|
||||
---
|
||||
|
||||
<a
|
||||
href="#"
|
||||
data-mail={encoded}
|
||||
class={className}
|
||||
style={style}
|
||||
onclick="return false;"
|
||||
aria-label="Email address (decoded on click)"
|
||||
>
|
||||
{displayLabel}
|
||||
</a>
|
||||
<noscript>
|
||||
<span class={className} style={style}>
|
||||
Enable JavaScript to see the email address, or use the contact form.
|
||||
</span>
|
||||
</noscript>
|
||||
|
||||
<script is:inline>
|
||||
// Decode and wire up every obfuscated email link on the page. Runs on initial
|
||||
// load and again after Astro View Transitions swap the DOM (astro:page-load).
|
||||
function initObfuscatedEmail() {
|
||||
document.querySelectorAll("a[data-mail]").forEach((el) => {
|
||||
const a = el;
|
||||
const encoded = a.getAttribute("data-mail");
|
||||
if (!encoded) return;
|
||||
// Reverse the base64 we encoded at render time, then decode.
|
||||
const reversed = atob(encoded).split("").reverse().join("");
|
||||
a.setAttribute("href", "mailto:" + reversed);
|
||||
// If no explicit label was provided, show the address as the link text.
|
||||
if (!a.textContent || a.textContent.trim() === "") {
|
||||
a.textContent = reversed;
|
||||
}
|
||||
});
|
||||
}
|
||||
initObfuscatedEmail();
|
||||
document.addEventListener("astro:page-load", initObfuscatedEmail);
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* BrandIcon.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React twin of Icon.astro for islands: renders a Patiun brand icon (by brand
|
||||
* name or mdi alias) as an inline stroke SVG that inherits currentColor.
|
||||
*/
|
||||
import { brandIconBody } from '@lib/brand-icons';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Accessible label; omit when the icon sits beside visible text. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function BrandIcon({ name, size = 20, className, title }: Props) {
|
||||
const body = brandIconBody(name) ?? '';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
role={title ? 'img' : undefined}
|
||||
aria-hidden={title ? undefined : true}
|
||||
aria-label={title}
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
/**
|
||||
* Motif.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A Patiun brand motif ("The Alchemist's Instrument") as a decorative image.
|
||||
* Files live in public/motifs/ and are copied from branding/motifs/svg with
|
||||
* their ring text and symbols outlined to paths, so they render without the
|
||||
* Cinzel or Noto Sans Symbols fonts. `-light` files are the Starlight-ground
|
||||
* recolor (deep teal, deep gold, Midnight symbols); the rest are drawn for the
|
||||
* night sky.
|
||||
*
|
||||
* Brand rules: one focal motif per surface, and keep motifs faint behind
|
||||
* content (they are texture, not illustration).
|
||||
*
|
||||
* Usage:
|
||||
* <Motif name="transmutation-seal-light" class="absolute -right-40 top-10 h-[40rem] w-[40rem]" style="opacity:.1" />
|
||||
*/
|
||||
export interface Props {
|
||||
name:
|
||||
| "layered-seal"
|
||||
| "astrolabe"
|
||||
| "transmutation-seal-light"
|
||||
| "tria-prima-array-seal-light"
|
||||
| "zodiac-seal-light"
|
||||
| "craft-seal-print-light"
|
||||
| "squared-circle-seal-light"
|
||||
| "kepler-nest-seal-light";
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { name, class: className, style } = Astro.props;
|
||||
---
|
||||
|
||||
<img
|
||||
src={`/motifs/${name}.svg`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class:list={["pointer-events-none select-none", className]}
|
||||
style={style}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
/**
|
||||
* PageSeal.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A faint Patiun seal behind the top-right of a page header, for pages
|
||||
* without a night-sky hero (those already carry the turning layered seal).
|
||||
* Sits behind content (-z-10) and is clipped so it never widens the page.
|
||||
*
|
||||
* Usage (first thing inside the page): <PageSeal name="zodiac-seal-light" />
|
||||
*/
|
||||
import Motif from "@components/brand/Motif.astro";
|
||||
import type { Props as MotifProps } from "@components/brand/Motif.astro";
|
||||
|
||||
interface Props {
|
||||
name: MotifProps["name"];
|
||||
}
|
||||
const { name } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[46rem] overflow-hidden"
|
||||
>
|
||||
<Motif
|
||||
name={name}
|
||||
class="absolute -right-40 top-20 h-[30rem] w-[30rem] sm:-right-24 sm:top-24 sm:h-[36rem] sm:w-[36rem] lg:right-[max(-6rem,calc((100vw-72rem)/2-12rem))]"
|
||||
style="opacity:.14;"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
/**
|
||||
* DeckCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a single Magic: The Gathering deck pulled live from Archidekt. Shows
|
||||
* the deck's featured card art (or a placeholder slab), the deck name as a link
|
||||
* to its Archidekt page, a row of mana-color pips sized by color presence, and
|
||||
* a meta line (card count, format, last-updated).
|
||||
*
|
||||
* Data shape matches ArchidektDeck in src/lib/archidekt.ts.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
href: string;
|
||||
featured?: string;
|
||||
colors?: { W: number; U: number; B: number; R: number; G: number };
|
||||
size?: number;
|
||||
formatLabel?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const { name, href, featured, colors, size, formatLabel, updatedAt } = Astro.props;
|
||||
|
||||
// MTG mana colors. Used for the presence pips; a color shows only if count > 0.
|
||||
const MANA: Array<[key: 'W' | 'U' | 'B' | 'R' | 'G', hex: string]> = [
|
||||
['W', '#f8f4e8'],
|
||||
['U', '#3b7bd4'],
|
||||
['B', '#2b2b2b'],
|
||||
['R', '#d44b3b'],
|
||||
['G', '#3f9d52'],
|
||||
];
|
||||
const present = colors ? MANA.filter(([k]) => (colors[k] ?? 0) > 0) : [];
|
||||
|
||||
// Friendly relative-ish date (e.g. "Jul 2026"). Falls back gracefully.
|
||||
const updated = updatedAt ? new Date(updatedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) : '';
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card card-interactive group flex h-full flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<div class="relative aspect-[4/3] w-full overflow-hidden">
|
||||
{
|
||||
featured ? (
|
||||
<img
|
||||
src={featured}
|
||||
alt={`${name} featured card`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name="cards-playing"
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Legibility gradient at the bottom for the meta row. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 bottom-0 h-1/3"
|
||||
style="background: linear-gradient(to top, rgba(0,0,0,0.55), transparent);"
|
||||
></div>
|
||||
|
||||
{
|
||||
/* Mana color pips, top-left over the art. */
|
||||
present.length > 0 && (
|
||||
<div class="absolute left-3 top-3 flex gap-1.5">
|
||||
{present.map(([key, hex]) => (
|
||||
<span
|
||||
class="h-3 w-3 rounded-full border border-black/30"
|
||||
style={`background:${hex};`}
|
||||
title={`${key} mana`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<h3
|
||||
class="font-display text-base leading-tight underline-offset-4 group-hover:underline"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 font-eyebrow text-[0.65rem] uppercase tracking-[0.16em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{formatLabel && <span>{formatLabel}</span>}
|
||||
{formatLabel && (size || updated) && <span aria-hidden="true">·</span>}
|
||||
{size ? <span>{size} cards</span> : null}
|
||||
{updated && (size ? <span aria-hidden="true">·</span> : null)}
|
||||
{updated && <span>{updated}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
/**
|
||||
* GameCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a Gaming Hall of Fame entry. Shows the game's banner/box art (or a
|
||||
* placeholder slab when no art is set yet), the title, a platform pill, the
|
||||
* release year, and the blurb. Matches the warm "interest" surface styling and
|
||||
* the existing card idiom (`.card card-interactive`).
|
||||
*
|
||||
* Driven by the `games` collection schema in src/content.config.ts.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import { platformToIcon } from '@lib/platforms';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
platform: string;
|
||||
blurb: string;
|
||||
/** Public path to art, e.g. "/images/games/elden-ring.jpg". */
|
||||
art?: string;
|
||||
/** Alt text for the art; defaults to the title. */
|
||||
artAlt?: string;
|
||||
/** Optional release year shown next to the platform pill. */
|
||||
year?: number;
|
||||
/** Optional mdi icon override; defaults to a platform-derived icon. */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const { title, platform, blurb, art, artAlt, year, icon } = Astro.props;
|
||||
|
||||
const platIcon = icon ?? platformToIcon(platform);
|
||||
const alt = artAlt ?? title;
|
||||
---
|
||||
|
||||
<article class="card card-interactive flex h-full flex-col gap-0 overflow-hidden p-0">
|
||||
<div class="relative aspect-[4/3] w-full overflow-hidden">
|
||||
{
|
||||
art ? (
|
||||
<img
|
||||
src={art}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name="gamepad-variant"
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 p-5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="pill text-sm">
|
||||
<Icon name={platIcon} class="h-3.5 w-3.5" />
|
||||
{platform}
|
||||
</span>
|
||||
{year && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">{title}</h3>
|
||||
|
||||
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{blurb}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* InterestCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic highlight card used across interest pages (the spec's "Highlights
|
||||
* (Card Grid)" sections). Each card: an mdi icon, a bold title, and a body.
|
||||
* Spec emojis in the highlight tables are mapped to mdi icon names at the call
|
||||
* site.
|
||||
*
|
||||
* Optional `currently` prop restyles the card as a "what's active now" item
|
||||
* with a pulse dot, matching the spec's "Currently" sections.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Optional small status pill text, e.g. "Complete", "Reading". */
|
||||
status?: string;
|
||||
currently?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { icon, title, status, currently = false, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-3',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
|
||||
style={currently
|
||||
? 'background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent);'
|
||||
: 'background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={icon} class="h-6 w-6" />
|
||||
</span>
|
||||
{currently && (
|
||||
<span class="inline-flex items-center gap-1.5 font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{status && !currently && (
|
||||
<span class="pill">{status}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">{title}</h3>
|
||||
<div class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* MediaCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a media favorite (anime / tv / movies / reading). Mirrors the
|
||||
* GameCard / InterestCard idiom (`.card card-interactive`): a poster-style art
|
||||
* slot with a kind-derived icon fallback, the title, an optional status pill,
|
||||
* an optional year/count detail, and a blurb body.
|
||||
*
|
||||
* Driven by the `MediaItem` shape in src/lib/media.ts. Populate the optional
|
||||
* `image` field via the matching `scripts/fetch-*-art.mjs` tool; until then the
|
||||
* icon fallback slab keeps the grid looking intentional.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { MediaKind } from '@lib/media';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Which medium — drives the fallback poster icon + accent. */
|
||||
kind: MediaKind;
|
||||
/** Local public path to poster/cover art, e.g. "/images/anime/demon-slayer.jpg". */
|
||||
image?: string;
|
||||
/** Alt text for the art; defaults to the title. */
|
||||
imageAlt?: string;
|
||||
/** Optional status pill text, e.g. "Completed", "Watching". */
|
||||
status?: string;
|
||||
/** Optional release year shown next to the status pill. */
|
||||
year?: number;
|
||||
/** Optional span/count for franchise cards, e.g. "All 4 films". */
|
||||
count?: string;
|
||||
/** Long-form blurb shown as the card body. */
|
||||
blurb?: string;
|
||||
}
|
||||
|
||||
const { title, kind, image, imageAlt, status, year, count, blurb } = Astro.props;
|
||||
|
||||
const fallbackIcon: Record<MediaKind, string> = {
|
||||
anime: 'animation-play',
|
||||
tv: 'television-classic',
|
||||
movies: 'movie-open',
|
||||
reading: 'book-open-page-variant',
|
||||
};
|
||||
|
||||
const alt = imageAlt ?? title;
|
||||
const detail = [year ? String(year) : null, count].filter(Boolean).join(' \u00b7 ');
|
||||
---
|
||||
|
||||
<article class="card card-interactive flex h-full flex-col gap-0 overflow-hidden p-0">
|
||||
<div class="relative aspect-[3/4] w-full overflow-hidden">
|
||||
{
|
||||
image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={fallbackIcon[kind]}
|
||||
class="h-12 w-12 opacity-50"
|
||||
style="color: var(--color-text-muted);"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{/* Favorite star badge, always present on these cards. */}
|
||||
<span
|
||||
class="absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full backdrop-blur-sm"
|
||||
style="background: color-mix(in srgb, var(--color-surface) 70%, transparent); color: var(--color-accent);"
|
||||
aria-label="Favorite"
|
||||
>
|
||||
<Icon name="star" class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-display text-lg leading-tight" style="color: var(--color-text);">
|
||||
{title}
|
||||
</h3>
|
||||
{status && <span class="pill shrink-0">{status}</span>}
|
||||
</div>
|
||||
{
|
||||
detail && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
blurb && (
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{blurb}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* AnimatedBackground.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The hero background island. Layers:
|
||||
*
|
||||
* 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
|
||||
* horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
|
||||
* clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
|
||||
* 2. The Patiun layered magic seal, faint and slowly turning in the top-left
|
||||
* corner (the surface's one focal motif).
|
||||
* 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
|
||||
* with thin constellation lines between near neighbors, plus the occasional
|
||||
* shooting star. The WHOLE field rotates together very slowly around the
|
||||
* field center — reading as a slice of sky turning as the planet rotates.
|
||||
* We render at devicePixelRatio and cap the count for perf. It pauses when
|
||||
* off-screen and is fully skipped when prefers-reduced-motion is set.
|
||||
*
|
||||
* The component is presentational only; it reads CSS variables for colors so it
|
||||
* adapts to the section surface tone automatically.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Show the canvas star field. Off on interest pages for a calmer feel. */
|
||||
particles?: boolean;
|
||||
/**
|
||||
* Tailwind/className passthrough for the root. The CALL SITE (in .astro) is
|
||||
* responsible for including `vt-sky` here when this element should carry the
|
||||
* `view-transition-name: sky` morph. We intentionally do NOT compute the
|
||||
* transition class inside this React component: any conditional className
|
||||
* interpolation causes a server/client hydration attribute mismatch under
|
||||
* Astro's client:idle. Passing a static, fully-formed string guarantees the
|
||||
* server-rendered and hydrated class attributes are byte-identical.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnimatedBackground({
|
||||
particles = true,
|
||||
className = '',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduce || !particles) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
interface Star {
|
||||
// Position in sky coordinates (pre-rotation). The whole field rotates.
|
||||
rx: number; // offset from field center (cx, cy)
|
||||
ry: number;
|
||||
r: number; // base radius
|
||||
tw: number; // twinkle phase
|
||||
tws: number; // twinkle speed (slow)
|
||||
baseA: number; // base alpha
|
||||
anchor: boolean; // brighter "anchor" star used for constellations
|
||||
hue: 'white' | 'gold' | 'violet' | 'teal';
|
||||
}
|
||||
|
||||
interface Shooter {
|
||||
x: number;
|
||||
y: number;
|
||||
len: number;
|
||||
speed: number;
|
||||
angle: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
let stars: Star[] = [];
|
||||
let shooters: Shooter[] = [];
|
||||
let nextShooterAt = 0;
|
||||
let t0 = performance.now();
|
||||
let angle = 0; // current field rotation (radians)
|
||||
|
||||
// Resolve star colors from CSS variables so the field matches the theme.
|
||||
const gold =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-gold').trim() ||
|
||||
'#c9a24a';
|
||||
// Nebula Violet is background light only, so violet stars stay sparse; teal stars use the on-dark teal so they read at dot size.
|
||||
const violet = '#6a2fe0';
|
||||
const teal =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-teal-bright').trim() ||
|
||||
'#2bc2b7';
|
||||
const white =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-starlight').trim() ||
|
||||
'#f4f2ed';
|
||||
|
||||
const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white);
|
||||
|
||||
// Max distance for two stars to be connected by a constellation line.
|
||||
const LINK_DIST = 168;
|
||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||
|
||||
// Rotation speed: a full turn takes ~6 minutes, so the drift is felt but
|
||||
// never distracting — "as the planet rotates".
|
||||
const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second
|
||||
|
||||
function pickHue(): Star['hue'] {
|
||||
const n = Math.random();
|
||||
// Mostly white stars, with occasional colored ones for richness.
|
||||
if (n < 0.7) return 'white';
|
||||
if (n < 0.85) return 'gold';
|
||||
if (n < 0.94) return 'violet';
|
||||
return 'teal';
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Dense field tuned for ~40% constellation coverage on any slice. Stars
|
||||
// live in a sky disk large enough that rotation never reveals an empty
|
||||
// edge. The disk is centered on the PIVOT (the top-right corner) so the
|
||||
// whole field rotates around that corner like a star chart turning about
|
||||
// its corner mount; maxR reaches the far (bottom-left) corner.
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const maxR = Math.sqrt(cx * cx + height * height) + 40;
|
||||
|
||||
// Ultra-dense starfield: ~5x the previous density for a richly packed sky.
|
||||
// One star per ~220 px^2 of the disk, capped for perf (~17% anchors).
|
||||
const diskArea = Math.PI * maxR * maxR;
|
||||
const count = Math.min(3000, Math.floor(diskArea / 220));
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 6 === 0; // ~17% anchors -> very dense constellation backbone
|
||||
// Uniform-ish distribution across the disk via sqrt on radius.
|
||||
const rr = maxR * Math.sqrt(Math.random());
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
|
||||
return {
|
||||
rx: cx + rr * Math.cos(a),
|
||||
ry: cy + rr * Math.sin(a),
|
||||
r,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
// Slow twinkle: 0.15–0.5 cycles/sec.
|
||||
tws: 0.15 + Math.random() * 0.35,
|
||||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
|
||||
anchor,
|
||||
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function spawnShooter(now: number) {
|
||||
const startX = Math.random() * width * 0.8;
|
||||
const startY = Math.random() * height * 0.5;
|
||||
const a = Math.PI * (0.18 + Math.random() * 0.12);
|
||||
shooters.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
len: 90 + Math.random() * 120,
|
||||
speed: 5.5 + Math.random() * 3.5,
|
||||
angle: a,
|
||||
life: 1,
|
||||
});
|
||||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
angle += ROT_SPEED * dt; // whole sky rotates together
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Pivot = top-right corner (matches the disk center used in resize()).
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
|
||||
// Rotate each sky point about the field center into screen space.
|
||||
const project = (rx: number, ry: number): [number, number] => [
|
||||
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||
];
|
||||
|
||||
// Project every star's sky position into screen space once.
|
||||
// Each entry carries the source star + its rotated (x, y).
|
||||
const proj = stars.map((s) => {
|
||||
const [x, y] = project(s.rx, s.ry);
|
||||
return { s, x, y };
|
||||
});
|
||||
|
||||
// 1) Constellation lines (behind stars) — connect near anchor pairs.
|
||||
ctx.lineWidth = 0.6;
|
||||
for (let i = 0; i < proj.length; i++) {
|
||||
const A = proj[i];
|
||||
if (!A.s.anchor) continue;
|
||||
for (let j = i + 1; j < proj.length; j++) {
|
||||
const B = proj[j];
|
||||
if (!B.s.anchor) continue;
|
||||
const ddx = A.x - B.x;
|
||||
const ddy = A.y - B.y;
|
||||
const d2 = ddx * ddx + ddy * ddy;
|
||||
if (d2 > LINK_DIST_SQ) continue;
|
||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
|
||||
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
|
||||
ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(A.x, A.y);
|
||||
ctx.lineTo(B.x, B.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Stars (slow twinkle; positions already rotated).
|
||||
for (const p of proj) {
|
||||
const s = p.s;
|
||||
s.tw += s.tws * dt;
|
||||
// Cull stars rotated off-screen for perf.
|
||||
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||
|
||||
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
||||
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
||||
const color = hueColor(s.hue);
|
||||
|
||||
if (s.anchor) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha * 0.5;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 3) Shooting stars (screen-space; not part of the rotating sky).
|
||||
if (now >= nextShooterAt) spawnShooter(now);
|
||||
for (let i = shooters.length - 1; i >= 0; i--) {
|
||||
const sh = shooters[i];
|
||||
sh.x += Math.cos(sh.angle) * sh.speed;
|
||||
sh.y += Math.sin(sh.angle) * sh.speed;
|
||||
sh.life -= dt * 0.5;
|
||||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||||
shooters.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const tailX = sh.x - Math.cos(sh.angle) * sh.len;
|
||||
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
||||
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
||||
const headA = Math.max(0, Math.min(1, sh.life));
|
||||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
|
||||
grad.addColorStop(0.4, `rgba(201, 162, 74, ${(headA * 0.6).toFixed(3)})`);
|
||||
grad.addColorStop(1, 'rgba(201, 162, 74, 0)');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sh.x, sh.y);
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = gold;
|
||||
ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
t0 = performance.now();
|
||||
nextShooterAt = t0 + 3000 + Math.random() * 4000;
|
||||
raf = requestAnimationFrame(frame);
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Pause when off-screen to save cycles.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, [particles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
|
||||
a low horizon. Evokes a true clear night sky rather than a flat color. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Milky Way galaxy band — a soft diagonal luminous streak with a denser
|
||||
core, blending teal + violet + faint gold. Sits over the base sky. */}
|
||||
<div
|
||||
className="animate-gradient-shift absolute inset-0 opacity-90"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(38% 70% at 68% 38%, rgba(20, 163, 154, 0.22), transparent 62%),
|
||||
radial-gradient(30% 55% at 72% 44%, rgba(106, 47, 224, 0.26), transparent 60%),
|
||||
radial-gradient(22% 40% at 76% 50%, rgba(201, 162, 74, 0.12), transparent 60%),
|
||||
linear-gradient(115deg, transparent 30%, rgba(236, 233, 226, 0.06) 45%, rgba(236, 233, 226, 0.09) 52%, rgba(236, 233, 226, 0.05) 60%, transparent 72%)
|
||||
`,
|
||||
backgroundSize: '200% 200%',
|
||||
}}
|
||||
/>
|
||||
{/* Nebula clouds near the corners for color depth. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(45% 55% at 12% 12%, rgba(106, 47, 224, 0.18), transparent 60%),
|
||||
radial-gradient(40% 50% at 88% 88%, rgba(20, 163, 154, 0.16), transparent 62%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{/* The layered magic seal, turning slowly in the top-left corner: the
|
||||
one focal motif on the night sky. Its ring text and symbols are
|
||||
outlined to paths (public/motifs/), so no symbol font is needed. */}
|
||||
<img
|
||||
src="/motifs/layered-seal.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow select-none opacity-[0.2]"
|
||||
/>
|
||||
|
||||
{/* Subtle vignette for text legibility. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(8, 12, 20, 0.7) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Canvas star field (skipped under reduced motion). */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
/**
|
||||
* Footer.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Minimal footer on a deep night-sky surface: a faint static starfield accent,
|
||||
* the Patiun seal beside the name, social links, the copyright with the
|
||||
* "a Patiun LLC company" line, and
|
||||
* the spec microcopy "Built with intention. Powered by caffeine." No emojis.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import StarField from '@components/mystic/StarField.astro';
|
||||
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
|
||||
const year = new Date().getFullYear();
|
||||
const socialList = Object.values(socials);
|
||||
---
|
||||
|
||||
<footer
|
||||
class="relative overflow-hidden border-t"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 55%, transparent); background: var(--color-surface-sunken);"
|
||||
>
|
||||
{/* Faint static starfield — carries the night-sky treatment into the footer. */}
|
||||
<StarField
|
||||
class="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style="color: var(--color-accent); opacity:.35;"
|
||||
density="sparse"
|
||||
/>
|
||||
<div class="container-prose relative z-10 flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/brand/patiun-seal.svg" alt="" width="72" height="72" class="h-[72px] w-[72px] shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-display text-sm font-bold uppercase tracking-[0.14em]" style="color: var(--color-text);">
|
||||
{siteIdentity.name}
|
||||
</p>
|
||||
<p class="font-eyebrow text-xs italic" style="color: var(--color-text-subtle);">
|
||||
{footerMicrocopy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
{socialList.map((s) => (
|
||||
<li>
|
||||
<a
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
class="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-muted); border-color: var(--color-border);"
|
||||
>
|
||||
<Icon name={s.icon} class="h-5 w-5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="container-prose relative z-10 pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
|
||||
© {year} {siteIdentity.name} · {siteIdentity.lockupLine}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
/**
|
||||
* InterestHero.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared header for every interest sub-page. Compact hero on the animated
|
||||
* Milky Way field: a back-to-hub breadcrumb, a small icon mark, the title, and
|
||||
* the italic tagline.
|
||||
*
|
||||
* Tuned for the dark starfield: text uses bright Starlight tones (not the dim
|
||||
* --color-text-subtle/--color-text which vanish on the deep sky). The title is
|
||||
* h2-sized (the page is a sub-page, not Home) so it doesn't dominate. An
|
||||
* optional banner image sits above the heading.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon used as the decorative mark. */
|
||||
icon: string;
|
||||
title: string;
|
||||
/** The italicized pull-quote tagline from the spec. */
|
||||
tagline: string;
|
||||
/** Back-to-hub breadcrumb label. */
|
||||
eyebrow?: string;
|
||||
/** Optional hero banner image (vibe-matched stock; swap for real photos). */
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
tagline,
|
||||
eyebrow = 'Interests',
|
||||
image,
|
||||
imageAlt = '',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
|
||||
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
|
||||
Carries the per-page morph name (one .vt-sky per page snapshot). -->
|
||||
<AnimatedBackground client:idle className="vt-sky" />
|
||||
|
||||
{/* Two-column layout: text on the left, image on the right. Putting the image
|
||||
BESIDE the text (instead of stacked above it) means the hero height is
|
||||
driven by the text column and stays consistent across interest pages
|
||||
regardless of image presence. On small screens it stacks (image below). */}
|
||||
<div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
|
||||
{/* --- Left: text column --- */}
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/interests"
|
||||
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em] transition-colors"
|
||||
style="color: var(--color-starlight); opacity: 0.8;"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
{eyebrow}
|
||||
</a>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<span
|
||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={icon} class="h-6 w-6" />
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<h2
|
||||
class="font-display font-bold"
|
||||
style="color: var(--color-starlight); font-size: clamp(1.75rem, 2vw + 1rem, 2.5rem); line-height: 1.15;"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<p
|
||||
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
|
||||
style="border-color: var(--color-gold); color: var(--color-starlight); opacity: 0.92;"
|
||||
>
|
||||
{tagline}
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* --- Right: image column (only when an image is provided). A fixed
|
||||
aspect keeps every interest hero the same height. --- */}
|
||||
{image && (
|
||||
<Reveal delay={80} class="w-full">
|
||||
<figure
|
||||
class="relative w-full overflow-hidden rounded-2xl border"
|
||||
style="border-color: color-mix(in srgb, var(--color-gold) 70%, transparent);"
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
</figure>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* Placeholder.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual marker for content Greg still needs to provide (the spec's many
|
||||
* "[Greg to fill]" items). Renders a dashed slot with a small mdi pencil badge
|
||||
* and a "TODO" label so the structure is visible and demoable, but it's obvious
|
||||
* what needs swapping. The chosen content approach is "verbatim spec copy, leave
|
||||
* gaps", so these mark every gap rather than inventing fake content.
|
||||
*
|
||||
* Usage:
|
||||
* <Placeholder label="Project name" hint="Add 3-6 portfolio entries" />
|
||||
* <Placeholder label="Email"><p>Inline hint...</p></Placeholder>
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Short name of what's missing, e.g. "Email". */
|
||||
label: string;
|
||||
/** Optional longer hint shown as muted text. */
|
||||
hint?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { label, hint, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex items-start gap-3 rounded-xl border border-dashed p-4',
|
||||
className,
|
||||
]}
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); background: color-mix(in srgb, var(--color-accent) 6%, transparent);"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
style="background: var(--color-accent); color: var(--color-accent-contrast);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="pencil" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-eyebrow text-[0.65rem] font-bold uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
TODO · {label}
|
||||
</span>
|
||||
{hint && <p class="text-sm" style="color: var(--color-text-muted);">{hint}</p>}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* RelatedInterests.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "You might also like..." cross-link block that ends each interest page,
|
||||
* per the spec's Interests Page Flow. Pass an array of related slugs/titles and
|
||||
* this renders attractive teaser cards.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
export interface RelatedLink {
|
||||
href: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: RelatedLink[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mt-16">
|
||||
<h3 class="mb-5 font-eyebrow text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
|
||||
You might also like
|
||||
</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<a href={item.href} class="card card-interactive group flex items-start gap-4">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="flex items-center gap-1 font-sans font-semibold" style="color: var(--color-text);">
|
||||
{item.title}
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{item.blurb}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* SectionHeader.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Reusable section title block: a mono "kicker" (e.g. "02"), an h2 heading, and
|
||||
* an optional supporting line. Keeps section openers visually consistent.
|
||||
*
|
||||
* `tone` controls contrast:
|
||||
* - "default" for headers on lighter CONTENT surfaces (uses --color-text /
|
||||
* --color-text-muted, which are tuned for the page body).
|
||||
* - "hero" for headers that sit on a dark hero / starfield background: forces
|
||||
* near-white title + bright subtitle + a glowing kicker so the text stands
|
||||
* out against the deep Milky Way field.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Monospace kicker, e.g. "02" or "Experience". */
|
||||
kicker?: string;
|
||||
title: string;
|
||||
/** Optional subtitle shown under the title. */
|
||||
subtitle?: string;
|
||||
/** Optional mdi icon shown beside the kicker. */
|
||||
icon?: string;
|
||||
class?: string;
|
||||
align?: "left" | "center";
|
||||
/** "hero" = bright text for dark backgrounds; "default" = body content. */
|
||||
tone?: "default" | "hero";
|
||||
}
|
||||
|
||||
const {
|
||||
kicker,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
class: className = "",
|
||||
align = "left",
|
||||
tone = "default",
|
||||
} = Astro.props;
|
||||
|
||||
const isHero = tone === "hero";
|
||||
const titleColor = isHero ? "var(--color-starlight)" : undefined;
|
||||
const subtitleColor = isHero ? "var(--color-starlight)" : "var(--color-text-muted)";
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"flex flex-col gap-3",
|
||||
align === "center" && "items-center text-center",
|
||||
className,
|
||||
]}
|
||||
>
|
||||
{
|
||||
kicker && (
|
||||
<span
|
||||
class="inline-flex items-center gap-2 font-eyebrow text-xs uppercase tracking-[0.22em]"
|
||||
style={
|
||||
isHero
|
||||
? "color: var(--color-accent); text-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 45%, transparent);"
|
||||
: "color: var(--color-secondary);"
|
||||
}
|
||||
>
|
||||
{icon && <Icon name={icon} class="h-4 w-4" />}
|
||||
{kicker}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<h2 style={titleColor ? `color: ${titleColor};` : undefined}>{title}</h2>
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class="max-w-2xl text-base sm:text-lg"
|
||||
style={`color: ${subtitleColor};${isHero ? " opacity: 0.92;" : ""}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
/**
|
||||
* SplitTransition.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "fork" used at the end of the About page (and reusable elsewhere) that
|
||||
* reinforces the spec's "two wings of the same house" idea: two side-by-side
|
||||
* paths inviting the visitor into Professional or Interests.
|
||||
*
|
||||
* Left -> Professional (polished tone)
|
||||
* Right -> Interests (warm tone)
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Optional override copy for each side. Defaults to spec phrasing. */
|
||||
leftHref?: string;
|
||||
rightHref?: string;
|
||||
leftText?: string;
|
||||
rightText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
leftHref = "/professional",
|
||||
rightHref = "/interests",
|
||||
leftText = "See where I apply that curiosity professionally",
|
||||
rightText = "See where it takes me outside of work",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href={leftHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<Icon
|
||||
name="arrow-left"
|
||||
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle); text-align:right;"
|
||||
>
|
||||
The professional wing
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-lg font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{leftText}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={rightHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
The personal wing
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-lg font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{rightText}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
/**
|
||||
* CategoryList.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders a media kind's full library grouped by category: for each group a
|
||||
* small subheader (the category name + item count), then a wrapping row of
|
||||
* pills (title + optional status). Reuses the existing `.pill` + `<Icon>`
|
||||
* pattern already established on the old reading page.
|
||||
*
|
||||
* Pass the output of `getGrouped(kind)` directly. When the list is empty the
|
||||
* caller should fall back to `<Placeholder>` (see the pages); this component
|
||||
* assumes at least one group.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { MediaGroup } from '@lib/media';
|
||||
|
||||
interface Props {
|
||||
groups: MediaGroup[];
|
||||
/** mdi icon shown before each pill. */
|
||||
pillIcon?: string;
|
||||
}
|
||||
|
||||
const { groups, pillIcon = 'play-circle' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
{
|
||||
groups.map((group) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<h3
|
||||
class="font-display text-lg"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{group.category}
|
||||
</h3>
|
||||
<span
|
||||
class="font-eyebrow text-[0.65rem] uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-wrap gap-2.5">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
class="pill inline-flex items-center gap-1.5 text-sm"
|
||||
title={item.note ?? ''}
|
||||
>
|
||||
<Icon name={pillIcon} class="h-3.5 w-3.5" />
|
||||
<span>{item.title}</span>
|
||||
{item.status && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.6rem] uppercase tracking-[0.14em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
{item.year && (
|
||||
<span
|
||||
class="font-eyebrow text-[0.6rem] tracking-[0.04em]"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{item.year}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* Reveal.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zero-dependency scroll-reveal wrapper. Adds the `reveal` class (defined in
|
||||
* global.css, which handles the transition + the reduced-motion override) and
|
||||
* toggles `is-visible` when the element enters the viewport.
|
||||
*
|
||||
* Why not Framer Motion here? Scroll reveals happen on potentially dozens of
|
||||
* elements per page; using IntersectionObserver + a CSS class keeps this free
|
||||
* of client JS. Framer Motion is reserved for genuinely interactive islands.
|
||||
*
|
||||
* This component renders a <div> wrapper. When you need a different semantic
|
||||
* element (e.g. an <li> or <section>), apply the `reveal` class and the
|
||||
* `data-reveal` attribute to that element directly instead of using Reveal.
|
||||
*
|
||||
* Usage:
|
||||
* <Reveal delay={100}><h2>...</h2></Reveal>
|
||||
* <li class="reveal" data-reveal style="transition-delay:80ms">...</li>
|
||||
*/
|
||||
interface Props {
|
||||
delay?: number;
|
||||
class?: string;
|
||||
style?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const { delay = 0, class: className = '', style = '', ...rest } = Astro.props;
|
||||
const delayStyle = delay ? `transition-delay:${delay}ms;` : '';
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['reveal', className]}
|
||||
style={`${delayStyle}${style}`}
|
||||
data-reveal
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
/**
|
||||
* AlchemicalDivider.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Section break in the Patiun star-line style: a dotted (faint) run leading
|
||||
* into a solid (main) run on each side, each stopping short of the stars, with
|
||||
* a gold four-point star at the center flanked by two small ones. Inherits
|
||||
* currentColor; call sites pass Alchemist Gold.
|
||||
*
|
||||
* Usage:
|
||||
* <AlchemicalDivider class="mx-auto h-6 w-full max-w-sm" style="color: var(--color-gold)" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { class: className = '', style = '' } = Astro.props;
|
||||
---
|
||||
<div class={`flex items-center justify-center ${className}`} style={style} aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 24"
|
||||
class="h-full w-full"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
{/* Faint links: dotted runs at the outer ends. */}
|
||||
<path d="M6 12h30M164 12h30" stroke-width="1" stroke-dasharray="0.1 4" />
|
||||
{/* Main figure: solid runs that stop short of the stars. */}
|
||||
<path d="M42 12h38M120 12h38" stroke-width="1" />
|
||||
<polygon points="86.50,8.80 87.18,11.32 89.70,12.00 87.18,12.68 86.50,15.20 85.82,12.68 83.30,12.00 85.82,11.32" fill="currentColor" stroke="none" />
|
||||
<polygon points="113.50,8.80 114.18,11.32 116.70,12.00 114.18,12.68 113.50,15.20 112.82,12.68 110.30,12.00 112.82,11.32" fill="currentColor" stroke="none" />
|
||||
<polygon points="100.00,3.00 101.91,10.09 109.00,12.00 101.91,13.91 100.00,21.00 98.09,13.91 91.00,12.00 98.09,10.09" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
/**
|
||||
* CelestialFrame.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* An ornamental frame for imagery: rounded image container with a gold border
|
||||
* + small star-burst flourishes in the corners and a faint sacred-geometry tint
|
||||
* behind. Wraps an <img> (or any media) passed via the default slot.
|
||||
*
|
||||
* CORNER FIT: the gold border lives on the outer <figure>. The media container
|
||||
* is INSET by exactly the border width (via --frame-bw) and its corner radius
|
||||
* is reduced by the same amount, so the image nests flush inside the border at
|
||||
* every corner — no gap, no mismatch. Adjust --frame-bw here in one place if
|
||||
* the border width changes.
|
||||
*/
|
||||
import StarBurst from "./StarBurst.astro";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Corner rounding. */
|
||||
rounded?: "lg" | "xl" | "2xl" | "none";
|
||||
/** Stroke emphasis on the frame border. */
|
||||
emphasis?: "subtle" | "strong";
|
||||
}
|
||||
const {
|
||||
class: className = "",
|
||||
rounded = "2xl",
|
||||
emphasis = "subtle",
|
||||
} = Astro.props;
|
||||
|
||||
// Outer radius (px) per option. The inner media radius is outer - border width
|
||||
// so the image corner nests perfectly inside the border's corner.
|
||||
const outerPx = { none: 0, lg: 8, xl: 12, "2xl": 16 }[rounded];
|
||||
const bw = emphasis === "strong" ? 4 : 3; // border width in px (matches border-2 / 3px)
|
||||
const innerPx = Math.max(0, outerPx - bw);
|
||||
---
|
||||
|
||||
<figure
|
||||
class:list={["relative", className]}
|
||||
style={`border-radius: ${outerPx}px; border: ${bw}px solid var(--color-gold); `}
|
||||
>
|
||||
{/* Faint sacred-geometry tint behind the image (fills the full figure). */}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style={`border-radius: ${outerPx}px; background: color-mix(in srgb, var(--color-violet, var(--color-accent)) 14%, var(--color-surface-sunken));`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* The media itself, in normal flow (defines the figure's height via the slot's
|
||||
aspect-ratio div). Its radius = outer radius - border width, so the image's
|
||||
rounded corner nests PERFECTLY inside the border's rounded corner. The
|
||||
figure's `overflow: hidden` clips the tint layer to the outer radius. */
|
||||
}
|
||||
<div class="relative overflow-hidden" style={`border-radius: ${innerPx}px;`}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* Corner four-point stars in Alchemist Gold (sit on the border corners). */}
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -left-3 -top-3 h-6 w-6 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -bottom-5 -right-5 h-7 w-7 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
|
||||
style="color: var(--color-gold);"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -right-2 -top-2 h-3 w-3"
|
||||
style="color: var(--color-gold); opacity:.7;"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -left-2 -bottom-2 h-3 w-3"
|
||||
style="color: var(--color-gold); opacity:.7;"
|
||||
/>
|
||||
</figure>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
/**
|
||||
* CelestialImage.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The standard way to put an image on the site: an <img> with sensible defaults
|
||||
* (lazy, async decode, object-cover, explicit aspect via prop) wrapped in a
|
||||
* CelestialFrame so every photo reads as part of the mystical system.
|
||||
*
|
||||
* Falls back to a themed SVG placeholder (starfield on the surface tone) when
|
||||
* `src` is empty or the path is known to be missing — so the layout never
|
||||
* shows a broken-image icon while stock photos are being swapped for real ones.
|
||||
*
|
||||
* Usage:
|
||||
* <CelestialImage src="/images/hero.jpg" alt="Greg at his desk" aspect="4/3" />
|
||||
* <CelestialImage src="/images/about.jpg" alt="Greg" aspect="3/4" rounded="xl" />
|
||||
*/
|
||||
import CelestialFrame from "./CelestialFrame.astro";
|
||||
import StarField from "./StarField.astro";
|
||||
|
||||
interface Props {
|
||||
src?: string;
|
||||
alt: string;
|
||||
/** Aspect ratio, e.g. "4/3", "3/4", "16/9". */
|
||||
aspect?: string;
|
||||
/** Image fit. object-cover by default. */
|
||||
fit?: "cover" | "contain";
|
||||
rounded?: "lg" | "xl" | "2xl" | "none";
|
||||
class?: string;
|
||||
/** When true, render only the frame + placeholder (no <img>). */
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
aspect = "4/3",
|
||||
fit = "cover",
|
||||
rounded = "2xl",
|
||||
class: className = "",
|
||||
placeholder = false,
|
||||
} = Astro.props;
|
||||
|
||||
const showPlaceholder = placeholder || !src;
|
||||
const objectFit = fit === "contain" ? "object-contain" : "object-cover";
|
||||
---
|
||||
|
||||
<CelestialFrame rounded={rounded} class:list={["w-full", className]}>
|
||||
<div class="w-full" style={`aspect-ratio: ${aspect};`}>
|
||||
{
|
||||
showPlaceholder ? (
|
||||
// Themed SVG placeholder: starfield + moon glyph over the surface tone.
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
style="background: radial-gradient(120% 90% at 30% 20%, color-mix(in srgb, var(--color-violet, var(--color-accent)) 22%, transparent), transparent 60%), var(--color-surface-sunken);"
|
||||
>
|
||||
<StarField
|
||||
class="absolute inset-0 h-full w-full"
|
||||
style="color: var(--color-accent); opacity:.5;"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-10 w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
style="color: var(--color-accent); opacity:.55;"
|
||||
>
|
||||
{/* Crescent moon + small star as the placeholder glyph. */}
|
||||
<path d="M16 12a7 7 0 1 1-6.4-7 5.5 5.5 0 0 0 0 14A7 7 0 0 0 16 12z" />
|
||||
<path
|
||||
d="M18.5 5.5c.3 1.8 1 3.1 2.5 3.5-1.5.3-2.2 1.7-2.5 3.5-.3-1.8-1-3.2-2.5-3.5 1.5-.4 2.2-1.7 2.5-3.5z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 font-eyebrow text-[0.6rem] uppercase tracking-[0.2em]"
|
||||
style="color: var(--color-text-subtle); opacity:.8;"
|
||||
>
|
||||
{alt}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class:list={["h-full w-full", objectFit]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</CelestialFrame>
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* CircuitField.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The Professional page's counterpart to ConstellationField. It draws a faint
|
||||
* circuit-board pattern — orthogonal traces connecting node pads, with a slow
|
||||
* "signal pulse" traveling the traces — instead of stars and constellations.
|
||||
*
|
||||
* Crucially it carries the SAME `view-transition-name` ("sky") as the star
|
||||
* fields on other pages. So when you navigate from, say, the Home hero
|
||||
* (ConstellationField / AnimatedBackground) to the Professional page, the
|
||||
* browser's View Transition morphs the star field into the circuit field as
|
||||
* one element — constellations visibly transform into circuitry.
|
||||
*
|
||||
* Honors prefers-reduced-motion (renders a static board) and pauses off-screen.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Tailwind/className passthrough. The .astro call site includes `vt-sky` when
|
||||
* this element should carry the view-transition morph. Not computed here to
|
||||
* avoid a server/client hydration mismatch (see AnimatedBackground).
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Trace {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function CircuitField({
|
||||
className = '',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let t0 = performance.now();
|
||||
|
||||
let traces: Trace[] = [];
|
||||
let nodes: Node[] = [];
|
||||
|
||||
const gold = '#c9a24a';
|
||||
const violet = '#6a2fe0';
|
||||
const teal = '#2bc2b7';
|
||||
|
||||
function build() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Build a clean, ALIGNED orthogonal circuit on a grid: pads sit exactly at
|
||||
// grid intersections (no jitter) and traces connect them in straight
|
||||
// right-angle lines. Reads as an orderly schematic / PCB layout.
|
||||
const step = 64;
|
||||
const cols = Math.ceil(width / step) + 1;
|
||||
const rows = Math.ceil(height / step) + 1;
|
||||
const present: boolean[][] = [];
|
||||
nodes = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
present[r] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
// ~62% of intersections get a pad.
|
||||
present[r][c] = Math.random() < 0.62;
|
||||
if (present[r][c]) {
|
||||
// Snapped to the grid — no jitter, so traces land flush on pads.
|
||||
nodes.push({ x: c * step, y: r * step });
|
||||
}
|
||||
}
|
||||
}
|
||||
traces = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (!present[r][c]) continue;
|
||||
const here = { x: c * step, y: r * step };
|
||||
// Right neighbor.
|
||||
if (c + 1 < cols && present[r][c + 1] && Math.random() < 0.7) {
|
||||
traces.push({ x1: here.x, y1: here.y, x2: (c + 1) * step, y2: r * step });
|
||||
}
|
||||
// Down neighbor.
|
||||
if (r + 1 < rows && present[r + 1][c] && Math.random() < 0.7) {
|
||||
traces.push({ x1: here.x, y1: here.y, x2: c * step, y2: (r + 1) * step });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Traces. A signal pulse brightens a moving window along each trace, with
|
||||
// traces staggered so pulses are distributed across the board over time.
|
||||
for (let i = 0; i < traces.length; i++) {
|
||||
const t = traces[i];
|
||||
// Base trace — clearly visible violet conductor line.
|
||||
ctx.strokeStyle = 'rgba(43, 194, 183, 0.55)';
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x1, t.y1);
|
||||
ctx.lineTo(t.x2, t.y2);
|
||||
ctx.stroke();
|
||||
|
||||
// Pulse: a bright gold "signal" traveling the trace. position 0..1,
|
||||
// looping, with traces staggered so pulses spread across the board.
|
||||
const period = 6 + (i % 7);
|
||||
const phase = ((now / 1000) % period) / period;
|
||||
const px = t.x1 + (t.x2 - t.x1) * phase;
|
||||
const py = t.y1 + (t.y2 - t.y1) * phase;
|
||||
ctx.strokeStyle = gold;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = gold;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px - (t.x2 - t.x1) * 0.08, py - (t.y2 - t.y1) * 0.08);
|
||||
ctx.lineTo(px, py);
|
||||
ctx.stroke();
|
||||
// Bright pulse head dot.
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#fbf3dc';
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Node pads — solid teal fill with a brighter violet ring (solder pad).
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = teal;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.strokeStyle = violet;
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, 4.5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
void dt;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
for (const t of traces) {
|
||||
ctx.strokeStyle = 'rgba(43, 194, 183, 0.5)';
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x1, t.y1);
|
||||
ctx.lineTo(t.x2, t.y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const n of nodes) {
|
||||
ctx.fillStyle = teal;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
build();
|
||||
if (reduce) {
|
||||
drawStatic();
|
||||
} else {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
const onResize = () => {
|
||||
build();
|
||||
if (reduce) drawStatic();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (reduce) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// Root container mirrors AnimatedBackground's structure (gradient layers +
|
||||
// canvas all INSIDE the named element) so the view-transition morph from a
|
||||
// star hero into this circuit field is element-to-element equivalent and
|
||||
// nothing flashes separately. The `vt-sky`/opacity classes are passed via
|
||||
// className from the .astro call site.
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Base gradient — IDENTICAL colors to AnimatedBackground's night sky so
|
||||
the morph between star field and circuit field is color-consistent.
|
||||
No nebula radials here (the user asked to keep the circuit style only);
|
||||
just the clean deep-blue sky base. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Circuit traces + nodes + signal pulses. */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* ConstellationField.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* A calmer, reusable star-field island for SECTION backgrounds (everything that
|
||||
* isn't the hero). Compared to AnimatedBackground it is:
|
||||
* - sparser and dimmer (so foreground content stays readable),
|
||||
* - slow-blink only (no shooting stars / nebula),
|
||||
* - the whole field rotates together very slowly, reading as a slice of sky
|
||||
* turning as the planet rotates.
|
||||
*
|
||||
* Carries a `view-transition-name` (default "sky") so that when the browser
|
||||
* morphs between pages via Astro's <ClientRouter />, the hero's star field and
|
||||
* a section's constellation field cross-fade/translate as one continuous sky.
|
||||
* On the Professional page we render CircuitField instead, which shares the
|
||||
* same transition name — so constellations visibly MORPH into circuit traces.
|
||||
*
|
||||
* Honors prefers-reduced-motion (renders a static field) and pauses when the
|
||||
* section is off-screen.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Density multiplier. 1 = default; lower for quieter sections. */
|
||||
density?: number;
|
||||
/**
|
||||
* Tailwind/className passthrough. The .astro call site is responsible for
|
||||
* including `vt-sky` when this element should carry the view-transition
|
||||
* morph. We do NOT compute the transition class here: conditional className
|
||||
* interpolation triggers a server/client hydration attribute mismatch under
|
||||
* Astro's client:idle. A static, fully-formed string keeps SSR and hydration
|
||||
* byte-identical.
|
||||
*/
|
||||
className?: string;
|
||||
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
|
||||
tint?: 'gold' | 'violet' | 'teal';
|
||||
}
|
||||
|
||||
export default function ConstellationField({
|
||||
density = 1,
|
||||
className = '',
|
||||
tint = 'gold',
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let angle = 0;
|
||||
let t0 = performance.now();
|
||||
|
||||
interface Star {
|
||||
rx: number;
|
||||
ry: number;
|
||||
r: number;
|
||||
tw: number;
|
||||
tws: number;
|
||||
baseA: number;
|
||||
anchor: boolean;
|
||||
}
|
||||
let stars: Star[] = [];
|
||||
|
||||
const gold = '#c9a24a';
|
||||
const violet = '#6a2fe0';
|
||||
const teal = '#2bc2b7';
|
||||
const white = '#f4f2ed';
|
||||
const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold;
|
||||
|
||||
// One full rotation every ~10 minutes — barely perceptible, "planet turning".
|
||||
const ROT_SPEED = (Math.PI * 2) / (10 * 60);
|
||||
const LINK_DIST = 175;
|
||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Pivot = top-right corner. The disk is centered there so the field
|
||||
// rotates around that corner; maxR reaches the far (bottom-left) corner.
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const maxR = Math.sqrt(cx * cx + height * height) + 40;
|
||||
// Ultra-dense: ~5x. One star per ~300 px^2, scaled by density; ~17%
|
||||
// anchors for a very dense constellation backbone.
|
||||
const count = Math.min(
|
||||
3000,
|
||||
Math.floor(((Math.PI * maxR * maxR) / 300) * density),
|
||||
);
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 7 === 0;
|
||||
const rr = maxR * Math.sqrt(Math.random());
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
return {
|
||||
rx: cx + rr * Math.cos(a),
|
||||
ry: cy + rr * Math.sin(a),
|
||||
r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
tws: 0.1 + Math.random() * 0.25,
|
||||
baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3,
|
||||
anchor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
for (const s of stars) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = s.anchor ? accent : white;
|
||||
ctx.globalAlpha = s.baseA;
|
||||
ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
void cx;
|
||||
void cy;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||
t0 = now;
|
||||
angle += ROT_SPEED * dt;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Pivot = top-right corner (matches the disk center used in resize()).
|
||||
const cx = width;
|
||||
const cy = 0;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const project = (rx: number, ry: number): [number, number] => [
|
||||
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||
];
|
||||
|
||||
const proj = stars.map((s) => {
|
||||
const [x, y] = project(s.rx, s.ry);
|
||||
return { s, x, y };
|
||||
});
|
||||
|
||||
// Constellation lines between near anchors.
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i < proj.length; i++) {
|
||||
const A = proj[i];
|
||||
if (!A.s.anchor) continue;
|
||||
for (let j = i + 1; j < proj.length; j++) {
|
||||
const B = proj[j];
|
||||
if (!B.s.anchor) continue;
|
||||
const ddx = A.x - B.x;
|
||||
const ddy = A.y - B.y;
|
||||
const d2 = ddx * ddx + ddy * ddy;
|
||||
if (d2 > LINK_DIST_SQ) continue;
|
||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1600 + (i + j) * 0.7);
|
||||
const alpha = proximity * 0.24 * (0.55 + 0.45 * pulse);
|
||||
ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(A.x, A.y);
|
||||
ctx.lineTo(B.x, B.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Slow-blink stars.
|
||||
for (const p of proj) {
|
||||
const s = p.s;
|
||||
s.tw += s.tws * dt;
|
||||
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||
const blink = 0.55 + 0.45 * Math.sin(s.tw);
|
||||
const alpha = Math.max(0, Math.min(1, s.baseA * blink));
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = s.anchor ? accent : white;
|
||||
ctx.globalAlpha = alpha;
|
||||
if (s.anchor) {
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowColor = accent;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
if (reduce) {
|
||||
drawStatic();
|
||||
} else {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
const onResize = () => {
|
||||
resize();
|
||||
if (reduce) drawStatic();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (reduce) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, [density, tint]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
/**
|
||||
* StarBurst.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A small decorative four-point "sparkle" star (alchemical/star-chart style),
|
||||
* drawn as inline SVG so it inherits `currentColor`. Used as an accent mark
|
||||
* next to headings, in dividers, and as corner flourishes on frames.
|
||||
*
|
||||
* Variants:
|
||||
* - `solid` : filled sparkle (default)
|
||||
* - `outline`: stroke-only, lighter
|
||||
*
|
||||
* Usage:
|
||||
* <StarBurst class="h-4 w-4" style="color: var(--color-gold)" />
|
||||
* <StarBurst variant="outline" class="h-6 w-6" />
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'solid' | 'outline';
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { variant = 'solid', class: className = '', style = '' } = Astro.props;
|
||||
const fill = variant === 'outline' ? 'none' : 'currentColor';
|
||||
const sw = variant === 'outline' ? 1.4 : 0;
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class={className}
|
||||
style={style}
|
||||
fill={fill}
|
||||
stroke="currentColor"
|
||||
stroke-width={sw}
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* The Patiun four-point star: thin straight arms (inner radius 0.3 of the
|
||||
outer), the same star used on the seal, the icons and the favicon. */}
|
||||
<polygon points="12,1 14.33,9.67 23,12 14.33,14.33 12,23 9.67,14.33 1,12 9.67,9.67" />
|
||||
</svg>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
/**
|
||||
* StarField.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A static field of tiny scattered stars + a few brighter sparkles, as inline
|
||||
* SVG. Use as an ambient, non-animated decorative layer behind hero blocks or
|
||||
* inside frames — the *animated* star field lives in AnimatedBackground.tsx.
|
||||
*
|
||||
* Stars are deterministic (hard-coded positions) so the layout is stable and
|
||||
* cheap. Pointer-events disabled; inherits `currentColor`.
|
||||
*
|
||||
* Usage:
|
||||
* <StarField class="absolute inset-0 h-full w-full" style="color: var(--color-gold); opacity:.5" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
/** Seed for how many sparkles; keeps the markup compact. */
|
||||
density?: 'sparse' | 'default' | 'dense';
|
||||
}
|
||||
const { class: className = '', style = '', density = 'default' } = Astro.props;
|
||||
|
||||
// Pre-baked star positions (cx, cy, r) on a 100x100 viewBox; scaled by density.
|
||||
const baseDots: [number, number, number][] = [
|
||||
[8, 12, 0.5], [22, 6, 0.7], [34, 18, 0.4], [47, 9, 0.6], [61, 14, 0.5],
|
||||
[73, 5, 0.8], [86, 16, 0.4], [94, 8, 0.5], [14, 28, 0.4], [29, 34, 0.6],
|
||||
[43, 27, 0.5], [57, 36, 0.4], [68, 29, 0.7], [81, 38, 0.5], [92, 31, 0.4],
|
||||
[6, 46, 0.6], [19, 52, 0.4], [31, 47, 0.5], [44, 55, 0.4], [58, 49, 0.7],
|
||||
[71, 54, 0.5], [84, 47, 0.4], [96, 55, 0.5], [12, 66, 0.4], [26, 72, 0.6],
|
||||
[39, 64, 0.5], [52, 73, 0.4], [65, 67, 0.5], [78, 74, 0.7], [90, 68, 0.4],
|
||||
[9, 84, 0.5], [23, 90, 0.4], [36, 86, 0.6], [49, 92, 0.4], [62, 85, 0.5],
|
||||
[75, 91, 0.7], [88, 88, 0.4], [16, 40, 0.4], [50, 22, 0.4], [80, 60, 0.4],
|
||||
];
|
||||
// Brighter 4-point sparkles (cx, cy, size).
|
||||
const sparkles: [number, number, number][] = [
|
||||
[20, 20, 2.2], [70, 30, 1.8], [40, 60, 2.4], [85, 75, 1.6], [12, 70, 1.4],
|
||||
];
|
||||
|
||||
const step = density === 'sparse' ? 2 : density === 'dense' ? 1 : 1;
|
||||
const dots = baseDots.filter((_, i) => i % step === 0);
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class={className}
|
||||
style={style}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{dots.map(([x, y, r]) => (
|
||||
<circle cx={x} cy={y} r={r} />
|
||||
))}
|
||||
{sparkles.map(([x, y, s]) => (
|
||||
<path
|
||||
d={`M${x} ${y - s}c${s * 0.18} ${s * 0.6} ${s * 0.4} ${s * 0.82} ${s} ${s}c${-s * 0.6}${s * 0.18} ${-s * 0.82}${s * 0.4} ${-s}${s}c${-s * 0.18}${-s * 0.6} ${-s * 0.4}${-s * 0.82} ${-s}${-s}c${s * 0.6}${-s * 0.18} ${s * 0.82}${-s * 0.4} ${s}${-s}z`}
|
||||
opacity="0.9"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
/**
|
||||
* Certifications.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card grid for the Certifications section of the Professional page. Each entry
|
||||
* is a cert with name, issuer, year, and optional status. Seeded with SAP/cloud
|
||||
* placeholders (Greg to fill); swap the array below.
|
||||
*
|
||||
* Card aesthetic matches EducationHistory / ProjectCard.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface CertEntry {
|
||||
icon: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
year: string;
|
||||
status?: "Active" | "Expired" | "In progress";
|
||||
}
|
||||
|
||||
// Placeholder entries — replace with Greg's real certifications.
|
||||
const entries: CertEntry[] = [
|
||||
/*{
|
||||
icon: "microsoft-azure-devops",
|
||||
name: "SAP BTP Certification",
|
||||
issuer: "SAP",
|
||||
year: "[Year]",
|
||||
status: "Active",
|
||||
},*/
|
||||
{
|
||||
icon: "cloud",
|
||||
name: "Planet 9 Devlopment",
|
||||
issuer: "Neptune",
|
||||
year: "2019",
|
||||
status: "Active",
|
||||
},
|
||||
/*{
|
||||
icon: "robot",
|
||||
name: "[AI / ML cert]",
|
||||
issuer: "[Issuer]",
|
||||
year: "[Year]",
|
||||
},*/
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
entries.map((c) => (
|
||||
<article class="card flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={c.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
{c.status && <span class="pill">{c.status}</span>}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="font-display text-base" style="color: var(--color-text);">
|
||||
{c.name}
|
||||
</h3>
|
||||
<p class="text-sm font-medium" style="color: var(--color-accent);">
|
||||
{c.issuer}
|
||||
</p>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
{c.year}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* ContactForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Accessible contact form island. Posts to /api/contact, which either sends an
|
||||
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
||||
* states, does lightweight client validation, and includes a honeypot field for
|
||||
* crude spam defense (the server also rate-limits and verifies a Turnstile
|
||||
* token when configured).
|
||||
*
|
||||
* The form is keyboard-accessible, labels every field, and announces status
|
||||
* changes via an aria-live region.
|
||||
*
|
||||
* Turnstile: when `turnstileSiteKey` is provided, Cloudflare's invisible
|
||||
* challenge is rendered. The token it produces is sent with the submission and
|
||||
* verified server-side. If no key is configured (local dev), Turnstile is
|
||||
* skipped entirely so the form still works.
|
||||
*/
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// Declared by the Turnstile script on window once loaded.
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({ turnstileSiteKey }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
// Holds the live Turnstile token between renders (cleared on reset/expiry).
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
// Widget ID returned by turnstile.render(); used to reset on failure.
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
// Container div the widget mounts into.
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Load the Turnstile script once when a site key is configured, then render
|
||||
// the widget once it's ready. Two effects coordinate via a script onload
|
||||
// callback + a readiness poll, because the script loads async and
|
||||
// window.turnstile isn't defined the moment the script tag is inserted.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
|
||||
// If the script is already present (e.g. HMR / re-mount), just render.
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid injecting a second script tag on re-mount.
|
||||
let script = document.getElementById(
|
||||
'cf-turnstile-script',
|
||||
) as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
// No SRI `integrity` attribute here — deliberate. Cloudflare rotates the
|
||||
// Turnstile loader contents (so a pinned hash would break the challenge
|
||||
// on rotation), the loader itself fetches further challenge resources
|
||||
// that SRI can't cover, and Cloudflare does not publish official SRI
|
||||
// hashes. SRI on Turnstile is industry-standard NOT to use; the risk is
|
||||
// mitigated by serving the site over HTTPS and by the CSP (which
|
||||
// restricts script-src to this one Cloudflare origin). See
|
||||
// src/middleware.ts.
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// When the script finishes loading, render the widget. onload fires only
|
||||
// once even if multiple components share the same script tag.
|
||||
script.addEventListener('load', renderWidget);
|
||||
return () => {
|
||||
script?.removeEventListener('load', renderWidget);
|
||||
};
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function renderWidget() {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
// Avoid double-rendering if this fires twice (StrictMode / onload + poll).
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(
|
||||
turnstileContainerRef.current,
|
||||
{
|
||||
sitekey: turnstileSiteKey,
|
||||
// Called by Turnstile once it has a token. For invisible/Managed mode
|
||||
// this fires automatically once the challenge resolves.
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
},
|
||||
// On expiry/error, clear the token so the server rejects the stale
|
||||
// submission — but DON'T re-disable the button, because an invisible
|
||||
// widget gives the user nothing to click to re-solve it. Instead we
|
||||
// reset the widget, which re-runs the challenge and fires callback
|
||||
// again with a fresh token.
|
||||
// On token expiry: clear it. The next submit will detect the empty
|
||||
// token and reset the widget for a fresh challenge. We don't auto-reset
|
||||
// here to keep behavior simple and avoid any retry churn.
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
// On widget error: clear the token but DO NOT call turnstile.reset().
|
||||
// Resetting from inside error-callback causes Turnstile to immediately
|
||||
// retry, hit the same error (e.g. 110200 domain-not-authorized), and
|
||||
// fire error-callback again — an infinite loop that manifests as the
|
||||
// widget rapidly flashing. Instead we leave the widget in its errored
|
||||
// state; the next form submission will reset it for a fresh attempt.
|
||||
// The error surfaces as the token being empty, which onSubmit reports.
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!values.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!values.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(values.email)) e.email = 'That email address looks off.';
|
||||
if (!values.message.trim()) e.message = 'Please enter a message.';
|
||||
else if (values.message.trim().length < 10) e.message = 'A little more detail, please (10+ characters).';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
message: String(data.get('message') ?? '').trim(),
|
||||
// Honeypot: bots fill this, humans never see it.
|
||||
company: String(data.get('company') ?? ''),
|
||||
// Turnstile token (empty string if Turnstile is not configured).
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
// If Turnstile is configured, require a token before we even hit the server.
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.message = found.message ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// Reset the Turnstile widget so a fresh challenge runs. The widget's
|
||||
// callback will populate the token ref with a new value when it
|
||||
// resolves; the button is never disabled on Turnstile state, so there's
|
||||
// no UI flag to manage here.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
// Fresh challenge for the next submission.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BrandIcon name="check" size={24} />
|
||||
</span>
|
||||
<h3 className="font-display text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Message sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks for reaching out. I will get back to you soon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('idle')}
|
||||
className="btn btn-secondary mt-2"
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot: visually hidden, not aria-hidden so screen readers can skip it. */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={{ color: 'var(--color-text)' }}>Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.name ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
/>
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={{ color: 'var(--color-text)' }}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.email ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="message" className={labelBase} style={{ color: 'var(--color-text)' }}>Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.message ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
aria-describedby={errors.message ? 'message-error' : undefined}
|
||||
/>
|
||||
{errors.message && <p id="message-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cloudflare Turnstile widget. Rendered by the script via the ref.
|
||||
Hidden container; Turnstile injects its iframe here. Skipped entirely
|
||||
when no site key is configured (turnstileSiteKey is undefined). */}
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
|
||||
{/* Screen-reader status announcements. */}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your message.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
/**
|
||||
* EducationHistory.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card grid for the Education section of the Professional page. Each entry is a
|
||||
* degree/diploma with institution, period, and optional detail. Data is seeded
|
||||
* with placeholder rows (Greg to fill); swap the array below with real entries.
|
||||
*
|
||||
* Mirrors the Timeline/ProjectCard card aesthetic: raised surface, hairline
|
||||
* border, gold accent for the credential, mono period label.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
|
||||
interface EduEntry {
|
||||
icon: string;
|
||||
credential: string;
|
||||
institution: string;
|
||||
period: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// Placeholder entries — replace with Greg's real education history.
|
||||
const entries: EduEntry[] = [
|
||||
{
|
||||
icon: "school",
|
||||
credential: "B.S., Computer Science",
|
||||
institution: "Drexel University",
|
||||
period: "2014 – 2018",
|
||||
detail:
|
||||
"Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.",
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{
|
||||
entries.map((e) => (
|
||||
<article class="card flex flex-col gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={e.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="font-display text-lg" style="color: var(--color-text);">
|
||||
{e.credential}
|
||||
</h3>
|
||||
<p
|
||||
class="font-sans font-semibold"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
{e.institution}
|
||||
</p>
|
||||
<span
|
||||
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
{e.period}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{e.detail && (
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
{e.detail}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* ProjectCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a single project entry from the `projects` collection. Renders title,
|
||||
* role, tech pills, summary, and links (repo/demo/case study). Placeholder
|
||||
* entries (frontmatter `placeholder: true`) get a subtle badge so they read as
|
||||
* "to be filled" rather than real work, per the chosen content approach.
|
||||
*/
|
||||
import { type CollectionEntry } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<'projects'>;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const d = project.data;
|
||||
|
||||
const links = [
|
||||
d.repo && { href: d.repo, label: 'GitHub', icon: 'github' },
|
||||
d.demo && { href: d.demo, label: 'Live demo', icon: 'open-in-new' },
|
||||
d.caseStudy && { href: d.caseStudy, label: 'Case study', icon: 'file-document-outline' },
|
||||
].filter(Boolean) as { href: string; label: string; icon: string }[];
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-4',
|
||||
d.placeholder && 'border-dashed',
|
||||
]}
|
||||
style={d.placeholder ? 'border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);' : ''}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 style="color: var(--color-text);">{d.title}</h3>
|
||||
<p class="font-eyebrow text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{d.role}
|
||||
</p>
|
||||
</div>
|
||||
{d.placeholder && (
|
||||
<span
|
||||
class="pill"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="pencil" class="h-3.5 w-3.5" />
|
||||
Placeholder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.tech.length > 0 && (
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{d.tech.map((t) => <li class="pill">{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{d.summary}
|
||||
</p>
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul class="mt-1 flex flex-wrap gap-4">
|
||||
{links.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name={l.icon} class="h-4 w-4" />
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
/**
|
||||
* SectionNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Fixed LEFT-SIDE vertical rail for the Professional page. Six sections as a
|
||||
* stacked icon+label list; highlights the current section; click to smooth-
|
||||
* scroll. A vertical progress line LEFT of the icons fills as you scroll.
|
||||
*
|
||||
* Three bugs previously fixed here:
|
||||
* 1. Contrast: the rail used surface-relative tokens (--color-surface-raised /
|
||||
* --color-starlight) which collapsed to the same tone on light surfaces (the
|
||||
* interest-surface cream made icons/text invisible). The rail now uses
|
||||
* FIXED dark colors regardless of the page's surface tone.
|
||||
* 2. Progress line clipping: the line sat at the icon column's horizontal
|
||||
* center, running THROUGH the circles. It now sits in a dedicated left
|
||||
* gutter, clear of the icons.
|
||||
* 3. Last section (Contact) not activating: the IntersectionObserver band was
|
||||
* too thin for the short final section to land in. The rootMargin is
|
||||
* widened + click handlers set active immediately so feedback is instant.
|
||||
*
|
||||
* Hidden below `lg`.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
const sections = [
|
||||
{ id: "summary", label: "Summary", icon: "format-list-text" },
|
||||
{ id: "experience", label: "Experience", icon: "timeline-text" },
|
||||
{ id: "skills", label: "Skills", icon: "chart-bar" },
|
||||
{ id: "projects", label: "Projects", icon: "view-grid" },
|
||||
{ id: "education", label: "Education", icon: "school" },
|
||||
{ id: "contact", label: "Contact", icon: "email" },
|
||||
];
|
||||
---
|
||||
|
||||
<nav
|
||||
aria-label="Professional sections"
|
||||
class="pro-section-nav fixed left-4 top-1/2 z-40 hidden -translate-y-1/2 flex-col lg:flex"
|
||||
data-pro-nav
|
||||
>
|
||||
{/* Rail container. FIXED dark colors (not surface tokens) so it's always
|
||||
legible regardless of the page's data-surface tone. Layout: a left gutter
|
||||
for the progress line, then the icon+label list. */}
|
||||
<div
|
||||
class="relative flex gap-2 rounded-2xl border p-3 shadow-2xl"
|
||||
style="border-color: rgba(201, 162, 74, 0.22); background: linear-gradient(180deg, rgba(19, 28, 43, 0.97), rgba(14, 21, 34, 0.97)); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 18px 40px -24px rgba(8, 12, 20, 0.7);"
|
||||
>
|
||||
{/* --- Left gutter: the progress track + fill. Sits LEFT of the icons. --- */}
|
||||
<div class="relative my-1 w-2 shrink-0">
|
||||
{/* Track (full height of the gutter). */}
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2"
|
||||
style="background: rgba(255, 255, 255, 0.12);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
{/* Fill — height set by JS. Anchored to the top of the track. */}
|
||||
<div
|
||||
class="pointer-events-none absolute left-1/2 top-0 w-px -translate-x-1/2 origin-top"
|
||||
style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);"
|
||||
data-pro-fill
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- The icon+label list. --- */}
|
||||
<div class="flex flex-col gap-1">
|
||||
{sections.map((s) => (
|
||||
<a
|
||||
href={`#${s.id}`}
|
||||
data-section-link={s.id}
|
||||
class="group relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all"
|
||||
style="color: #ece9e2;"
|
||||
>
|
||||
<span
|
||||
class="relative z-10 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors"
|
||||
style="background: rgba(201, 162, 74, 0.14); color: #d8b35a;"
|
||||
data-section-dot={s.id}
|
||||
>
|
||||
<Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" />
|
||||
</span>
|
||||
<span class="relative z-10 tracking-wide">{s.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Inactive items: readable but slightly dimmed. */
|
||||
.pro-section-nav a[data-section-link] {
|
||||
opacity: 0.82;
|
||||
}
|
||||
.pro-section-nav a[data-section-link]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(201, 162, 74, 0.08);
|
||||
}
|
||||
/* Active link: bright accent text + accent wash. */
|
||||
.pro-section-nav a[data-active="true"] {
|
||||
opacity: 1 !important;
|
||||
color: var(--color-accent) !important;
|
||||
background: rgba(201, 162, 74, 0.14);
|
||||
}
|
||||
/* Active dot: filled accent, dark icon, glow ring. */
|
||||
.pro-section-nav a[data-active="true"] [data-section-dot] {
|
||||
background: var(--color-accent) !important;
|
||||
color: #131c2b !important;
|
||||
box-shadow: 0 0 12px rgba(201, 162, 74, 0.65);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function setupSectionSpy() {
|
||||
const nav = document.querySelector<HTMLElement>("[data-pro-nav]");
|
||||
if (!nav) return;
|
||||
|
||||
const links = Array.from(
|
||||
document.querySelectorAll<HTMLAnchorElement>("[data-section-link]"),
|
||||
);
|
||||
if (!links.length) return;
|
||||
const sections = links
|
||||
.map((l) => document.getElementById(l.dataset.sectionLink!))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
const fill = nav.querySelector<HTMLElement>("[data-pro-fill]");
|
||||
|
||||
const setActive = (id: string) => {
|
||||
for (const l of links) {
|
||||
l.setAttribute("data-active", l.dataset.sectionLink === id ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
// Manual-scroll suppression. When the user clicks a nav link we set active
|
||||
// immediately AND suppress IO updates until the smooth-scroll settles. This
|
||||
// is what stops the flicker: without it, the IO callback and the scroll
|
||||
// handler fight during the animated scroll, toggling active state rapidly.
|
||||
let suppressIo = false;
|
||||
let suppressTimer: number | undefined;
|
||||
|
||||
// Click → set active immediately + suppress IO until scrolling settles.
|
||||
for (const l of links) {
|
||||
l.addEventListener("click", () => {
|
||||
const id = l.dataset.sectionLink!;
|
||||
setActive(id);
|
||||
suppressIo = true;
|
||||
window.clearTimeout(suppressTimer);
|
||||
// Re-enable IO once the smooth-scroll has had time to finish.
|
||||
suppressTimer = window.setTimeout(() => {
|
||||
suppressIo = false;
|
||||
}, 900);
|
||||
});
|
||||
}
|
||||
|
||||
// The IntersectionObserver is the SINGLE source of truth for the active
|
||||
// section during free scrolling. (Previously the scroll handler also set
|
||||
// active, which caused the two systems to thrash.)
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (suppressIo) return;
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||
if (visible) setActive(visible.target.id);
|
||||
},
|
||||
// Band spans roughly the middle 50% of the viewport vertically.
|
||||
{ rootMargin: "-25% 0px -25% 0px", threshold: [0, 0.1, 0.25, 0.5] },
|
||||
);
|
||||
sections.forEach((s) => io.observe(s));
|
||||
|
||||
// Progress fill ONLY. The scroll handler must never set active state — that
|
||||
// was the other half of the flicker. Last-section-at-bottom is handled by
|
||||
// a dedicated, debounced check below instead of on every scroll tick.
|
||||
const updateFill = () => {
|
||||
if (!fill) return;
|
||||
const track = fill.parentElement; // the .w-2 gutter
|
||||
if (!track) return;
|
||||
const doc = document.documentElement;
|
||||
const max = doc.scrollHeight - doc.clientHeight;
|
||||
const frac = max > 0 ? Math.max(0, Math.min(1, window.scrollY / max)) : 0;
|
||||
fill.style.height = `${(frac * track.clientHeight).toFixed(1)}px`;
|
||||
};
|
||||
|
||||
// Debounced at-bottom check: pin the last section active when truly at the
|
||||
// bottom, but only after scrolling has paused (not on every tick).
|
||||
let bottomTimer: number | undefined;
|
||||
const checkBottom = () => {
|
||||
window.clearTimeout(bottomTimer);
|
||||
bottomTimer = window.setTimeout(() => {
|
||||
if (suppressIo) return;
|
||||
const doc = document.documentElement;
|
||||
const atBottom =
|
||||
window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
|
||||
if (atBottom && sections.length) {
|
||||
setActive(sections[sections.length - 1].id);
|
||||
}
|
||||
}, 120);
|
||||
};
|
||||
|
||||
updateFill();
|
||||
window.addEventListener("scroll", () => {
|
||||
updateFill();
|
||||
checkBottom();
|
||||
}, { passive: true });
|
||||
window.addEventListener("resize", updateFill);
|
||||
}
|
||||
setupSectionSpy();
|
||||
document.addEventListener("astro:page-load", setupSectionSpy);
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
/**
|
||||
* SkillGrid.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the spec's three skill tiers (Core Competencies, Technical Stack,
|
||||
* Emerging & Exploring) as categorized cards. The spec lists some items as
|
||||
* "[Fill in]" for languages/frameworks/tools; those are surfaced as Placeholder
|
||||
* slots rather than invented content.
|
||||
*
|
||||
* Items map to mdi icons for visual texture. The Core tier uses a subtle bar
|
||||
* treatment (decorative, not a proficiency claim) to feel like the spec's
|
||||
* "visual skill bars" suggestion without implying false metrics.
|
||||
*/
|
||||
import Icon from "@components/Icon.astro";
|
||||
import Placeholder from "@components/layout/Placeholder.astro";
|
||||
|
||||
const core = [
|
||||
{ icon: "cloud", label: "Cloud Architecture & Development" },
|
||||
{ icon: "cellphone-link", label: "Mobile Application Development" },
|
||||
{ icon: "server-network", label: "SAP Business Technology Platform (BTP)" },
|
||||
{ icon: "account-group", label: "Team Leadership & Technical Mentorship" },
|
||||
{ icon: "translate", label: "Cross-functional Communication" },
|
||||
];
|
||||
|
||||
const emerging = [
|
||||
{ icon: "chart-line", label: "SAP BTP deep-dive (active expansion)" },
|
||||
{ icon: "robot", label: "Cutting edge AI-powered development tooling" },
|
||||
{
|
||||
icon: "cog-refresh",
|
||||
label: "Advanced automation and workflow engineering",
|
||||
},
|
||||
];
|
||||
|
||||
// Technical stack mixes known items (from spec context) with explicit gaps.
|
||||
const techStack = [
|
||||
{
|
||||
icon: "code-braces",
|
||||
label: "Languages",
|
||||
value:
|
||||
"JavaScript, TypeScript, Java, Python, C#, C, SQL, HTML, CSS, and many more.",
|
||||
hint: "JavaScript, TypeScript, Java, Python, C#, C, SQL, HTML, CSS, and many more.",
|
||||
},
|
||||
{
|
||||
icon: "cube-outline",
|
||||
label: "Frameworks",
|
||||
value: "React, Vue, Astro, Flask",
|
||||
hint: "Add your primary frameworks here.",
|
||||
},
|
||||
{
|
||||
icon: "microsoft-azure-devops",
|
||||
label: "Cloud Platforms",
|
||||
value: "SAP BTP, Google Cloud Platform, AWS",
|
||||
hint: "List the cloud platforms you ship on.",
|
||||
},
|
||||
{
|
||||
icon: "git",
|
||||
label: "Tools",
|
||||
value:
|
||||
"PostgreSQL, VSCode, Unity, XCode, Android Studio, Git, Gitea, CI/CD pipelines",
|
||||
hint: "Add the rest of your toolchain.",
|
||||
},
|
||||
{
|
||||
icon: "brain",
|
||||
label: "AI/ML",
|
||||
value:
|
||||
"Hands-on experience with machine learning frameworks and LLM integration and agent harnesses like ClaudeCode, Hermes, and OpenClaw",
|
||||
hint: null,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Core Competencies -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
Core Competencies
|
||||
</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{
|
||||
core.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-center gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 60}ms`}
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm font-medium" style="color: var(--color-text);">
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Decorative bar; intentionally not a % so we never imply false metrics. */}
|
||||
<span
|
||||
class="ml-auto hidden h-1.5 w-16 overflow-hidden rounded-full sm:block"
|
||||
style="background: var(--color-border);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="block h-full w-full"
|
||||
style="background: var(--color-accent);"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Technical Stack -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">Technical Stack</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{
|
||||
techStack.map((item) => (
|
||||
<div class="card flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span
|
||||
class="font-sans text-sm font-semibold"
|
||||
style="color: var(--color-text);"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.value ? (
|
||||
<p class="text-sm" style="color: var(--color-text-muted);">
|
||||
{item.value}
|
||||
</p>
|
||||
) : (
|
||||
<Placeholder
|
||||
label={item.label}
|
||||
hint={item.hint ?? ""}
|
||||
class="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emerging & Exploring -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-display" style="color: var(--color-text);">
|
||||
Emerging & Exploring
|
||||
</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
{
|
||||
emerging.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-start gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 70}ms`}
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text);">
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
/**
|
||||
* Timeline.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Vertical timeline for the Experience section. Each entry has a marker (mdi
|
||||
* icon), a period label, company/title, focus, and rendered markdown body (the
|
||||
* bullet list from the experience collection entry).
|
||||
*
|
||||
* The vertical line is drawn with a pseudo-element; markers sit on it. Entry
|
||||
* bodies are expandable detail (the spec asks for "expandable detail"). On
|
||||
* small screens the marker line stays but spacing tightens.
|
||||
*
|
||||
* Astro 7 content layer: `render(entry)` returns `{ Content }` (an Astro
|
||||
* component), which we render with `<Content />` rather than stringifying.
|
||||
*/
|
||||
import { type CollectionEntry, render } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
|
||||
interface Props {
|
||||
entries: CollectionEntry<'experience'>[];
|
||||
}
|
||||
|
||||
const { entries } = Astro.props;
|
||||
|
||||
// Pre-render each entry to get its Content component (Astro 7 API).
|
||||
const rendered = await Promise.all(
|
||||
entries.map(async (e) => ({
|
||||
entry: e,
|
||||
Content: (await render(e)).Content,
|
||||
})),
|
||||
);
|
||||
---
|
||||
|
||||
<ol class="flex flex-col gap-10">
|
||||
{rendered.map(({ entry, Content }, i) => (
|
||||
<li class="relative">
|
||||
<Reveal delay={i * 80}>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
{/* Header row: icon + company name inline, period on the right. */}
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
<h3 class="flex items-center gap-3" style="color: var(--color-text);">
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={entry.data.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
</span>
|
||||
{entry.data.company}
|
||||
</h3>
|
||||
<span class="font-eyebrow text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{entry.data.period}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-sans font-semibold" style="color: var(--color-accent);">
|
||||
{entry.data.title}
|
||||
</p>
|
||||
{entry.data.focus && (
|
||||
<p class="text-sm italic" style="color: var(--color-text-muted);">
|
||||
Focus: {entry.data.focus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details class="group mt-1">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-2 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="chevron-down" class="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Show details
|
||||
</summary>
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-3 text-sm leading-relaxed [&_ul]:flex [&_ul]:flex-col [&_ul]:gap-2 [&_li]:ml-4 [&_li]:list-disc [&_strong]:font-semibold [&_strong]:text-[var(--color-text)] [&_p:first-child]:mt-0"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Carousel.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Server-driven image carousel for store product pages. The list of slides
|
||||
* comes from the product's `images` array (defined in store-products.ts), so
|
||||
* you manage what's shown purely from the data file.
|
||||
*
|
||||
* Features:
|
||||
* - Swipe / drag via pointer events (works for touch + mouse).
|
||||
* - A caption (label) rendered on each slide.
|
||||
* - Prev/next buttons + dot indicators.
|
||||
* - Keyboard accessible (buttons + left/right arrows when focused).
|
||||
* - Respects prefers-reduced-motion (no slide animation, instant swap).
|
||||
*
|
||||
* The carousel is presentation-only on the image; each slide's alt text
|
||||
* carries the meaning for screen readers, and the caption is rendered as
|
||||
* visible text (not aria-hidden) so it's announced.
|
||||
*/
|
||||
import { useState, useRef, useCallback, type PointerEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
export interface CarouselSlide {
|
||||
src: string;
|
||||
alt: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slides: CarouselSlide[];
|
||||
/** Optional aria-label for the carousel region. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ slides, label = 'Product examples' }: Props) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// Horizontal drag delta in px accumulated during the current gesture.
|
||||
const dragDeltaRef = useRef(0);
|
||||
const startXRef = useRef(0);
|
||||
// Live offset applied to the track during a drag (px), for visual feedback.
|
||||
const [liveOffset, setLiveOffset] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const count = slides.length;
|
||||
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
if (count === 0) return;
|
||||
const next = (i + count) % count;
|
||||
setIndex(next);
|
||||
},
|
||||
[count],
|
||||
);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
const prev = useCallback(() => goTo(index - 1), [goTo, index]);
|
||||
|
||||
// --- Pointer (touch + mouse) drag handling --------------------------------
|
||||
// Threshold in px before a drag counts as a swipe. Below it, the gesture is
|
||||
// treated as a click (ignored), so taps on the image don't advance.
|
||||
const SWIPE_THRESHOLD = 40;
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
// Only react to primary button / touch.
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||
setDragging(true);
|
||||
startXRef.current = e.clientX;
|
||||
dragDeltaRef.current = 0;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
const delta = e.clientX - startXRef.current;
|
||||
dragDeltaRef.current = delta;
|
||||
setLiveOffset(delta);
|
||||
};
|
||||
|
||||
const finishDrag = () => {
|
||||
if (!dragging) return;
|
||||
const delta = dragDeltaRef.current;
|
||||
setDragging(false);
|
||||
setLiveOffset(0);
|
||||
if (delta <= -SWIPE_THRESHOLD) {
|
||||
next();
|
||||
} else if (delta >= SWIPE_THRESHOLD) {
|
||||
prev();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
prev();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const slide = slides[index]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="group"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border select-none"
|
||||
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface-sunken)' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Slides track. We translate by -index*100% and apply the live drag
|
||||
offset for feedback. touch-action: pan-y lets vertical scroll pass
|
||||
through while we capture horizontal swipes. */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(${-index * 100}% + ${liveOffset}px))`,
|
||||
transitionDuration: dragging ? '0ms' : undefined,
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
>
|
||||
{slides.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-full shrink-0"
|
||||
aria-hidden={i !== index}
|
||||
aria-roledescription="slide"
|
||||
aria-label={`Slide ${i + 1} of ${count}`}
|
||||
>
|
||||
<img
|
||||
src={s.src}
|
||||
alt={s.alt}
|
||||
draggable={false}
|
||||
loading={i === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
{/* Caption gradient + label, always visible for the active slide. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end p-4"
|
||||
style={{ background: 'linear-gradient(to top, rgba(14,21,34,0.85), transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-sm font-semibold sm:text-base"
|
||||
style={{ color: 'var(--color-starlight)' }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prev / next controls. */}
|
||||
{count > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
aria-label="Previous slide"
|
||||
className="absolute left-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-starlight)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(14,21,34,0.5)',
|
||||
}}
|
||||
>
|
||||
<BrandIcon name="chevron-left" size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
aria-label="Next slide"
|
||||
className="absolute right-2 top-1/2 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-starlight)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
background: 'rgba(14,21,34,0.5)',
|
||||
}}
|
||||
>
|
||||
<BrandIcon name="chevron-right" size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slide counter (top-right). */}
|
||||
<span
|
||||
className="pointer-events-none absolute right-3 top-3 rounded-full px-2.5 py-1 font-eyebrow text-xs"
|
||||
style={{ color: 'var(--color-starlight)', background: 'rgba(14,21,34,0.6)' }}
|
||||
>
|
||||
{index + 1} / {count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators. */}
|
||||
{count > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: i === index ? '1.75rem' : '0.5rem',
|
||||
background:
|
||||
i === index
|
||||
? 'var(--color-accent)'
|
||||
: 'color-mix(in srgb, var(--color-text-subtle) 50%, transparent)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sr-only live region announcing the active slide's label. */}
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{slide.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
/**
|
||||
* ColorGrid.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the available print/filament colors for a product as a grid of
|
||||
* swatches. Each swatch shows the color, its name, and an In-stock / Out-of-
|
||||
* stock pill.
|
||||
*
|
||||
* The data comes from the product's `colors` array in store-products.ts. To
|
||||
* manage colors, edit that array (add/remove entries, toggle `inStock`).
|
||||
*
|
||||
* If `colors` is omitted or empty, this component renders nothing.
|
||||
*/
|
||||
import type { ColorOption } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
colors?: ColorOption[];
|
||||
}
|
||||
|
||||
const { colors = [] } = Astro.props;
|
||||
const inStockCount = colors.filter((c) => c.inStock).length;
|
||||
---
|
||||
|
||||
{colors.length > 0 && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">
|
||||
Choose from the available filament colors.
|
||||
</span>
|
||||
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">
|
||||
{inStockCount} of {colors.length} in stock
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{colors.map((c) => (
|
||||
<li
|
||||
class:list={[
|
||||
'flex items-center gap-3 rounded-xl border p-3',
|
||||
!c.inStock && 'opacity-60',
|
||||
]}
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
{/* Swatch. A hairline ring keeps light colors (e.g. bone white)
|
||||
visible against the raised surface. */}
|
||||
<span
|
||||
class="h-9 w-9 shrink-0 rounded-full"
|
||||
style={`background: ${c.hex}; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 18%, transparent);`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
{c.name}
|
||||
</span>
|
||||
<span
|
||||
class="pill text-[0.7rem]"
|
||||
style={c.inStock
|
||||
? undefined
|
||||
: 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'}
|
||||
>
|
||||
{c.inStock ? 'In stock' : 'Out of stock'}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* OrderForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store order-request form island. Modeled on ContactForm.tsx: same
|
||||
* idle/submitting/success/error states, honeypot, optional Turnstile, aria-live
|
||||
* status, and identical input styling. Posts to /api/order.
|
||||
*
|
||||
* This collects an ORDER REQUEST only — no payment fields, no checkout. The
|
||||
* submission is emailed to Greg. The server endpoint (/api/order) independently
|
||||
* re-checks store-availability.json and 403s if orders are closed, so the gate
|
||||
* can't be bypassed from the client.
|
||||
*
|
||||
* The form requires an explicit acknowledgment of the store policies (checkbox)
|
||||
* before it can be submitted.
|
||||
*/
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
type OrderType = 'cards' | 'deckBoxes' | 'both';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
details?: string;
|
||||
quantity?: string;
|
||||
agree?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ColorOption {
|
||||
name: string;
|
||||
hex: string;
|
||||
inStock: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
/**
|
||||
* Available filament colors for printed items, from store-products.ts. Only
|
||||
* in-stock colors are offered in the color dropdown. Omit to hide the color
|
||||
* selector entirely (e.g. if no colors are configured).
|
||||
*/
|
||||
colors?: ColorOption[];
|
||||
}
|
||||
|
||||
export default function OrderForm({ turnstileSiteKey, colors = [] }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
// Tracks the order type so the color selector can show/hide. Mirrors the
|
||||
// <select name="orderType"> so the two stay in sync.
|
||||
const [orderType, setOrderType] = useState<OrderType>('cards');
|
||||
|
||||
// Colors currently offered = in-stock only. Empty when none configured.
|
||||
const availableColors = colors.filter((c) => c.inStock);
|
||||
const showColors = availableColors.length > 0 && (orderType === 'deckBoxes' || orderType === 'both');
|
||||
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Same Turnstile bootstrap as ContactForm.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (window.turnstile) {
|
||||
renderWidget();
|
||||
return;
|
||||
}
|
||||
let script = document.getElementById('cf-turnstile-script') as HTMLScriptElement | null;
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.addEventListener('load', renderWidget);
|
||||
return () => {
|
||||
script?.removeEventListener('load', renderWidget);
|
||||
};
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function renderWidget() {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(turnstileContainerRef.current, {
|
||||
sitekey: turnstileSiteKey,
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
},
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
},
|
||||
theme: 'dark',
|
||||
});
|
||||
}
|
||||
|
||||
interface OrderValues {
|
||||
name: string;
|
||||
email: string;
|
||||
orderType: OrderType;
|
||||
color: string;
|
||||
quantity: string;
|
||||
details: string;
|
||||
notes: string;
|
||||
agree: boolean;
|
||||
company: string;
|
||||
turnstileToken: string;
|
||||
}
|
||||
|
||||
function validate(v: OrderValues): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!v.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!v.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(v.email)) e.email = 'That email address looks off.';
|
||||
if (!v.details.trim()) e.details = 'Please describe your order (card list or deck-box spec).';
|
||||
else if (v.details.trim().length < 10) e.details = 'A little more detail, please (10+ characters).';
|
||||
if (!v.quantity.trim()) e.quantity = 'Please enter an approximate quantity.';
|
||||
if (!v.agree) e.agree = 'You must acknowledge the policies to continue.';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values: OrderValues = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
orderType: (String(data.get('orderType') ?? 'cards') as OrderType),
|
||||
color: String(data.get('color') ?? '').trim(),
|
||||
quantity: String(data.get('quantity') ?? '').trim(),
|
||||
details: String(data.get('details') ?? '').trim(),
|
||||
notes: String(data.get('notes') ?? '').trim(),
|
||||
agree: data.get('agree') === 'on',
|
||||
company: String(data.get('company') ?? ''),
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.agree = found.agree ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
const fieldColor = { color: 'var(--color-text)' };
|
||||
const borderFor = (k: keyof FieldErrors) => (errors[k] ? 'var(--color-accent)' : 'var(--color-border)');
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BrandIcon name="check" size={24} />
|
||||
</span>
|
||||
<h3 className="font-display text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Order request sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks! I'll review your request and reply by email. No payment is taken through the site.
|
||||
</p>
|
||||
<button type="button" onClick={() => setStatus('idle')} className="btn btn-secondary mt-2">
|
||||
Submit another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={fieldColor}>Name</label>
|
||||
<input id="name" name="name" type="text" autoComplete="name" className={inputBase} style={{ borderColor: borderFor('name'), ...fieldColor }} aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'name-error' : undefined} />
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={fieldColor}>Email</label>
|
||||
<input id="email" name="email" type="email" autoComplete="email" className={inputBase} style={{ borderColor: borderFor('email'), ...fieldColor }} aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'email-error' : undefined} />
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="orderType" className={labelBase} style={fieldColor}>Order type</label>
|
||||
<select
|
||||
id="orderType"
|
||||
name="orderType"
|
||||
value={orderType}
|
||||
onChange={(e) => setOrderType(e.target.value as OrderType)}
|
||||
className={inputBase}
|
||||
style={{ borderColor: 'var(--color-border)', ...fieldColor }}
|
||||
>
|
||||
<option value="cards">Proxy MTG cards</option>
|
||||
<option value="deckBoxes">3D-printed deck box / counters</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color selector — only for printed items, only when colors are
|
||||
configured and in stock. The list comes from store-products.ts. */}
|
||||
{showColors && (
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="color" className={labelBase} style={fieldColor}>Print color</label>
|
||||
<select id="color" name="color" defaultValue="" className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor }}>
|
||||
<option value="" disabled>Select a color…</option>
|
||||
{availableColors.map((c) => (
|
||||
<option key={c.name} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
Colors shown are what’s loaded right now. See the deck-boxes page for swatches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="quantity" className={labelBase} style={fieldColor}>Approximate quantity</label>
|
||||
<input id="quantity" name="quantity" type="text" placeholder="e.g. 75 cards, or 1 deck box + 4 counters" className={inputBase} style={{ borderColor: borderFor('quantity'), ...fieldColor }} aria-invalid={Boolean(errors.quantity)} aria-describedby={errors.quantity ? 'quantity-error' : undefined} />
|
||||
{errors.quantity && <p id="quantity-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="details" className={labelBase} style={fieldColor}>Card list / deck-box spec</label>
|
||||
<textarea
|
||||
id="details"
|
||||
name="details"
|
||||
rows={6}
|
||||
className={inputBase}
|
||||
style={{ borderColor: borderFor('details'), ...fieldColor, resize: 'vertical' }}
|
||||
placeholder={'For cards: paste your full card list (one per line). Remember double-sided cards count as 2 slots.\nFor deck boxes: describe capacity, color, and any counters.'}
|
||||
aria-invalid={Boolean(errors.details)}
|
||||
aria-describedby={errors.details ? 'details-error' : undefined}
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
The card list is final once received — cards cannot be changed for that order.
|
||||
</p>
|
||||
{errors.details && <p id="details-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.details}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="notes" className={labelBase} style={fieldColor}>Notes <span className="font-normal" style={{ color: 'var(--color-text-subtle)' }}>(optional)</span></label>
|
||||
<textarea id="notes" name="notes" rows={3} className={inputBase} style={{ borderColor: 'var(--color-border)', ...fieldColor, resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
{/* Policy acknowledgment */}
|
||||
<label className="flex items-start gap-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<input
|
||||
id="agree"
|
||||
name="agree"
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 shrink-0"
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span>
|
||||
I understand these are clearly-marked proxies (not counterfeits), the card list is final
|
||||
once received, double-sided cards count as 2 slots, and all sales are final. I am not
|
||||
requesting custom or offensive art.
|
||||
</span>
|
||||
</label>
|
||||
{errors.agree && <p className="-mt-2 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.agree}</p>}
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={status === 'submitting'} className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{status === 'submitting' ? 'Sending…' : 'Send order request'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
||||
No payment is taken through this site. Your request is emailed to Greg, who will reply to
|
||||
confirm details and availability.
|
||||
</p>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your order request.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
/**
|
||||
* PricingTable.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic quantity/pricing table. Takes a caption + a list of tiers
|
||||
* ({ band, price, note? }) and renders a clean three-column table. Used for
|
||||
* both the card-set tiers and the deck-box print tiers.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Tier {
|
||||
band: string;
|
||||
price: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** mdi icon shown beside the caption. */
|
||||
icon?: string;
|
||||
caption: string;
|
||||
tiers: Tier[];
|
||||
/** Column header for the price column, e.g. "Price". */
|
||||
priceHeader?: string;
|
||||
/** Optional supporting line under the caption. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const { icon, caption, tiers, priceHeader = 'Price', subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
{icon && <Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />}
|
||||
{caption}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border" style="border-color: var(--color-border);">
|
||||
<table class="w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr style="background: var(--color-surface-sunken);">
|
||||
<th scope="col" class="px-4 py-3 font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
Quantity
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-sans text-xs font-semibold uppercase tracking-wider" style="color: var(--color-text-subtle);">
|
||||
{priceHeader}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.map((tier, i) => (
|
||||
<tr style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}>
|
||||
<td class="px-4 py-3" style="color: var(--color-text);">
|
||||
<span class="font-medium">{tier.band}</span>
|
||||
{tier.note && (
|
||||
<span class="ml-2 text-xs" style="color: var(--color-text-subtle);">({tier.note})</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-semibold" style="color: var(--color-accent);">
|
||||
{tier.price}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
/**
|
||||
* ProductCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Product specification block: a title + summary and a spec definition list
|
||||
* (size, thickness, finishes, white back, etc.). Example IMAGES live in the
|
||||
* product-page Carousel, not here, so this component is specs-only now.
|
||||
*
|
||||
* Used on the product detail pages (cards / deck-boxes). On the storefront we
|
||||
* use the compact ProductTile instead (image + title + summary).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
/** When true, show the full spec list (detail pages). When false, just
|
||||
* title + summary. */
|
||||
detailed?: boolean;
|
||||
/** Optional link wrapped around the compact card (storefront tiles). */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const { product, detailed = false, href } = Astro.props;
|
||||
const Wrapper = href ? 'a' : 'div';
|
||||
---
|
||||
|
||||
<Wrapper
|
||||
href={href}
|
||||
class:list={[
|
||||
'group flex flex-col gap-4 rounded-2xl border p-4',
|
||||
href && 'card-interactive',
|
||||
]}
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
</div>
|
||||
|
||||
{detailed && (
|
||||
<dl class="flex flex-col gap-0 rounded-xl border text-sm" style="border-color: var(--color-border);">
|
||||
{product.specs.map((spec, i) => (
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:items-baseline sm:gap-4"
|
||||
style={i > 0 ? 'border-top: 1px solid var(--color-border);' : undefined}
|
||||
>
|
||||
<dt class="font-sans text-xs font-semibold uppercase tracking-wider sm:w-40 sm:shrink-0" style="color: var(--color-text-subtle);">
|
||||
{spec.label}
|
||||
</dt>
|
||||
<dd style="color: var(--color-text);">{spec.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Wrapper>
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
/**
|
||||
* ProductTile.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Compact storefront tile for a product line: the first example image (from the
|
||||
* product's `images` array), the title, the summary, and a "View details" hint.
|
||||
* Wraps the whole tile in a link to the product page.
|
||||
*
|
||||
* The full image carousel lives on the product page; this is just the storefront
|
||||
* teaser. ProductCard.astro (specs-only) is used on the detail pages.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Product } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { product, href } = Astro.props;
|
||||
const cover = product.images[0];
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class="group card-interactive flex flex-col gap-4 rounded-2xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
{cover && (
|
||||
<figure class="relative w-full overflow-hidden rounded-xl" style="border: 1px solid var(--color-border);">
|
||||
<img
|
||||
src={cover.src}
|
||||
alt={cover.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[4/3] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
{product.images.length > 1 && (
|
||||
<span
|
||||
class="pointer-events-none absolute right-2 top-2 inline-flex items-center gap-1 rounded-full px-2 py-1 font-eyebrow text-xs"
|
||||
style="color: var(--color-starlight); background: rgba(14,21,34,0.6);"
|
||||
>
|
||||
<Icon name="gallery" class="h-3.5 w-3.5" />
|
||||
{product.images.length}
|
||||
</span>
|
||||
)}
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-lg font-bold" style="color: var(--color-text);">
|
||||
<Icon name={product.icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{product.title}
|
||||
</span>
|
||||
<p class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{product.summary}</p>
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium transition-colors" style="color: var(--color-accent);">
|
||||
View details
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* RequirementList.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the store rules / requirements. Each item has an mdi icon that
|
||||
* signals tone: 'check-decagram' / 'information' = neutral rule,
|
||||
* 'alert' = caution, 'close-circle' = hard no / final.
|
||||
*
|
||||
* Used on the storefront and repeated on the order page; the OrderForm also
|
||||
* requires an explicit acknowledgment of these.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import type { Requirement } from '@data/store-products';
|
||||
|
||||
interface Props {
|
||||
items: Requirement[];
|
||||
/** Optional heading shown above the list. */
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
const { items, heading } = Astro.props;
|
||||
|
||||
// Map the requirement icon to a tone color.
|
||||
function toneColor(icon: Requirement['icon']): string {
|
||||
switch (icon) {
|
||||
case 'alert':
|
||||
return '#d8b35a'; // gold — caution
|
||||
case 'close-circle':
|
||||
return '#e5655a'; // red — hard no
|
||||
case 'check-decagram':
|
||||
case 'information':
|
||||
default:
|
||||
return 'var(--color-accent)';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
{heading && (
|
||||
<h3 class="font-display text-xl font-bold" style="color: var(--color-text);">{heading}</h3>
|
||||
)}
|
||||
|
||||
<ul class="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
class="flex gap-3 rounded-lg border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<span class="mt-0.5 shrink-0" style={`color: ${toneColor(item.icon)};`}>
|
||||
<Icon name={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-sans text-sm font-semibold" style="color: var(--color-text);">{item.title}</span>
|
||||
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{item.body}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* SlotMeter.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual "X of Y slots open" capacity meter for a store category. Reads a slot
|
||||
* status (open + total) and renders a progress-style bar plus an open/closed
|
||||
* pill. The fill width reflects open/total; when open === 0 it shows CLOSED.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Category label, e.g. "Card orders" or "3D-print slots". */
|
||||
label: string;
|
||||
/** mdi icon for the row. */
|
||||
icon: string;
|
||||
/** Number of currently open slots. */
|
||||
open: number;
|
||||
/** Total slots for the category. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
const { label, icon, open, total } = Astro.props;
|
||||
const closed = open <= 0;
|
||||
const pct = total > 0 ? Math.max(0, Math.min(100, (open / total) * 100)) : 0;
|
||||
---
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border p-4"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-raised);"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-2 font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
<Icon name={icon} class="h-5 w-5" style="color: var(--color-accent);" />
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
class="pill"
|
||||
style={closed
|
||||
? 'background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);'
|
||||
: undefined}
|
||||
>
|
||||
{closed ? 'Closed' : 'Open'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm" style="color: var(--color-text-muted);">
|
||||
<span>{open} of {total} slots open</span>
|
||||
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">{closed ? '0%' : `${Math.round(pct)}%`}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-border) 60%, transparent);"
|
||||
role="meter"
|
||||
aria-valuenow={open}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={total}
|
||||
aria-label={`${label}: ${open} of ${total} slots open`}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-500"
|
||||
style={`width: ${pct}%; background: ${closed ? '#e55' : 'var(--color-accent)'};`}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
/**
|
||||
* StoreNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Store-only top navigation. Deliberately separate from the main NavBar: this
|
||||
* renders ONLY store links (Storefront, Cards, Deck Boxes, Order) and contains
|
||||
* NO links back to the main gregkilmer.com site, per Greg's request that the
|
||||
* two sites not be clearly linked yet.
|
||||
*
|
||||
* Visually it mirrors NavBar.astro's treatment — the dark nebula band, the
|
||||
* scroll blur, and the Patiun seal — so it reads as the same house while
|
||||
* being unmistakably a store (a "Store" wordmark + the store.gregkilmer.com
|
||||
* domain label).
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import MobileMenu from '@components/nav/MobileMenu.tsx';
|
||||
|
||||
interface NavEntry {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Single flat group (no "two wings" divider — the store is one thing).
|
||||
const storeNav: NavEntry[] = [
|
||||
{ label: 'Store', href: '/store', icon: 'store' },
|
||||
{ label: 'Cards', href: '/store/cards', icon: 'cards-playing' },
|
||||
{ label: 'Deck Boxes', href: '/store/deck-boxes', icon: 'printer-3d-nozzle' },
|
||||
{ label: 'Order', href: '/store/order', icon: 'clipboard-list' },
|
||||
];
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) =>
|
||||
href === '/store' ? path === '/store' : path === href || path.startsWith(href + '/');
|
||||
|
||||
// MobileMenu expects the { items: [] }[] shape.
|
||||
const groups = [{ items: storeNav }];
|
||||
---
|
||||
<header
|
||||
id="store-nav"
|
||||
data-nav
|
||||
class="on-sky fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300"
|
||||
>
|
||||
<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="Store">
|
||||
<!-- Wordmark: "Greg Kilmer · Store" + the store domain label -->
|
||||
<a href="/store" class="group flex items-center gap-3 leading-tight" aria-label="Greg Kilmer Store — 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.12em]" style="color: var(--color-starlight);">
|
||||
Greg Kilmer · Store
|
||||
</span>
|
||||
<span
|
||||
class="hidden font-eyebrow text-[0.7rem] tracking-[0.08em] sm:block"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
Store.GregKilmer.com
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
{storeNav.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class="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>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<MobileMenu client:idle groups={groups} currentPath={path} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The store nav uses a SOLID opaque band (not the main NavBar's translucent
|
||||
gradient), because the store index hero renders a bright animated field
|
||||
plus a gold label right at the top edge. A translucent nav let that hero
|
||||
content bleed through, making the nav read as if it sat behind the hero.
|
||||
Solid + a hairline border keeps it a clearly distinct bar at all times. */
|
||||
[data-nav-inner] {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(14, 21, 34) 0%,
|
||||
rgb(19, 28, 43) 100%
|
||||
);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
[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-accent) 30%,
|
||||
var(--color-border)
|
||||
);
|
||||
box-shadow: 0 8px 30px -20px rgba(8, 12, 20, 0.7);
|
||||
}
|
||||
[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>
|
||||
// Mirror NavBar's scroll handler. Re-bind on View Transitions navigations.
|
||||
function setupNavScroll() {
|
||||
const nav = document.getElementById('store-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,187 @@
|
||||
/**
|
||||
* BoardGameShelf.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Two-column "Board games" island for the TTRPGs page.
|
||||
*
|
||||
* Left: a list of game names, each a real <a> to its BoardGameGeek page.
|
||||
* Hovering (or keyboard-focusing) a name selects it.
|
||||
* Right: a tall image frame. At rest it shows Greg's board-game shelf photo;
|
||||
* when a name is selected, that game's BGG cover cross-fades in on
|
||||
* top of the shelf, with the game's name + note as a caption.
|
||||
*
|
||||
* Progressive enhancement: without JS the list is still fully usable — the
|
||||
* links work and the shelf photo stays put. The cross-fade is the enhancement.
|
||||
* framer-motion honors prefers-reduced-motion automatically.
|
||||
*
|
||||
* Data shape (src/data/boardgames.json):
|
||||
* { name: string; bggId: number; note?: string; image?: string }
|
||||
* `image` is optional — populated by scripts/fetch-bgg-covers.mjs. Rows without
|
||||
* an `image` simply keep the shelf photo on hover (graceful fallback).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import BrandIcon from '@components/brand/BrandIcon';
|
||||
|
||||
export interface BoardGame {
|
||||
name: string;
|
||||
/** BoardGameGeek numeric game id; drives the link + cover lookup. */
|
||||
bggId: number;
|
||||
/** Optional one-line note shown as the caption. */
|
||||
note?: string;
|
||||
/** Optional cover image URL/path. If absent, hovering won't swap images. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
games: BoardGame[];
|
||||
/** Public path to the default shelf photo, e.g. "/images/ttrpg-boardgame-shelf.jpg". */
|
||||
shelfImage: string;
|
||||
/** Alt text for the shelf photo. */
|
||||
shelfAlt: string;
|
||||
/** Optional caption shown at rest over the shelf photo. */
|
||||
shelfCaption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed height for BOTH columns. The list shows up to 8 rows in a scroll
|
||||
* container; the image frame is pinned to this same height so the two
|
||||
* columns stay aligned regardless of how many games are in the list.
|
||||
* ~3.25rem per row x 8 rows.
|
||||
*/
|
||||
const SHELF_HEIGHT = '44rem';
|
||||
|
||||
const crossfade = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
|
||||
};
|
||||
|
||||
export default function BoardGameShelf({
|
||||
games,
|
||||
shelfImage,
|
||||
shelfAlt,
|
||||
shelfCaption = 'The shelf',
|
||||
}: Props) {
|
||||
// Index of the active game, or null when at rest (showing the shelf).
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const activeGame = active !== null ? games[active] : null;
|
||||
// Only swap to the cover if the active game actually has an image.
|
||||
const showCover = activeGame?.image;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 sm:grid-cols-2 sm:items-stretch">
|
||||
{/* LEFT: game list — capped at the shelf height with internal scroll */}
|
||||
<ul
|
||||
className="flex flex-col divide-y overflow-y-auto pr-1"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
height: SHELF_HEIGHT,
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
{games.map((g, i) => {
|
||||
const isActive = active === i;
|
||||
return (
|
||||
<li key={g.bggId}>
|
||||
<a
|
||||
href={`https://boardgamegeek.com/boardgame/${g.bggId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onFocus={() => setActive(i)}
|
||||
onMouseLeave={() => setActive((cur) => (cur === i ? null : cur))}
|
||||
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
|
||||
className="group flex items-center gap-3 py-3 transition-colors"
|
||||
style={{
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
style={{
|
||||
background: isActive
|
||||
? 'color-mix(in srgb, var(--color-accent) 20%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-secondary) 16%, transparent)',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-secondary)',
|
||||
}}
|
||||
>
|
||||
{/* Patiun dice icon */}
|
||||
<BrandIcon name="dice" size={16} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="font-sans text-base leading-tight underline-offset-4 group-hover:underline group-focus:underline">
|
||||
{g.name}
|
||||
</span>
|
||||
{g.note && (
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{g.note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* RIGHT: tall image frame, shelf at rest, cover on hover. Pinned to the
|
||||
same height as the list so the two columns line up. */}
|
||||
<figure
|
||||
className="relative w-full overflow-hidden rounded-xl border"
|
||||
style={{
|
||||
height: SHELF_HEIGHT,
|
||||
borderColor: 'color-mix(in srgb, var(--color-gold) 70%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-secondary) 12%, transparent)',
|
||||
}}
|
||||
// When the pointer leaves the whole figure, snap back to the shelf.
|
||||
onMouseLeave={() => setActive(null)}
|
||||
>
|
||||
{/* Base layer: shelf photo (always present; covers cross-fade on top). */}
|
||||
<img
|
||||
src={shelfImage}
|
||||
alt={shelfAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{/* Gradient + caption overlay for legibility. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/3"
|
||||
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)' }}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showCover && (
|
||||
<motion.img
|
||||
key={activeGame!.bggId}
|
||||
src={activeGame!.image}
|
||||
alt={`${activeGame!.name} cover`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
initial={crossfade.initial}
|
||||
animate={crossfade.animate}
|
||||
exit={crossfade.exit}
|
||||
transition={crossfade.transition}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<figcaption
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 p-4"
|
||||
style={{ color: 'var(--color-starlight)' }}
|
||||
>
|
||||
<span className="font-sans text-lg leading-tight drop-shadow">
|
||||
{activeGame?.name ?? shelfCaption}
|
||||
</span>
|
||||
{activeGame?.note && (
|
||||
<span className="text-sm opacity-90 drop-shadow">{activeGame.note}</span>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user