mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
bento: overhaul — webshops morph reel, bloomword vignette, vegan-iq card flip
New lineup (drops Occupy Wallets + Wownerogue): Webshops (span 2) leads with the theming-morph reel — the 5 re-snapped post-glowup demo shops crossfading, proof that one platform yields completely different storefronts; Bloomword gets a transparent chroma-keyed tree vignette (full bloom at rest, hover retracts + regrows, palindrome seam 0.0); Vegan IQ gets a brand-panel card-flip reel from its own social pipeline (logo -> real quiz card unanswered -> answered, seam 0.0); Barns/Smirk/Bauhaus stay. HoverVignette grows fit=cover (opaque reels fill the tile, poster = tile art), an optional H.264 mp4 source (Safari can't decode VP9), and on-screen autoplay for no-hover devices (IntersectionObserver >= 0.5) so phones see the animations at all. All four live tiles verified with Playwright: correct video plays on its tile's hover, others stay paused.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
@@ -52,20 +52,26 @@ type Item = {
|
||||
* webm: /showcase/vignettes/<slug>.webm (VP9 + alpha)
|
||||
* poster: /showcase/vignettes/<slug>.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}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
|
||||
@@ -4,40 +4,58 @@ import { useTheme } from 'next-themes';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type HoverVignetteProps = {
|
||||
/** Alpha (VP9-with-alpha) WebM that plays on hover/focus, layered over the tile art (the LIGHT-mode asset). */
|
||||
/** WebM that plays on hover/focus (VP9-alpha for transparent overlays; plain VP9 for `fit="cover"` reels). */
|
||||
webm: string;
|
||||
/** Transparent RGBA PNG shown by default and whenever the video can't/shouldn't play (LIGHT-mode). */
|
||||
/** Poster shown by default and whenever the video can't/shouldn't play (transparent PNG for overlays). */
|
||||
poster: string;
|
||||
/** Optional DARK-mode asset pair. Alpha vignettes are baked, so the ink/colour that reads on a light tile
|
||||
* (dark outline) vanishes on a dark tile and vice-versa. When provided, these are used in dark mode. */
|
||||
webmDark?: string;
|
||||
posterDark?: string;
|
||||
/** Optional opaque H.264 fallback (for `fit="cover"` reels): lets Safari-family browsers, which cannot
|
||||
* decode VP9, still play the clip. Pointless for alpha overlays (H.264 carries no alpha). */
|
||||
mp4?: string;
|
||||
/** contain (default): transparent overlay letterboxed over the tile art. cover: an opaque reel that
|
||||
* FILLS the tile (no padding) — the poster is the tile art. */
|
||||
fit?: 'contain' | 'cover';
|
||||
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:
|
||||
* A perf-safe, purely-additive hover "vignette": a video that plays over (or as) a tile's
|
||||
* art when the tile is hovered or keyboard-focused, then resets to the 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.
|
||||
* - Poster-first: the `poster` paints immediately; for `contain` overlays it is a
|
||||
* transparent RGBA PNG so the overlay is INVISIBLE by default and the tile's own art
|
||||
* shows through untouched. For `cover` reels the poster IS the tile art.
|
||||
* - `preload="none"`: the video downloads NOTHING until an actual 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.
|
||||
* - NO-HOVER DEVICES (phones/tablets, `(hover: none)`): hover can never fire, so the
|
||||
* tile plays while it is substantially on screen (IntersectionObserver ≥ 0.5) and
|
||||
* resets when it scrolls away. Same deferred download: nothing loads until the tile
|
||||
* is actually seen.
|
||||
* - `prefers-reduced-motion`: no listeners, no observer — the video never downloads and
|
||||
* never plays. The poster is the whole experience.
|
||||
* - Codec gate: play() is skipped unless the browser reports it can decode one of the
|
||||
* provided sources (VP9 webm, or the optional H.264 mp4 for `cover` reels). Safari
|
||||
* cannot decode VP9-alpha, so overlay tiles degrade to the transparent poster there
|
||||
* — never a broken or opaque box.
|
||||
* - `pointer-events-none` + `aria-hidden`: decorative; never blocks the tile's link or
|
||||
* steals focus, and adds no layout (absolutely positioned, inset-0).
|
||||
*/
|
||||
export function HoverVignette({ webm, poster, webmDark, posterDark, className }: HoverVignetteProps) {
|
||||
export function HoverVignette({
|
||||
webm,
|
||||
poster,
|
||||
webmDark,
|
||||
posterDark,
|
||||
mp4,
|
||||
fit = 'contain',
|
||||
className,
|
||||
}: HoverVignetteProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(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 (
|
||||
<div
|
||||
@@ -106,7 +141,7 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }:
|
||||
<video
|
||||
key={activeWebm}
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-contain p-2"
|
||||
className={fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain p-2'}
|
||||
poster={activePoster}
|
||||
muted
|
||||
loop
|
||||
@@ -115,6 +150,7 @@ export function HoverVignette({ webm, poster, webmDark, posterDark, className }:
|
||||
tabIndex={-1}
|
||||
>
|
||||
<source src={activeWebm} type='video/webm; codecs="vp9"' />
|
||||
{mp4 && <source src={mp4} type="video/mp4" />}
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user