diff --git a/apps/web/public/showcase/reels/vegan-iq.jpg b/apps/web/public/showcase/reels/vegan-iq.jpg new file mode 100644 index 0000000..520c8f7 Binary files /dev/null and b/apps/web/public/showcase/reels/vegan-iq.jpg differ diff --git a/apps/web/public/showcase/reels/vegan-iq.mp4 b/apps/web/public/showcase/reels/vegan-iq.mp4 new file mode 100644 index 0000000..50a76ee Binary files /dev/null and b/apps/web/public/showcase/reels/vegan-iq.mp4 differ diff --git a/apps/web/public/showcase/reels/vegan-iq.webm b/apps/web/public/showcase/reels/vegan-iq.webm new file mode 100644 index 0000000..9c45b5e Binary files /dev/null and b/apps/web/public/showcase/reels/vegan-iq.webm differ diff --git a/apps/web/public/showcase/reels/webshops.jpg b/apps/web/public/showcase/reels/webshops.jpg new file mode 100644 index 0000000..5c5490d Binary files /dev/null and b/apps/web/public/showcase/reels/webshops.jpg differ diff --git a/apps/web/public/showcase/reels/webshops.mp4 b/apps/web/public/showcase/reels/webshops.mp4 new file mode 100644 index 0000000..80b48aa Binary files /dev/null and b/apps/web/public/showcase/reels/webshops.mp4 differ diff --git a/apps/web/public/showcase/reels/webshops.webm b/apps/web/public/showcase/reels/webshops.webm new file mode 100644 index 0000000..a7be2a0 Binary files /dev/null and b/apps/web/public/showcase/reels/webshops.webm differ diff --git a/apps/web/public/showcase/vignettes/bloomword.png b/apps/web/public/showcase/vignettes/bloomword.png new file mode 100644 index 0000000..0ee6407 Binary files /dev/null and b/apps/web/public/showcase/vignettes/bloomword.png differ diff --git a/apps/web/public/showcase/vignettes/bloomword.webm b/apps/web/public/showcase/vignettes/bloomword.webm new file mode 100644 index 0000000..9e68315 Binary files /dev/null and b/apps/web/public/showcase/vignettes/bloomword.webm differ diff --git a/apps/web/src/components/portfolio/bento-grid.tsx b/apps/web/src/components/portfolio/bento-grid.tsx index 2a6ccfc..3559b58 100644 --- a/apps/web/src/components/portfolio/bento-grid.tsx +++ b/apps/web/src/components/portfolio/bento-grid.tsx @@ -52,20 +52,26 @@ type Item = { * webm: /showcase/vignettes/.webm (VP9 + alpha) * poster: /showcase/vignettes/.png (transparent RGBA PNG) */ - vignette?: { webm: string; poster: string; webmDark?: string; posterDark?: string }; + vignette?: { webm: string; poster: string; webmDark?: string; posterDark?: string; mp4?: string; fit?: "contain" | "cover" }; }; -// Six tiles, spans tiling the 3-column grid gap-free: (2,1) / (1,2) / (1,2) = three full rows. -// Barns & Neutrons is the feature tile — a live transparent 3D vignette that grows on hover. +// Six tiles, spans tiling the 3-column grid gap-free: (2,1) / (1,2) / (2,1) = three full rows. +// Two live tiles: Webshops (the theming-morph reel, an opaque cover video: five completely +// different storefronts from one platform) and Barns & Neutrons (transparent 3D vignette). +// Videos play on hover/focus on desktop and while on screen on no-hover (touch) devices. const items: Item[] = [ - { name: "Occupy Wallets", tagline: "Custom e-commerce storefront we build and host for an artist.", color: "emerald", span: 2, image: "/images/products/occupy-wallets.png", alt: "Occupy Wallets online store and gallery", href: "https://occupywallets.art", external: true }, + { name: "Webshops", tagline: "Custom storefronts we build and host. One platform, five completely different shops.", color: "emerald", span: 2, image: "/showcase/reels/webshops.jpg", alt: "Five differently themed demo storefronts built on the same platform", href: "/products/webshops", vignette: { webm: "/showcase/reels/webshops.webm", poster: "/showcase/reels/webshops.jpg", mp4: "/showcase/reels/webshops.mp4", fit: "cover" } }, { name: "Smirk Wallet", tagline: "Non-custodial browser wallet with social tipping.", color: "amber", span: 1, image: "/images/products/smirk-wallet.png", alt: "Smirk Wallet browser extension", href: "https://smirk.cash", external: true }, - { name: "Wownerogue", tagline: "Provably-fair roguelike, synced to crypto block times.", color: "orange", span: 1, image: "/images/products/privacy-labs.png", alt: "Screenshot of the Wownerogue roguelike dungeon-crawler", href: "/products/wownerogue" }, - { name: "Bauhaus Echo", tagline: "Visual memory puzzle game.", color: "blue", span: 2, image: "/images/products/bauhaus-echo.png", alt: "Bauhaus Echo visual memory puzzle game", href: "/products/bauhaus-echo" }, - { name: "Vegan IQ", tagline: "Plant-based trivia game.", color: "green", span: 1, image: "/images/products/vegan-iq.png", alt: "Vegan IQ plant-based trivia game", href: "/products/vegan-iq" }, + // Transparent chroma-keyed vignette (the engine can't emit real alpha — BW_FLAT magenta key): + // full-bloom tree at rest, hover retracts and regrows it. Palindrome loop, seam 0.0. + { name: "Bloomword", tagline: "A word game where every answer grows the tree.", color: "green", span: 1, image: "/images/products/bloomword.svg", alt: "Bloomword, a word game about growing a tree", href: "/products/bloomword", vignette: { webm: "/showcase/vignettes/bloomword.webm", poster: "/showcase/vignettes/bloomword.png" } }, // Single theme-independent asset: the INSET black silhouette stroke (sg_outline) sits ON the yellow letters, so one // baked alpha render reads on both light and dark tiles — no per-theme pair needed (a 3D->web pipeline win). { name: "Barns & Neutrons", tagline: "Cozy puzzle expedition across the real Table of Nuclides.", color: "amber", span: 2, image: "/images/products/barns-and-neutrons.svg", alt: "Barns & Neutrons, a game about the Table of Nuclides", href: "/products/barns-and-neutrons", vignette: { webm: "/showcase/vignettes/barns.webm", poster: "/showcase/vignettes/barns.png" } }, + { name: "Bauhaus Echo", tagline: "Visual memory puzzle game.", color: "blue", span: 2, image: "/images/products/bauhaus-echo.png", alt: "Bauhaus Echo visual memory puzzle game", href: "/products/bauhaus-echo" }, + // Cover reel from the vegan-IQ social pipeline: logo panel at rest, card-flips to a real + // quiz question (unanswered -> answered) on hover, flips back for a perfect loop seam. + { name: "Vegan IQ", tagline: "Plant-based trivia game.", color: "green", span: 1, image: "/showcase/reels/vegan-iq.jpg", alt: "Vegan IQ plant-based trivia game", href: "/products/vegan-iq", vignette: { webm: "/showcase/reels/vegan-iq.webm", poster: "/showcase/reels/vegan-iq.jpg", mp4: "/showcase/reels/vegan-iq.mp4", fit: "cover" } }, ]; function BentoCard({ item, delay }: { item: Item; delay: number }) { @@ -85,6 +91,8 @@ function BentoCard({ item, delay }: { item: Item; delay: number }) { poster={item.vignette!.poster} webmDark={item.vignette!.webmDark} posterDark={item.vignette!.posterDark} + mp4={item.vignette!.mp4} + fit={item.vignette!.fit} /> ) : ( (null); const videoRef = useRef(null); const [allowMotion, setAllowMotion] = useState(false); @@ -60,15 +78,11 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }: const video = videoRef.current; if (!wrap || !video) return; - // The focusable/hoverable element is the tile link (an ancestor). focusin/focusout - // bubble from it, and mouseenter/mouseleave cover pointer hover over the whole card. - const trigger: HTMLElement = - (wrap.closest('.bento-item') as HTMLElement | null) ?? (wrap.parentElement as HTMLElement) ?? wrap; + const canDecode = () => + !!video.canPlayType('video/webm; codecs="vp9"') || (!!mp4 && !!video.canPlayType('video/mp4')); const play = () => { - // Browsers that can't decode VP9 WebM (Safari family, no alpha support) return "" - // here — bail so the transparent poster stays put instead of an opaque frame. - if (!video.canPlayType('video/webm; codecs="vp9"')) return; + if (!canDecode()) return; // play() on a preload="none" element kicks off the deferred fetch, then plays once // buffered. Swallow the rejection browsers throw when a quick blur/leave pause() // interrupts an in-flight play(). @@ -76,7 +90,7 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }: }; const stop = () => { video.pause(); - // Snap back to the transparent first frame so the tile art shows cleanly again. + // Snap back to frame 0 (the rest state) so the tile art shows cleanly again. try { video.currentTime = 0; } catch { @@ -84,6 +98,27 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }: } }; + // No-hover devices: play while substantially on screen instead of on hover. + if (window.matchMedia('(hover: none)').matches) { + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) play(); + else stop(); + }, + { threshold: 0.5 }, + ); + io.observe(wrap); + return () => { + io.disconnect(); + video.pause(); + }; + } + + // The focusable/hoverable element is the tile link (an ancestor). focusin/focusout + // bubble from it, and mouseenter/mouseleave cover pointer hover over the whole card. + const trigger: HTMLElement = + (wrap.closest('.bento-item') as HTMLElement | null) ?? (wrap.parentElement as HTMLElement) ?? wrap; + trigger.addEventListener('mouseenter', play); trigger.addEventListener('mouseleave', stop); trigger.addEventListener('focusin', play); @@ -95,7 +130,7 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }: trigger.removeEventListener('focusout', stop); video.pause(); }; - }, [allowMotion, activeWebm]); + }, [allowMotion, activeWebm, mp4]); return (
+ {mp4 && }
);