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

Phase 1 of the functional design plan.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
@@ -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>
);
}