diff --git a/apps/web/public/showcase/vignettes/barns.png b/apps/web/public/showcase/vignettes/barns.png new file mode 100644 index 0000000..5fbe5a0 Binary files /dev/null and b/apps/web/public/showcase/vignettes/barns.png differ diff --git a/apps/web/public/showcase/vignettes/barns.webm b/apps/web/public/showcase/vignettes/barns.webm new file mode 100644 index 0000000..359814f Binary files /dev/null and b/apps/web/public/showcase/vignettes/barns.webm differ diff --git a/apps/web/src/components/portfolio/bento-grid.tsx b/apps/web/src/components/portfolio/bento-grid.tsx index ec98acc..c6bfb20 100644 --- a/apps/web/src/components/portfolio/bento-grid.tsx +++ b/apps/web/src/components/portfolio/bento-grid.tsx @@ -1,6 +1,7 @@ import { Card } from "@repo/ui/components/card"; import Image from "next/image"; import Link from "next/link"; +import { HoverVignette } from "@/components/showcase/hover-vignette"; // Literal class strings per color so Tailwind's JIT keeps them (no dynamic interpolation). const cardBg: Record = { @@ -41,20 +42,32 @@ type Item = { alt: string; href: string; external?: boolean; + /** + * Optional hover/focus "vignette" — a transparent alpha-WebM 3D render from the + * game/app that plays OVER this tile's static art on hover or keyboard focus. + * Purely additive: with no assets present (or on Safari / reduced-motion) the tile + * renders exactly as its static `image`. Asset convention: + * webm: /showcase/vignettes/.webm (VP9 + alpha) + * poster: /showcase/vignettes/.png (transparent RGBA PNG) + */ + vignette?: { webm: string; poster: string }; }; -// Order matters: spans 2,1,1,2,2,1 tile a 3-column grid with no gaps while alternating the -// wide side row to row for a balanced bento look. +// 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. 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: "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: "Neroswap", tagline: "DEX & CEX orderbook aggregator.", color: "yellow", span: 1, image: "/images/products/neroswap.png", alt: "Screenshot of the Neroswap orderbook aggregator", href: "https://neroswap.com", external: true }, - { name: "Wownerogue", tagline: "Provably-fair roguelike, synced to crypto block times.", color: "orange", span: 2, image: "/images/products/privacy-labs.png", alt: "Screenshot of the Wownerogue roguelike dungeon-crawler", href: "/products/wownerogue" }, + { 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" }, + { 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" } }, ]; function BentoCard({ item, delay }: { item: Item; delay: number }) { + // Feature tiles (a live vignette) drop the static screenshot — the transparent poster is the + // rest state — and grow + lift above their neighbours on hover so the animation is the focus. + const feature = !!item.vignette; const inner = (
@@ -62,18 +75,26 @@ function BentoCard({ item, delay }: { item: Item; delay: number }) {

{item.tagline}

- {item.alt} + {feature ? ( + + ) : ( + {item.alt} + )}
); - const className = `bento-item ${spanClass[item.span]}`; + const className = `bento-item ${spanClass[item.span]}${ + feature + ? " relative z-0 transition-transform duration-500 ease-out will-change-transform motion-safe:hover:scale-[1.05] motion-safe:hover:z-30 focus-visible:z-30" + : "" + }`; const style = { animationDelay: `${delay}ms` }; return item.external ? ( diff --git a/apps/web/src/components/showcase/hover-vignette.tsx b/apps/web/src/components/showcase/hover-vignette.tsx new file mode 100644 index 0000000..28917ea --- /dev/null +++ b/apps/web/src/components/showcase/hover-vignette.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +type HoverVignetteProps = { + /** Alpha (VP9-with-alpha) WebM that plays on hover/focus, layered over the tile art. */ + webm: string; + /** Transparent RGBA PNG shown by default and whenever the video can't/shouldn't play. */ + poster: string; + className?: string; +}; + +/** + * A perf-safe, purely-additive hover "vignette": a transparent alpha-WebM that plays + * OVER a tile's existing static art when the tile is hovered or keyboard-focused, then + * resets to a transparent poster on leave/blur. The studio's motion budget, applied to + * the bento tiles: + * + * - Poster-first: the `poster` is a transparent RGBA PNG, so the overlay is INVISIBLE + * by default and the tile's own art shows through untouched. + * - `preload="none"`: the WebM downloads NOTHING until an actual hover/focus play() + * fires. Idle page weight is unchanged; a visitor who never hovers never pays a byte. + * - Trigger is hover AND focus-within of the interactive tile (the `.bento-item` + * ancestor link), so keyboard users get the same effect. Pause + reset to the poster + * on mouseleave / blur. + * - `prefers-reduced-motion`: the listeners are never attached, so the video never + * downloads and never plays. The transparent poster is the whole experience. + * - Safari: VP9-alpha is unsupported. `canPlayType` returns "" there, so play() is a + * no-op and the transparent poster remains — graceful degradation to the tile art, + * never a broken or opaque box. + * - `pointer-events-none` + `aria-hidden`: the overlay is decorative and never blocks + * the tile's link/click/tap or steals focus. It also adds no layout (absolutely + * positioned, inset-0), so it can't shift the tile. + */ +export function HoverVignette({ webm, poster, className }: HoverVignetteProps) { + const wrapRef = useRef(null); + const videoRef = useRef(null); + const [allowMotion, setAllowMotion] = useState(false); + + useEffect(() => { + setAllowMotion(!window.matchMedia('(prefers-reduced-motion: reduce)').matches); + }, []); + + useEffect(() => { + if (!allowMotion) return; + const wrap = wrapRef.current; + 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 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; + // 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(). + video.play().catch(() => {}); + }; + const stop = () => { + video.pause(); + // Snap back to the transparent first frame so the tile art shows cleanly again. + try { + video.currentTime = 0; + } catch { + /* not yet loaded — nothing to reset */ + } + }; + + trigger.addEventListener('mouseenter', play); + trigger.addEventListener('mouseleave', stop); + trigger.addEventListener('focusin', play); + trigger.addEventListener('focusout', stop); + return () => { + trigger.removeEventListener('mouseenter', play); + trigger.removeEventListener('mouseleave', stop); + trigger.removeEventListener('focusin', play); + trigger.removeEventListener('focusout', stop); + video.pause(); + }; + }, [allowMotion]); + + return ( + + ); +}