diff --git a/app/[username]/vs/[opponent]/opengraph-image.tsx b/app/[username]/vs/[opponent]/opengraph-image.tsx new file mode 100644 index 0000000..e4e02ec --- /dev/null +++ b/app/[username]/vs/[opponent]/opengraph-image.tsx @@ -0,0 +1,281 @@ +import { ImageResponse } from "next/og"; +import { after } from "next/server"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { scoutCard } from "@/lib/scout"; +import { pickFlag } from "@/lib/flagPriority"; +import { recordScout } from "@/lib/analytics"; +import { loadCardAssets, cardTree } from "@/lib/og/renderCard"; +import { loadCardFonts } from "@/lib/og/card"; +import { resolveResultTheme } from "@/components/finishTheme"; +import { VS_PALETTE } from "@/components/VsBurst"; +import { S_PATH, V_PATH, particlesAlong, sliverBetween } from "@/lib/vsBurst"; +import type { Card } from "@/lib/scoring/types"; + +// The Fixture unfurl — the file-convention OG image for //vs/. Next wires +// it into the page's og:image + twitter:image automatically, so page.tsx only +// sets the title/description/canonical. +// +// Composition mirrors the reference art: near-black stage, a full-canvas +// lightning strike behind a huge VS, and the two cards splayed OUTWARD with +// their inner corners tucked BEHIND the bolt so there's no dead-black band +// between card and centre. Angles were measured off the reference (outer edges +// lean ~6° out, bolt ~21° off vertical). The reference's 3D keystone can't be +// reproduced (Satori is strictly 2D), so the lean is a flat rotation. The card +// art PNGs are transparent outside the FUT silhouette, so the cards sit on the +// stage with no rectangular matte — same renderer as the / card unfurl. + +export const runtime = "nodejs"; +export const alt = + "GitFut Scout Duel — two player cards facing off across a lightning strike"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +const CARD_W = 373; // card ≈ 90% of poster height +const TILT = 6; // degrees each card's top tips AWAY from the centre line +const CARD_X = 150; // inner edges tuck behind the bolt; the right card mirrors it +const CARD_Y = 32; + +// Full-canvas strike, top-right to bottom-left through the centre; the tips +// sit past the edges so no taper is visible on-canvas. +const BOLT_TOP: [number, number] = [740, -40]; +const BOLT_BOT: [number, number] = [460, 670]; +const EMBERS = particlesAlong(BOLT_BOT, BOLT_TOP, 34, 4, 4.2); + +// VS letter block: V_PATH/S_PATH live on the 120×170 VsBurst grid, where the +// letters span x 16..105 / y 56..118. Scale that block up and centre it. +const VS_SCALE = 3.55; // letter block ≈ 220px tall (reference: ~1/3 of canvas) +const VS_TX = 600 - 60.5 * VS_SCALE; +const VS_TY = 315 - 87 * VS_SCALE; + +const CACHE = { + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", +}; + +async function loadBebas() { + const data = await readFile( + join(process.cwd(), "app", "fonts", "BebasNeue-Regular.ttf"), + ); + return { + name: "Bebas Neue", + data, + weight: 400 as const, + style: "normal" as const, + }; +} + +async function tryCard(username: string): Promise { + try { + const card = await scoutCard(username); + return { ...card, country: pickFlag(null, card.country) ?? "" }; + } catch { + return null; + } +} + +// The big explosive headline, shared by both variants. +function title(fontSize: number) { + return ( +
+ SCOUT DUEL +
+ ); +} + +export default async function Image({ + params, +}: { + params: Promise<{ username: string; opponent: string }>; +}) { + const { username, opponent } = await params; + const [a, b] = await Promise.all([tryCard(username), tryCard(opponent)]); + + // A side missing -> a text-only fixture card, still spoiler-free. + if (!a || !b) { + const [fonts, bebas] = await Promise.all([loadCardFonts(), loadBebas()]); + return new ImageResponse( + ( +
+ {title(96)} +
+ @{username} vs @{opponent} +
+
+ watch the duel at +
+
+ gitfut.com +
+
+ ), + { ...size, fonts: [...fonts, bebas], headers: CACHE }, + ); + } + + after(() => Promise.all([recordScout(), recordScout()])); // count unfurls like the card OG does + + const aGlow = resolveResultTheme(a).glow; + const bGlow = resolveResultTheme(b).glow; + // Only aAssets.fonts is passed to ImageResponse; both cards use the same + // static font set, so skip re-reading it for B (withFonts=false). + const [aAssets, bAssets, bebas] = await Promise.all([ + loadCardAssets(a, CARD_W), + loadCardAssets(b, CARD_W, false), + loadBebas(), + ]); + + const { fill, rim, glow, core } = VS_PALETTE; + + return new ImageResponse( + ( +
+ {/* challenger — top tipping away from the centre line */} +
+ {cardTree(a, aAssets, CARD_W)} +
+ + {/* opponent — mirrored lean */} +
+ {cardTree(b, bAssets, CARD_W)} +
+ + {/* the strike + VS, painted AFTER the cards (Satori has no z-index — + document order is paint order), so the bolt crosses in front of the + cards' inner corners exactly like the reference */} + + + + + + {EMBERS.map((p, i) => ( + + ))} + + {/* letters over the bolt — the dark halo swallows the strike where it + passes close, so it reads as behind the letter block */} + + + + + + + + + {/* the headline, on top of everything */} +
+ {title(92)} +
+
+ ), + { ...size, fonts: [...aAssets.fonts, bebas], headers: CACHE }, + ); +} diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx new file mode 100644 index 0000000..4f45375 --- /dev/null +++ b/app/[username]/vs/[opponent]/page.tsx @@ -0,0 +1,134 @@ +import { after } from "next/server"; +import type { Metadata } from "next"; +import Link from "next/link"; +import Background from "@/components/Background"; +import DuelView from "@/components/DuelView"; +import { type GithubError } from "@/lib/github/client"; +import { loadCard } from "@/lib/scout"; +import { getRepoStars } from "@/lib/github/stars"; +import { pickFlag } from "@/lib/flagPriority"; +import { recordScout } from "@/lib/analytics"; +import { computeDuel } from "@/lib/duel"; +import type { Card } from "@/lib/scoring/types"; + +export const dynamic = "force-dynamic"; // per-user, token-gated, always fresh + +// GitHub-derived flag only — duel links carry no ?country= override. +const withFlag = (card: Card): Card => ({ + ...card, + country: pickFlag(null, card.country) ?? "", +}); + +interface Params { + params: Promise<{ username: string; opponent: string }>; +} + +export async function generateMetadata({ params }: Params): Promise { + const { username, opponent } = await params; + const [a, b] = await Promise.all([loadCard(username), loadCard(opponent)]); + if ("card" in a && "card" in b) { + // The fixture unfurl is the file-convention opengraph-image.tsx in this + // folder; Next wires it into og:image + twitter:image automatically, so we + // only set the copy here. summary_large_image makes X render it full-bleed. + return { + title: `${a.card.name} vs ${b.card.name} · GitFut Duel`, + // Score-free on purpose: the fixture poster and this line sell the click, + // the page plays the match. + description: `Six stats, one result: @${a.card.login} vs @${b.card.login}, settled on real GitHub numbers.`, + alternates: { canonical: `/${a.card.login}/vs/${b.card.login}` }, + twitter: { card: "summary_large_image" }, + }; + } + return { + title: `@${username} vs @${opponent} · GitFut`, + robots: { index: false }, + }; +} + +// A duel with a missing side: the fixture fell through. Kept in the same voice +// as the scout report's NotScouted page. +function MatchPostponed({ + username, + opponent, + aError, + bError, +}: { + username: string; + opponent: string; + aError?: GithubError; + bError?: GithubError; +}) { + // Only a missing/invalid login is the player's fault; a network or token + // failure must never read as "your opponent doesn't exist". + const isNoShow = (e?: GithubError) => + e?.type === "notfound" || e?.type === "invalid"; + const rateLimited = + aError?.type === "ratelimit" || bError?.type === "ratelimit"; + const noShows = [ + ...(isNoShow(aError) ? [username] : []), + ...(isNoShow(bError) ? [opponent] : []), + ]; + const message = rateLimited + ? "GitHub showed the scouts a yellow card for time-wasting. Give them a couple minutes to catch their breath, then replay the fixture." + : noShows.length === 2 + ? `Neither @${username} nor @${opponent} made it out of the tunnel — check both usernames.` + : noShows.length === 1 + ? `@${noShows[0]} didn't show for the fixture — there's no GitHub profile by that name.` + : "The scouts lost the feed mid-fixture — not your fault. Give it a minute and replay the duel."; + return ( +
+
+ SCOUT DUEL +
+

+ Match postponed +

+

+ {message} +

+ + BACK TO THE BENCH + +
+ ); +} + +export default async function Page({ params }: Params) { + const { username, opponent } = await params; + const [a, b, stars] = await Promise.all([ + loadCard(username), + loadCard(opponent), + getRepoStars(), + ]); + + if (!("card" in a) || !("card" in b)) { + return ( +
+ + +
+ ); + } + + after(() => Promise.all([recordScout(), recordScout()])); // a duel is two scouts + + const duel = computeDuel(withFlag(a.card), withFlag(b.card)); + return ( +
+ + +
+ ); +} diff --git a/app/api/card-image/[username]/route.tsx b/app/api/card-image/[username]/route.tsx index 1f210b8..57443cc 100644 --- a/app/api/card-image/[username]/route.tsx +++ b/app/api/card-image/[username]/route.tsx @@ -39,7 +39,7 @@ async function fallback(username: string) { flexDirection: "column", alignItems: "center", justifyContent: "center", - background: "#0d1117", + background: "#02001e", backgroundImage: "radial-gradient(60% 40% at 50% 32%, rgba(57,211,83,0.16), transparent 72%)", color: "#e6edf3", fontFamily: "DINPro", diff --git a/app/fonts/BebasNeue-Regular.ttf b/app/fonts/BebasNeue-Regular.ttf new file mode 100644 index 0000000..c328c6e Binary files /dev/null and b/app/fonts/BebasNeue-Regular.ttf differ diff --git a/app/global-error.tsx b/app/global-error.tsx index 1d02de9..7f2c01d 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -6,7 +6,7 @@ import { useEffect } from "react"; // so this replaces the entire document (it must render its own /). // It can't rely on the layout's fonts, globals.css tokens or , so it's // intentionally self-contained with inline styles in the brand palette -// (bg #0d1117, GitHub green #39d353). +// (bg #02001e, GitHub green #39d353). export default function GlobalError({ error, reset, @@ -27,7 +27,7 @@ export default function GlobalError({ display: "flex", alignItems: "center", justifyContent: "center", - background: "#0d1117", + background: "#02001e", color: "#e6edf3", fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", textAlign: "center", diff --git a/app/globals.css b/app/globals.css index 6f5e822..d07707b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,13 +9,13 @@ --font-sans: var(--font-inter); --font-mono: var(--font-jetbrains); - /* WC26 spine — black / surfaces / gray / ink / gold */ - --color-bg: #0d1117; - --color-bg-deep: #010409; - --color-panel: #161b22; - --color-surface: #161b22; - --color-surface-2: #21262d; - --color-line: #30363d; + /* WC26 spine — blue-black midnight / surfaces / gray / ink / gold */ + --color-bg: #02001e; + --color-bg-deep: #010012; + --color-panel: #0b0930; + --color-surface: #0b0930; + --color-surface-2: #130f3d; + --color-line: #262358; --color-gray: #8b949e; --color-ink: #e6edf3; @@ -100,6 +100,84 @@ } } +/* ---- THE DUEL: mirrored walkouts — each corner enters from its own side ---- */ +@keyframes walkout-left { + 0% { + transform: translateX(-72px) translateY(30px) rotate(-4deg) scale(0.9); + opacity: 0; + filter: brightness(0.4) saturate(0.6); + } + 55% { + opacity: 1; + } + 70% { + filter: brightness(1.25) saturate(1.1); + } + 100% { + transform: translateX(0) translateY(0) rotate(0) scale(1); + opacity: 1; + filter: brightness(1) saturate(1); + } +} +@keyframes walkout-right { + 0% { + transform: translateX(72px) translateY(30px) rotate(4deg) scale(0.9); + opacity: 0; + filter: brightness(0.4) saturate(0.6); + } + 55% { + opacity: 1; + } + 70% { + filter: brightness(1.25) saturate(1.1); + } + 100% { + transform: translateX(0) translateY(0) rotate(0) scale(1); + opacity: 1; + filter: brightness(1) saturate(1); + } +} + +/* a stat row resolving in the shootout — snaps in with a flash of its winner */ +@keyframes gf-row-resolve { + 0% { + opacity: 0; + transform: translateY(7px) scale(0.96); + } + 55% { + opacity: 1; + transform: translateY(0) scale(1.04); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* the corner panel (radar, scout notes, chips) sweeps up as the walkouts land */ +@keyframes gf-radar-in { + 0% { + opacity: 0; + transform: scale(0.82) translateY(6px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* scoreboard digit tick — pops as a goal goes in */ +@keyframes gf-score-tick { + 0% { + transform: translateY(-40%) scale(1.25); + opacity: 0; + } + 100% { + transform: translateY(0) scale(1); + opacity: 1; + } +} + /* ---- THE WALKOUT: card rises out of darkness into the spotlight ---- */ @keyframes walkout { 0% { @@ -219,6 +297,32 @@ } } +/* ---- duel fixture plate ---- */ +/* a floodlight sheen that crosses the plate once, then rests — the long tail + keeps the sweep rare enough to catch the eye without nagging it */ +@keyframes duel-sweep { + 0% { + transform: translateX(-110%); + } + 9%, + 100% { + transform: translateX(110%); + } +} + +/* the kickoff dot on the centre spot, waiting for the whistle */ +@keyframes ball-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.7); + opacity: 0.6; + } +} + /* gentle float for hero card/mascot */ @keyframes float { 0%, diff --git a/app/layout.tsx b/app/layout.tsx index adcb9c1..5a67cab 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -64,7 +64,7 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: "#0d1117", + themeColor: "#02001e", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index 74b7407..0754537 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -24,7 +24,7 @@ export default async function Image() { width: "100%", height: "100%", display: "flex", - background: "#0d1117", + background: "#02001e", backgroundImage: "radial-gradient(900px 520px at 22% -12%, rgba(57,211,83,0.20), transparent 60%), radial-gradient(720px 520px at 105% 120%, rgba(212,175,55,0.14), transparent 60%)", color: "#e6edf3", diff --git a/app/u/[username]/opengraph-image.tsx b/app/u/[username]/opengraph-image.tsx index 9b053db..08f3703 100644 --- a/app/u/[username]/opengraph-image.tsx +++ b/app/u/[username]/opengraph-image.tsx @@ -56,7 +56,7 @@ export default async function Image({ params }: { params: Promise<{ username: st flexDirection: "column", alignItems: "center", justifyContent: "center", - background: "#0d1117", + background: "#02001e", backgroundImage: "radial-gradient(900px 500px at 50% -10%, rgba(57,211,83,0.16), transparent 60%)", color: "#e6edf3", fontFamily: "DINPro", @@ -86,7 +86,7 @@ export default async function Image({ params }: { params: Promise<{ username: st height: "100%", display: "flex", alignItems: "center", - background: "#0d1117", + background: "#02001e", backgroundImage: "radial-gradient(760px 520px at 22% 12%, rgba(57,211,83,0.14), transparent 60%), radial-gradient(720px 520px at 100% 110%, rgba(212,175,55,0.10), transparent 60%)", color: "#e6edf3", diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index a516e00..83f177b 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -1,10 +1,9 @@ -import { cache } from "react"; import { after } from "next/server"; import type { Metadata } from "next"; import Link from "next/link"; import Background from "@/components/Background"; import { type GithubError } from "@/lib/github/client"; -import { scoutCard } from "@/lib/scout"; +import { loadCard } from "@/lib/scout"; import { getRepoStars } from "@/lib/github/stars"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; @@ -13,18 +12,6 @@ import ScoutRoute from "./ScoutRoute"; export const dynamic = "force-dynamic"; // per-user, token-gated, always fresh -// Memoised per request so generateMetadata and the page share one scout. The -// cross-request cache (and the tokenless sample fallback) live in lib/scout. -const loadCard = cache( - async (username: string): Promise<{ card: Card } | { error: GithubError }> => { - try { - return { card: await scoutCard(username) }; - } catch (e) { - return { error: e as GithubError }; - } - }, -); - export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { const { username } = await params; const res = await loadCard(username); diff --git a/components/AppShell.tsx b/components/AppShell.tsx index c665ac1..fb37985 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -12,6 +12,9 @@ import GithubStar from "@/components/GithubStar"; import { SAMPLE_CARDS } from "@/lib/github/samples"; const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), { ssr: false }); +// Home-page-only by design: the team-news bulletin mounts here (never in the +// root layout), so scout/duel pages stay clean. +const WhatsNew = dynamic(() => import("@/components/WhatsNew"), { ssr: false }); export default function AppShell({ stars, scoutCount }: { stars: number | null; scoutCount: number | null }) { const router = useRouter(); @@ -66,6 +69,8 @@ export default function AppShell({ stars, scoutCount }: { stars: number | null; {modalOpen && setModalOpen(false)} />} + + ); } diff --git a/components/BrandIcons.tsx b/components/BrandIcons.tsx new file mode 100644 index 0000000..cd06c9b --- /dev/null +++ b/components/BrandIcons.tsx @@ -0,0 +1,18 @@ +// Shared brand glyphs — pure inline SVGs (currentColor), so a button's text +// color drives the mark. Used by CardActions and DuelView's share rows. + +export function XLogo({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function LinkedInLogo({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/components/CardActions.tsx b/components/CardActions.tsx index 69dabce..1088b58 100644 --- a/components/CardActions.tsx +++ b/components/CardActions.tsx @@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from "react"; import { toBlob, toPng } from "html-to-image"; -import { Check, Copy, Download, ImageDown, Link2, Share2 } from "lucide-react"; +import { Check, ChevronDown, Copy, Download, ImageDown, Link2, Share2 } from "lucide-react"; import type { Card } from "@/lib/scoring/types"; import { cardUrl, intentUrl, nativeSharePayload } from "@/lib/share"; import { renderCardImage } from "@/lib/capture"; +import { useShareActions } from "@/hooks/useShareActions"; +import { XLogo, LinkedInLogo } from "./BrandIcons"; import { resolveResultTheme } from "./finishTheme"; // The on-page card is small, so it captures at 3× to hit print resolution. The @@ -14,89 +16,36 @@ import { resolveResultTheme } from "./finishTheme"; const RENDER_OPTS = { pixelRatio: 3, cacheBust: true } as const; const STORY_RENDER_OPTS = { pixelRatio: 1, cacheBust: true } as const; -function XLogo({ size = 16 }: { size?: number }) { - return ( - - - - ); -} +// Feedback and progress copy per action — shown on the split button itself +// (the menu closes as soon as an item is picked, so the button carries the +// "Saving… → Saved" story for everything in it). +type ActionId = "download" | "copy" | "story" | "link"; -function LinkedInLogo({ size = 16 }: { size?: number }) { - return ( - - - - ); -} +const ACTION_COPY: Record = { + download: { name: "Download", busy: "Saving…", done: "Saved" }, + copy: { name: "Copy image", busy: "Copying…", done: "Copied" }, + story: { name: "Story", busy: "Rendering…", done: "Done" }, + link: { name: "Copy link", busy: "…", done: "Link copied" }, +}; -interface ExportAction { - id: string; - label: string; - title: string; - done: string; - icon: typeof Download; - run: (node: HTMLElement, card: Card) => Promise; -} +const ICON_BTN = + "group flex h-[46px] w-[46px] shrink-0 items-center justify-center rounded-xl border border-line bg-white/[0.03] text-ink-soft transition-all duration-200 ease-out hover:-translate-y-[1px] hover:text-white active:translate-y-0 active:scale-[.96]"; -// Image actions only — link/social sharing lives in the visible share row. -const EXPORTS: ExportAction[] = [ - { - id: "download", - label: "Download", - title: "Download as PNG", - done: "Saved", - icon: Download, - run: async (node, card) => { - // renderCardImage awaits fonts and captures an off-screen clone that - // carries the gitfut.com signature (hidden on the live card). - const url = await renderCardImage(node, (n) => toPng(n, RENDER_OPTS)); - const a = document.createElement("a"); - a.download = `${card.login}-gitfut.png`; - a.href = url; - a.click(); - }, - }, - { - id: "copy", - label: "Copy image", - title: "Copy image to clipboard", - done: "Copied", - icon: Copy, - run: async (node) => { - // Pass a Promise so clipboard.write() fires synchronously within the - // click's user activation; awaiting the (slow, 3x) render first lets the - // activation lapse → NotAllowedError. The browser awaits the blob itself. - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": renderCardImage( - node, - async (n) => { - const blob = await toBlob(n, RENDER_OPTS); - if (!blob) throw new Error("render returned no image"); - return blob; - }, - { transparent: true }, - ), - }), - ]); - }, - }, -]; - -const PLATFORM_BTN = - "group flex items-center justify-center gap-[7px] rounded-xl border border-line bg-white/[0.03] py-[11px] text-[12.5px] font-semibold text-ink-soft transition-all duration-200 ease-out hover:-translate-y-[1px] hover:bg-[var(--pb)]/[0.12] hover:text-white active:translate-y-0 active:scale-[.98]"; +const MENU_ITEM = + "flex w-full items-center gap-[9px] rounded-lg px-[11px] py-[9px] text-left text-[12.5px] font-semibold text-ink-soft transition-colors hover:bg-white/[0.06] hover:text-white"; // Brand-colored border + glow on hover, so each target reads as tappable and -// recognizable rather than three identical grey tabs. +// recognizable rather than identical grey squares. const brandHover = (brand: string) => ({ onMouseEnter: (e: React.MouseEvent) => { e.currentTarget.style.borderColor = `${brand}66`; e.currentTarget.style.boxShadow = `0 6px 18px -8px ${brand}80`; + e.currentTarget.style.background = `${brand}1a`; }, onMouseLeave: (e: React.MouseEvent) => { e.currentTarget.style.borderColor = ""; e.currentTarget.style.boxShadow = ""; + e.currentTarget.style.background = ""; }, }); @@ -109,90 +58,172 @@ export default function CardActions({ card: Card; targetRef: React.RefObject; /** Off-screen 1080×1920 story canvas, captured for the Instagram-Story export. */ - storyRef?: React.RefObject; + storyRef: React.RefObject; /** GitHub-derived flag; the share link only carries ?country= when overridden. */ canonicalCountry?: string; }) { - const [done, setDone] = useState(null); - const [busy, setBusy] = useState(null); + const [done, setDone] = useState(null); + const [busy, setBusy] = useState(null); const [error, setError] = useState(null); - const [linkCopied, setLinkCopied] = useState(false); - // Default true so supported browsers (mobile + modern desktop) render the CTA - // with no layout shift; the effect hides it where Web Share is unavailable - // (e.g. desktop Firefox) so it never falls back to a redundant X-share. - const [canNativeShare, setCanNativeShare] = useState(true); - const copiedTimer = useRef | null>(null); - - useEffect(() => { - // Default is "shown"; only hide where Web Share is missing. The set is - // deferred (not synchronous in the effect) so it can't cascade renders. - const supported = typeof navigator !== "undefined" && typeof navigator.share === "function"; - if (supported) return; - const t = setTimeout(() => setCanNativeShare(false), 0); - return () => clearTimeout(t); - }, []); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const caretRef = useRef(null); + // Whether the current open came from the keyboard (click detail === 0), so + // only keyboard users get focus moved into the menu — mouse users keep it + // where they clicked. + const openedByKey = useRef(false); // Download CTA picks up the card's own tier color so the action matches the // card the user is saving (bronze → bronze, silver → silver, TOTY → blue, // founder → their accent). const tier = resolveResultTheme(card).ink; + // Both halves of the split button wear the same tier tint and hover swap — + // inline (not a CSS class) because the tier color is data-driven. + const splitHalf = { + style: { color: tier, borderColor: `${tier}66`, background: `${tier}1f` }, + onMouseEnter: (e: React.MouseEvent) => { + e.currentTarget.style.background = `${tier}33`; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.background = `${tier}1f`; + }, + }; + // The share/copy link carries ?country= ONLY when the flag is a manual override // (differs from the GitHub-derived default). Otherwise we strip it so the URL // stays clean — the recipient still resolves to the same canonical flag. const shareCard = card.country && card.country !== canonicalCountry ? card : { ...card, country: "" }; - const runExport = async (a: ExportAction) => { - const node = targetRef.current; - if (!node || busy) return; - setBusy(a.id); - setError(null); - try { - await a.run(node, card); // each action awaits fonts.ready itself, so the - // clipboard copy can call write() synchronously within the user gesture. - setDone(a.id); - setTimeout(() => setDone((d) => (d === a.id ? null : d)), 1500); - } catch (e) { - console.error("[gitfut] card export failed:", e); - setError(`${a.label} failed: ${e instanceof Error ? e.message : String(e)}`); - } finally { - setBusy(null); - } - }; - - // Native share sheet — the best one-tap path on mobile (and the only route to - // Instagram Stories). Tries to attach the card image; falls back to text+url. - const nativeShare = async () => { - const node = targetRef.current; - const payload = nativeSharePayload(shareCard); - try { + // Share gestures (native sheet + copy link) — shared with DuelView. The + // native payload attaches the freshly rendered card image when the platform + // can share files; otherwise it falls back to the text+url payload, and a + // failed/unsupported share opens the X intent. + const { canNativeShare, nativeShare, copyLink } = useShareActions({ + getSharePayload: async () => { + const node = targetRef.current; + const payload = nativeSharePayload(shareCard); if (node && "canShare" in navigator) { const blob = await renderCardImage(node, (n) => toBlob(n, RENDER_OPTS)); if (blob) { const file = new File([blob], `${card.login}-gitfut.png`, { type: "image/png" }); if (navigator.canShare?.({ files: [file] })) { - await navigator.share({ ...payload, files: [file] }); - return; + return { ...payload, files: [file] }; } } } - await navigator.share(payload); + return payload; + }, + getIntentUrl: () => intentUrl("x", shareCard), + getCopyUrl: () => cardUrl(shareCard), + }); + + // Escape closes the export menu (returning focus to the caret that opened + // it); a pointerdown outside menuRef is the click-away. Not onBlur with + // relatedTarget: WebKit and macOS Firefox fire blur with a null relatedTarget + // before a menu item's click, which would unmount the menu and swallow the + // click — and Safari buttons never take focus on mouse click, so focus loss + // alone can't detect clicking elsewhere either. + useEffect(() => { + if (!menuOpen) return; + if (openedByKey.current) { + menuRef.current?.querySelector("[role='menuitem']")?.focus(); + } + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setMenuOpen(false); + caretRef.current?.focus(); + } + }; + const onPointerDown = (e: PointerEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + document.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKey); + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [menuOpen]); + + // Minimal ARIA-menu keyboard support: ArrowDown/ArrowUp rove focus through + // the items, wrapping at the ends (entry focus + Escape live in the effect). + const onMenuKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const items = Array.from( + menuRef.current?.querySelectorAll("[role='menuitem']") ?? [], + ); + if (!items.length) return; + const i = items.indexOf(document.activeElement as HTMLButtonElement); + items[e.key === "ArrowDown" ? (i + 1) % items.length : i <= 0 ? items.length - 1 : i - 1]?.focus(); + }; + + const finish = (id: ActionId) => { + setDone(id); + setTimeout(() => setDone((d) => (d === id ? null : d)), 1500); + }; + + const track = async (id: ActionId, run: () => Promise) => { + if (busy) return; + setBusy(id); + setError(null); + setMenuOpen(false); + try { + await run(); + finish(id); } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; // user dismissed - window.open(intentUrl("x", shareCard), "_blank", "noopener,noreferrer"); + console.error(`[gitfut] card ${id} failed:`, e); + setError(`${ACTION_COPY[id].name} failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(null); } }; + const downloadPng = () => + track("download", async () => { + const node = targetRef.current; + if (!node) return; + // renderCardImage awaits fonts and captures an off-screen clone that + // carries the gitfut.com signature (hidden on the live card). + const url = await renderCardImage(node, (n) => toPng(n, RENDER_OPTS)); + const a = document.createElement("a"); + a.download = `${card.login}-gitfut.png`; + a.href = url; + a.click(); + }); + + const copyImage = () => + track("copy", async () => { + const node = targetRef.current; + if (!node) return; + // Pass a Promise so clipboard.write() fires synchronously within the + // click's user activation (a menu-item click is one); awaiting the slow 3x + // render first lets the activation lapse → NotAllowedError. The browser + // awaits the blob itself. + await navigator.clipboard.write([ + new ClipboardItem({ + "image/png": renderCardImage( + node, + async (n) => { + const blob = await toBlob(n, RENDER_OPTS); + if (!blob) throw new Error("render returned no image"); + return blob; + }, + { transparent: true }, + ), + }), + ]); + }); + // Instagram-Story export (1080×1920). On mobile, prefer the native share sheet // with the image attached — that's the one-tap route into IG Stories. On - // desktop (no file share), fall back to downloading the PNG to upload manually. - const shareStory = async () => { - const node = storyRef?.current; - if (!node || busy) return; - setBusy("story"); - setError(null); - try { + // desktop (no reliable file share), fall back to downloading the PNG. + const shareStory = () => + track("story", async () => { + const node = storyRef.current; + if (!node) return; const blob = await renderCardImage(node, async (n) => { const b = await toBlob(n, STORY_RENDER_OPTS); if (!b) throw new Error("render returned no image"); @@ -200,11 +231,10 @@ export default function CardActions({ }); const file = new File([blob], `${card.login}-gitfut-story.png`, { type: "image/png" }); - // On mobile, the share sheet is the one-tap route into IG Stories. On - // desktop, navigator.share with a file is often advertised (canShare=true) - // but no-ops or is dismissed — so it must NEVER be the only outcome. - // Only mobile (coarse pointer) attempts share; everyone else downloads, - // and a dismissed/failed share also falls back to download. + // navigator.share with a file is often advertised on desktop (canShare + // = true) but no-ops — so it must never be the only outcome. Only mobile + // (coarse pointer) attempts share; everyone else downloads, and a failed + // share also falls back to download. const isMobile = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches; let shared = false; @@ -220,10 +250,8 @@ export default function CardActions({ if (e instanceof Error && e.name === "AbortError") { shared = true; // user saw the sheet and chose to dismiss — don't also download } - // any other failure: fall through to download } } - if (!shared) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -232,32 +260,30 @@ export default function CardActions({ a.click(); URL.revokeObjectURL(url); } + }); - setDone("story"); - setTimeout(() => setDone((d) => (d === "story" ? null : d)), 1500); - } catch (e) { - console.error("[gitfut] story export failed:", e); - setError(`Story failed: ${e instanceof Error ? e.message : String(e)}`); - } finally { - setBusy(null); - } - }; + const copyCardLink = () => + track("link", async () => { + // copyLink reports failure as `false` (DuelView wants that shape) — turn + // it into a throw here so track shows its error state instead of a false + // "Link copied". + if (!(await copyLink())) throw new Error("clipboard unavailable"); + }); - const copyLink = async () => { - try { - await navigator.clipboard.writeText(cardUrl(shareCard)); - setLinkCopied(true); - if (copiedTimer.current) clearTimeout(copiedTimer.current); - copiedTimer.current = setTimeout(() => setLinkCopied(false), 1600); - } catch { - /* clipboard unavailable — silent */ - } - }; + // The split button's main zone tells the whole story: idle label, per-action + // progress, then the action's done copy. + const mainLabel = busy + ? ACTION_COPY[busy].busy + : done + ? ACTION_COPY[done].done + : "Download"; return (
- {/* primary — native share sheet (shown only where it's supported, so it - never degrades into a duplicate X-share). Focal green CTA. */} + {/* primary — only where the platform has a native share sheet + (canNativeShare stays false through SSR/first paint and is revealed + post-hydration); on desktop the CTA never renders, so it can't + degrade into a duplicate of the X icon below. */} {canNativeShare && ( - -
- {/* image actions — Download is the highest-intent action (save to repost), - so it's the hero of this row; Copy image sits beside it. */} - {(() => { - const dl = EXPORTS.find((a) => a.id === "download")!; - const rest = EXPORTS.filter((a) => a.id !== "download"); - const dlDone = done === dl.id; - const dlBusy = busy === dl.id; - const DlIcon = dl.icon; - return ( -
- - - {rest.map((a) => { - const isDone = done === a.id; - const isBusy = busy === a.id; - const Icon = a.icon; - return ( - - ); - })} -
- ); - })()} +
+ + - {/* Instagram-Story export — a 1080×1920 vertical image, the format Stories - want. One button: shares-with-file on mobile (one tap into IG), or - downloads the PNG on desktop. */} - {storyRef && ( - + + +
)} - {busy === "story" ? "Rendering…" : done === "story" ? "Done" : "Story format"} - - )} + + {error &&

{error}

} diff --git a/components/DuelButton.tsx b/components/DuelButton.tsx new file mode 100644 index 0000000..42bc3a0 --- /dev/null +++ b/components/DuelButton.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +// The duel entry — "the fixture plate". A pure CTA on the neutral chrome the +// icon buttons wear (so it sits on every tier-tinted page without clashing); +// the duel's identity lives in a small GOLD VS mark — the design system's +// reserved prestige metal, because a duel is a prestige fixture. On hover the +// label rolls up like a stadium scoreboard to tease the fixture (@you VS ???); +// clicking slides the opponent input open BELOW the plate — the button never +// morphs into a form. Purely typographic: no icon-pack glyphs. + +export default function DuelButton({ login }: { login: string }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [opponent, setOpponent] = useState(""); + const [isPending, startTransition] = useTransition(); + const inputRef = useRef(null); + const plateRef = useRef(null); + const wrapRef = useRef(null); + + // select() (which also focuses) so a stale draft from a previous open is + // replaced by typing instead of resurrecting mid-word. + useEffect(() => { + if (open) inputRef.current?.select(); + }, [open]); + + // Click-away closes via pointerdown-outside — NOT a blur heuristic: Safari + // buttons never take focus on tap, so an input-blur check can fire with + // activeElement=body between the blur and the KICK OFF click, unmount the + // form and swallow the submit (same WebKit trap CardActions documents). + // pointerdown fires before blur/click everywhere, so it can't race. + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const away = opponent.trim().replace(/^@/, ""); + if (!away || isPending) return; + startTransition(() => + router.push( + `/${encodeURIComponent(login)}/vs/${encodeURIComponent(away)}`, + ), + ); + }; + + // Keyboard tab-out collapses the row. Deferred so the new focus target is + // committed; only a REAL element outside closes it — activeElement=body is + // exactly the ambiguous Safari mid-tap state pointerdown already covers. + const onBlurAway = () => { + if (isPending) return; + setTimeout(() => { + const ae = document.activeElement; + if (ae && ae !== document.body && !wrapRef.current?.contains(ae)) { + setOpen(false); + } + }, 0); + }; + + // One roll window: both lines stack inside a clipped 50px viewport and the + // stack slides up one line on hover/focus/open — the scoreboard flip. + // 44px = the plate's 46px border-box minus its 1px borders, so one line is + // exactly one -100% roll step + const line = + "flex h-[44px] items-center justify-center gap-[10px] px-[46px] transition-transform duration-[350ms] ease-[cubic-bezier(.16,1,.3,1)] motion-reduce:transition-none"; + const rolled = (o: boolean) => + o + ? "-translate-y-full" + : "group-hover:-translate-y-full group-focus-visible:-translate-y-full"; + + return ( +
+ + + {/* the team sheet: slides open below, the plate above stays a button */} + {open && ( +
+ {/* 16px ONLY on small-screen touch devices (stops iOS Safari's focus + auto-zoom); touch laptops and desktops keep the quiet 13.5 */} + setOpponent(e.target.value)} + onKeyDown={(e) => { + // restore focus to the plate so Escape doesn't dump keyboard + // users at the top of the tab order; never mid-submit + if (e.key === "Escape" && !isPending) { + setOpen(false); + plateRef.current?.focus(); + } + }} + placeholder="their username" + autoComplete="off" + spellCheck={false} + readOnly={isPending} + aria-label="Opponent's GitHub username" + className="font-mono h-[34px] w-full min-w-0 flex-1 rounded-[8px] border border-line bg-bg/80 px-[10px] text-[13.5px] text-white outline-none transition placeholder:text-ink-faint focus:border-gold/60 focus:shadow-[0_0_0_3px_rgba(212,175,55,.14)] max-lg:pointer-coarse:text-[16px]" + /> + +
+ )} +
+ ); +} diff --git a/components/DuelView.tsx b/components/DuelView.tsx new file mode 100644 index 0000000..3c19642 --- /dev/null +++ b/components/DuelView.tsx @@ -0,0 +1,677 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Check, Link2, Repeat, Share2 } from "lucide-react"; +import { dominanceShare, tallyRows, type Duel, type DuelSide } from "@/lib/duel"; +import type { Card } from "@/lib/scoring/types"; +import PlayerCard from "./PlayerCard"; +import StatRadar from "./StatRadar"; +import TiltCard from "./TiltCard"; +import VsBurst from "./VsBurst"; +import Mascot from "./Mascot"; +import FooterCredit from "./FooterCredit"; +import GithubStar from "./GithubStar"; +import BuyMeACoffee from "./BuyMeACoffee"; +import { XLogo } from "./BrandIcons"; +import { duelThemes, resolveCardTheme, rgba } from "./finishTheme"; +import { useDuelReveal } from "@/hooks/useReveal"; +import { useShareActions } from "@/hooks/useShareActions"; +import { resolvedRows } from "@/lib/reveal"; +import { formatCount } from "@/lib/format"; +import { duelIntentUrl, duelSharePayload, duelUrl } from "@/lib/share"; + +const CARD_WIDTH = "clamp(150px, min(24vw, 34vh), 292px)"; + +// One shootout stat as a butterfly bar: the two bars grow out from the centre +// label toward each value (the FIFA head-to-head graphic, not a table row). +// Until the sequence reaches it the values are masked and the bars empty — +// layout stays stable, only the reveal moves. +function StatBar({ + row, + resolved, + aAccent, + bAccent, +}: { + row: Duel["rows"][number]; + resolved: boolean; + aAccent: string; + bAccent: string; +}) { + // The winner's dot is a non-color cue too: same-tier duels (gold vs gold) + // and color-blind viewers still read who took the row by dot presence. + const value = (side: DuelSide, accent: string) => { + const won = row.winner === side; + const lost = row.winner !== null && !won; + return ( + + + {row[side]} + + {won && ( + + )} + + ); + }; + const bar = (side: DuelSide, accent: string) => { + const won = row.winner === side; + const lost = row.winner !== null && !won; + return ( +
+
+
+ ); + }; + return ( +
+ + {resolved ? value("challenger", aAccent) : } + + {bar("challenger", aAccent)} + + {row.label} + + {bar("opponent", bAccent)} + + {resolved ? value("opponent", bAccent) : } + +
+ ); +} + +const Masked = () => ( + + ·· + +); + +// A scoreboard digit that pops on change (a goal going in). +function ScoreDigit({ value, accent }: { value: number; accent: string }) { + return ( + + {value} + + ); +} + +export default function DuelView({ + duel, + stars, +}: { + duel: Duel; + stars: number | null; +}) { + const { challenger, opponent, rows, winner, onPenalties, training } = duel; + // Kit clash (see finishTheme): ONLY a toty/totw vs silver pairing recolors — + // the toty side wears its saturated tier blue so the sides stay readable. + const { home: aTheme, away: bTheme } = duelThemes(challenger, opponent); + const { phase, skip } = useDuelReveal(); + const settled = phase.kind === "settled"; + const stamped = phase.kind === "result" || settled; + const shown = resolvedRows(phase); + const shared = new Set(duel.sharedPlaystyles); + + // Scoreline as currently visible: only rows the shootout has resolved count, + // so the scoreboard and the stadium light always agree with what's on screen. + const visible = rows.slice(0, shown); + const { a: scoreA, b: scoreB } = tallyRows(visible); + + // Dominance: margin-weighted, resolved rows only (see lib/duel) — it ticks + // live with the shootout and never runs ahead of the page. + const pctA = dominanceShare(visible); + + const focus: DuelSide | null = stamped ? winner : null; + + const winnerCard: Card | null = + winner === "challenger" + ? challenger + : winner === "opponent" + ? opponent + : null; + const winnerTheme = winner === "challenger" ? aTheme : bTheme; + // Result accent: the winner's tier ink, or a neutral for a draw. headlineHex + // is always a real hex (for the glow/shimmer); resultAccent may be a CSS var. + const resultAccent = winnerCard ? winnerTheme.ink : "var(--color-ink-faint)"; + const headlineHex = winnerCard ? winnerTheme.ink : "#8b949e"; + + // Share-row gestures — shared with CardActions via the same hook (score-free + // duel payload/intent from lib/share). + const { canNativeShare, nativeShare, copyLink, linkCopied } = useShareActions({ + getSharePayload: () => + duelSharePayload(challenger.login, opponent.login), + getIntentUrl: () => duelIntentUrl(challenger.login, opponent.login), + getCopyUrl: () => duelUrl(challenger.login, opponent.login), + }); + + const status = + !stamped && shown === 0 ? "KICK-OFF" : !stamped ? "LIVE" : "FULL TIME"; + + // Both header names share one size — the longer login sets it (a fixture + // reads as one pair, not two weights) — shrinking smoothly for long handles + // so neither side can shove the VS burst off the page's centre axis. + const nameLen = Math.max(challenger.login.length, opponent.login.length); + const nameSize = `clamp(16px, ${Math.min(4.2, 42 / nameLen)}vw, ${Math.min(44, 460 / nameLen)}px)`; + + const corner = (card: Card, theme: { ink: string }, side: DuelSide) => { + const won = focus === side; + const lost = focus !== null && !won; + return ( +
+
+ {/* maskSrc clips the hover glass to the card's own silhouette */} + + + +
+
+
+ +
+ {/* scout notes — the handle and the identity line the card face + doesn't carry (the @login lives here, not under the card) */} +
+ + @{card.login} + + · + + {card.archetype} + · {card.report.style} +
+ {card.report.playstyles.length > 0 && ( +
+ {card.report.playstyles.map((p) => { + const both = shared.has(p.name); + return ( + + {p.name} + {p.plus ? "+" : ""} + + ); + })} +
+ )} +
+
+ ); + }; + + return ( + <> +
{ + if ( + (e.target as HTMLElement).closest('a,button,[role="button"]') + ) + return; + skip(); + } + } + className="relative z-[2] mx-auto flex min-h-[100dvh] w-full max-w-[1280px] flex-col px-[clamp(16px,4vw,22px)]" + > + {/* The stadium leans: each corner's tier glow floods its half and + brightens as that side scores — then flares for the winner at full + time. Draws keep the house lights even. */} +
+
+
+
+ + {/* top bar — mirrors the scout report's frame */} +
+
+ + + GET SCOUTED + + +
+ +
+ + {/* fixture header — the names squaring up around the VS burst. The + [1fr_auto_1fr] grid pins the burst to the page's exact centre (in + line with the scoreboard below) no matter how long either login is; + the names mirror outward from it. */} +
+
+ SCOUT DUEL +
+

+ + {challenger.login} + + + + {opponent.login} + +

+
+ + {/* the stage */} +
+ {corner(challenger, aTheme, "challenger")} + + {/* center strip — the whole broadcast, top to bottom. The status + + scoreline are a polite live region so the match isn't silent to + assistive tech while it plays out. */} +
+
+ {status === "LIVE" && ( + + + + + )} + {status} + {stamped && onPenalties && ( + · PENS + )} +
+ +
+ + + +
+ + {/* dominance — the possession bar of the duel: how the raw stat + totals split between the corners, ticking with the shootout */} +
+
+ + {pctA}% + + DOMINANCE + + {100 - pctA}% + +
+
+
+
+
+
+ + {/* the shootout */} +
+ {rows.map((row, i) => ( + + ))} +
+ + {/* full-time banner — lands right under the shootout so the centre + strip never goes dark. The box is reserved from kick-off + (nothing jumps at the whistle) and holds the skip hint. */} +
+ {stamped ? ( +
+ {/* eyebrow — thin tier rules flanking the whistle call */} +
+ + + {onPenalties ? "AFTER PENALTIES" : "FULL TIME"} + + +
+ + {/* result headline: the winner's name in their tier color with + a one-shot shimmer over a soft glow, or the draw call */} +
+
+ {winnerCard ? ( +
+ {winnerCard.name.toUpperCase()} +
+ ) : ( +
+ {training ? "A DRAW. OBVIOUSLY." : "ALL SQUARE"} +
+ )} +
+ + {/* tier underline draws out from the centre */} + + + {/* the how */} +
+ {winnerCard && onPenalties + ? `dead level — ${winnerCard.overall} OVR settles it from the spot` + : winnerCard + ? `${duel.score[winner!]}–${duel.score[winner === "challenger" ? "opponent" : "challenger"]} across the six stats` + : training + ? "you can't nutmeg yourself" + : "six stats, nothing between them — rematch demanded"} +
+
+ ) : ( + + )} +
+ + {/* receipts + sharing — still centre strip, revealed once the duel + settles. visibility (not just opacity) keeps the hidden section + out of the tab order and the a11y tree during the broadcast. */} +
+
+
+ +

+ THE RECEIPTS +

+ +
+ {/* context, never score: real numbers, no winner highlighting */} + {duel.receipts.map((r) => ( +
+ + {formatCount(r.challenger)} + + + {r.label} + + + {formatCount(r.opponent)} + +
+ ))} +
+ + {/* share row — link-first by design; the poster sells the click */} +
+ {canNativeShare && ( + + )} +
+ + + + + Swap corners + +
+
+
+
+ + {corner(opponent, bTheme, "opponent")} +
+ +
+ +
+
+ + + + ); +} diff --git a/components/HowItWorksModal.tsx b/components/HowItWorksModal.tsx index da55060..701af2e 100644 --- a/components/HowItWorksModal.tsx +++ b/components/HowItWorksModal.tsx @@ -66,7 +66,7 @@ export default function HowItWorksModal({ onClose }: { onClose: () => void }) { return (
void }) { aria-modal="true" aria-labelledby="hiw-title" onClick={(e) => e.stopPropagation()} - className="relative max-h-[88vh] w-[min(600px,100%)] overflow-auto rounded-[22px] border border-white/10 bg-[linear-gradient(180deg,#12161d,#0b0e13)] p-[clamp(24px,4.5vw,40px)] shadow-[0_40px_120px_rgba(0,0,0,.6)] outline-none" + className="relative max-h-[88vh] w-[min(600px,100%)] overflow-auto rounded-[20px] border border-line bg-[linear-gradient(180deg,var(--color-surface-2),var(--color-panel))] p-[clamp(24px,4.5vw,40px)] shadow-[0_40px_120px_rgba(0,0,0,.6)] outline-none" style={{ opacity: shown ? 1 : 0, transform: shown ? "translateY(0) scale(1)" : "translateY(14px) scale(.985)", diff --git a/components/ResultView.tsx b/components/ResultView.tsx index f96e44a..b71272f 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -6,6 +6,7 @@ import type { Card } from "@/lib/scoring/types"; import PlayerCard from "./PlayerCard"; import StoryFrame from "./StoryFrame"; import CardActions from "./CardActions"; +import DuelButton from "./DuelButton"; import FlagPicker from "./FlagPicker"; import Mascot from "./Mascot"; import FooterCredit from "./FooterCredit"; @@ -13,7 +14,7 @@ import BuyMeACoffee from "./BuyMeACoffee"; import GithubStar from "./GithubStar"; import dynamic from "next/dynamic"; import { AttributesPanel, MetricsPanel, ReportHeader } from "./ScoutReport"; -import { resolveResultTheme } from "./finishTheme"; +import { confettiPalette, resolveResultTheme } from "./finishTheme"; import { useReveal } from "@/hooks/useReveal"; import { burstConfetti } from "@/lib/confetti"; @@ -34,13 +35,6 @@ interface Props { // (and a hard min/max) so it never overflows a narrow phone or a short laptop. const CARD_WIDTH = "clamp(220px, min(80vw, 40vh), 332px)"; -// Confetti palette per tier — gold for prestige, green always woven in (brand). -const CONFETTI: Record = { - toty: ["#e9cc74", "#d4af37", "#7fa8ff", "#ffffff", "#39d353"], - icon: ["#e9cc74", "#d4af37", "#f5f0e1", "#ffffff", "#39d353"], - totw: ["#39d353", "#e9cc74", "#ffffff", "#7fa8ff"], -}; - export default function ResultView({ card, onBack, @@ -68,16 +62,11 @@ export default function ResultView({ return () => clearTimeout(t); }, []); - // Fire confetti when the rare-tier reveal hits its burst. Founders burst in - // their own accent (woven with brand green); other tiers use the palette map. + // Fire confetti when the rare-tier reveal hits its burst, in the card's own + // tier palette (founders burst in their accent) — see finishTheme. useEffect(() => { - if (phase === "burst") { - const palette = card.founder - ? [card.founder.accent, "#ffffff", "#39d353"] - : (CONFETTI[card.finish] ?? ["#39d353", "#e9cc74", "#ffffff"]); - burstConfetti(palette); - } - }, [phase, card.finish, card.founder]); + if (phase === "burst") burstConfetti(confettiPalette(card)); + }, [phase, card]); const ignited = phase === "ignite" || phase === "burst" || phase === "freeze"; @@ -86,14 +75,18 @@ export default function ResultView({
{/* Tier-reactive backdrop: dims the global green wash and lets the card's own tier color own the result screen (green is the action, the card is - the prize — they shouldn't fight here). Fades in with the reveal. */} + the prize — they shouldn't fight here). Fades in with the reveal. The + bottom fade-out keeps it from burying the contribution-grid motif on + the floor: full tier wash up top, the grid stays lit below. */}
@@ -178,13 +171,14 @@ export default function ResultView({
-
+
+
diff --git a/components/ScoutReport.tsx b/components/ScoutReport.tsx index df2ef9e..01aed42 100644 --- a/components/ScoutReport.tsx +++ b/components/ScoutReport.tsx @@ -231,7 +231,7 @@ export function ReportHeader({ card }: { card: Card }) { className="relative flex h-[clamp(78px,13vw,98px)] w-[clamp(78px,13vw,98px)] flex-col items-center justify-center rounded-2xl border" style={{ borderColor: `${accent}40`, - background: `linear-gradient(160deg, ${accent}1a, transparent 70%), #161b22`, + background: `linear-gradient(160deg, ${accent}1a, transparent 70%), #0b0930`, boxShadow: `0 0 30px ${accent}1f, inset 0 1px 0 ${accent}26`, }} > diff --git a/components/StatRadar.tsx b/components/StatRadar.tsx new file mode 100644 index 0000000..9a328a3 --- /dev/null +++ b/components/StatRadar.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { radarGeometry, radarSector } from "@/lib/radar"; +import { STAT_LABELS, STATS } from "@/lib/scoring/constants"; +import type { Stats } from "@/lib/scoring/types"; +import { rgba } from "./finishTheme"; + +const SIZE = 150; // viewBox unit — the svg scales to its container width + +interface Rival { + stats: Stats; + accent: string; +} + +export default function StatRadar({ + stats, + accent, + rival, +}: { + stats: Stats; + accent: string; + rival?: Rival; +}) { + const geo = radarGeometry(stats, SIZE); + const outer = geo.rings.length - 1; + const [active, setActive] = useState(null); + const dimmed = (i: number) => active !== null && active !== i; + + const activeLabel = active !== null ? geo.labels[active] : null; + const activeKey = active !== null ? STATS[active] : null; + const value = activeKey ? stats[activeKey] : 0; + const rivalValue = activeKey && rival ? rival.stats[activeKey] : null; + const read = + rivalValue === null + ? "" + : value > rivalValue + ? "takes it" + : value < rivalValue + ? "drops it" + : "dead level"; + + return ( +
+ + {geo.rings.map((ring, i) => ( + + ))} + + {/* the focused wedge — the active stat's whole sector takes the light */} + {active !== null && ( + + )} + + {/* the stat shape — recedes a touch while a sector is focused */} + + + {/* active spoke — ties the popped stat back to its vertex */} + {active !== null && ( + + )} + + {geo.vertices.map((v, i) => ( + + ))} + + {geo.labels.map((l, i) => ( + + {l.label} + + ))} + + {/* hit zones — the full wedge, oversized past the labels, FIFA-style. + role=button keeps the Duel's tap-to-skip from hijacking the tap. */} + {STATS.map((key, i) => ( + setActive(i)} + onMouseLeave={() => setActive((a) => (a === i ? null : a))} + onClick={() => setActive((a) => (a === i ? null : i))} + onFocus={() => setActive(i)} + onBlur={() => setActive((a) => (a === i ? null : a))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActive((a) => (a === i ? null : i)); + } + }} + /> + ))} + + + {/* the pop-up — the stat's head-to-head, anchored to its corner */} + {activeLabel && activeKey && ( +
+
+ {STAT_LABELS[activeKey]} +
+
+ {value} + {rivalValue !== null && ( + <> + vs + + {rivalValue} + + + )} +
+ {rivalValue !== null && ( +
{read}
+ )} +
+ )} +
+ ); +} diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx new file mode 100644 index 0000000..87d4fd2 --- /dev/null +++ b/components/TiltCard.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useRef } from "react"; +import { round1 } from "@/lib/format"; + +// FUT-style hover physics for a card: it leans toward the cursor while a glassy +// specular streak sweeps with the pointer. The transform is driven imperatively +// on the element (no re-render per mousemove); the global reduced-motion rule +// zeroes the transitions, so for those viewers it's a functional no-frills hover. +// +// `maskSrc` clips the glass to the card's own silhouette — the same +// art-as-CSS-mask trick PlayerCard uses for the avatar — so the shine can never +// paint a rectangle outside the shield. +export default function TiltCard({ children, maskSrc }: { children: React.ReactNode; maskSrc?: string }) { + const ref = useRef(null); + + const move = (e: React.MouseEvent) => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width; + const py = (e.clientY - r.top) / r.height; + el.style.transform = `perspective(900px) rotateX(${round1((0.5 - py) * 10)}deg) rotateY(${round1((px - 0.5) * 12)}deg)`; + el.style.setProperty("--mx", `${round1(px * 100)}%`); + el.style.setProperty("--my", `${round1(py * 100)}%`); + }; + + const leave = () => { + const el = ref.current; + if (el) el.style.transform = ""; + }; + + return ( +
+ {children} +
+
+ ); +} diff --git a/components/VsBurst.tsx b/components/VsBurst.tsx new file mode 100644 index 0000000..58c8a52 --- /dev/null +++ b/components/VsBurst.tsx @@ -0,0 +1,95 @@ +import { + PARTICLES, + S_PATH, + V_PATH, + VS_H, + VS_W, + sliver, + vsBurstBox, +} from "@/lib/vsBurst"; + +// Brand greens (globals.css): letters brand-mid, rim brand-hi, glow brand. +// Exported so the fixture poster paints its full-canvas strike and letters in +// exactly this kit. +export const VS_PALETTE = { + fill: "#26a641", + rim: "#56e06b", + glow: "#39d353", + core: "#eaffe8", +} as const; +const { fill: FILL, rim: RIM, glow: GLOW, core: CORE } = VS_PALETTE; + +export default function VsBurst({ size = 10 }: { size: number }) { + const { w, h } = vsBurstBox(size); + return ( + + + {/* lightning — glow halo, body, then the white-hot core; the letters + paint after it, so the bolt passes BEHIND the VS like the art */} + + + + + {PARTICLES.map((p, i) => ( + + ))} + + {/* letters over the bolt — the art's trick is a DARK halo around them + (not a green glow): it swallows the bolt where it passes close, so + the strike reads as behind the letter block */} + + + + + + + ); +} diff --git a/components/WhatsNew.tsx b/components/WhatsNew.tsx new file mode 100644 index 0000000..77dbd85 --- /dev/null +++ b/components/WhatsNew.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { WHATS_NEW } from "@/config/whatsNew"; + +const SEEN_KEY = "gitfut:team-news:seen"; + +// Static config → filter once at module scope. If nothing is on show the +// component renders nothing at all. +const ITEMS = WHATS_NEW.filter((i) => i.show); + +function readSeen(): string[] { + try { + const parsed: unknown = JSON.parse( + sessionStorage.getItem(SEEN_KEY) ?? "[]", + ); + return Array.isArray(parsed) + ? parsed.filter((x): x is string => typeof x === "string") + : []; + } catch { + return []; + } +} + +export default function WhatsNew() { + // ITEMS is a module constant, so this early return is stable across renders. + if (ITEMS.length === 0) return null; + return ; +} + +function TeamNews() { + const [open, setOpen] = useState(false); + const [shown, setShown] = useState(false); // entrance transition flag + const panelRef = useRef(null); + const returnFocusRef = useRef(null); + + // Decide visibility after mount — sessionStorage isn't readable during SSR, + // and starting hidden means a dismissed session never sees a flash. The + // short hold also keeps the bulletin from fighting the hero's entrance. + useEffect(() => { + const seen = readSeen(); + if (!ITEMS.some((i) => !seen.includes(i.id))) return; + const t = setTimeout(() => setOpen(true), 900); + return () => clearTimeout(t); + }, []); + + const dismiss = useCallback(() => { + try { + const seen = new Set([...readSeen(), ...ITEMS.map((i) => i.id)]); + sessionStorage.setItem(SEEN_KEY, JSON.stringify([...seen])); + } catch {} + setOpen(false); + setShown(false); + returnFocusRef.current?.focus(); + }, []); + + // While open: focus moves into the dialog (and back on close), Escape + // dismisses, Tab is trapped inside, and the page behind can't scroll. + useEffect(() => { + if (!open) return; + returnFocusRef.current = document.activeElement as HTMLElement | null; + panelRef.current?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + dismiss(); + return; + } + if (e.key !== "Tab") return; + const panel = panelRef.current; + if (!panel) return; + const focusables = panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === panel)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKey); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const t = setTimeout(() => setShown(true), 10); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + clearTimeout(t); + }; + }, [open, dismiss]); + + if (!open) return null; + + const count = ITEMS.length; + + return ( +
+
e.stopPropagation()} + className="relative max-h-[86vh] w-[min(430px,100%)] overflow-auto rounded-[20px] border border-line bg-[linear-gradient(180deg,var(--color-surface-2),var(--color-panel))] p-[clamp(22px,4.5vw,30px)] shadow-[0_40px_120px_rgba(0,0,0,.65)] outline-none max-[560px]:w-full max-[560px]:rounded-b-none max-[560px]:pb-[max(22px,env(safe-area-inset-bottom))]" + style={{ + opacity: shown ? 1 : 0, + transform: shown + ? "translateY(0) scale(1)" + : "translateY(16px) scale(.985)", + transition: + "opacity .35s ease, transform .4s cubic-bezier(.16,1,.3,1)", + }} + > + {/* brand hairline bleeding in along the top edge — house style */} +
+ +
+
+

+ TEAM NEWS. +

+

+ {count === 1 ? "One change" : `${count} changes`} in this window. +

+
+ +
+ + {/* the changes — each one comes ON like a substitution */} +
    + {ITEMS.map((item) => ( +
  • +
    +
    +

    + {item.title} +

    + {item.icon && ( +
    + {typeof item.icon === "function" ? ( + + ) : ( + item.icon + )} +
    + )} +
    +

    + {item.body} +

    +
    +
  • + ))} +
+ + +
+
+ ); +} diff --git a/components/finishTheme.ts b/components/finishTheme.ts index 66f9a0a..5346606 100644 --- a/components/finishTheme.ts +++ b/components/finishTheme.ts @@ -115,3 +115,35 @@ export function resolveResultTheme(card: Card): ResultTheme { if (!card.founder) return base; return { ink: card.founder.accent, glow: rgba(card.founder.accent, 0.34), chip: base.chip }; } + +// ---- Duel kit clash: TOTY/TOTW vs silver, and nothing else ---- +// Those tiers' inks are near-twins (toty #CADBFF vs silver #D6DCE6), so in that +// one matchup nothing side-coded on the Duel (names, bars, radars, scoreboard) +// says whose color is whose. The fix is surgical, only for this pairing: the +// TOTY/TOTW side swaps its pale ink for the tier's own saturated blue — the +// color its glow already wears — so it reads MORE toty, and silver stays +// silver. Every other matchup keeps its true tier inks. +const TOTY_KIT: ResultTheme = { ink: "#7fa8ff", glow: RESULT_THEME.toty.glow, chip: RESULT_THEME.toty.chip }; +const wearsTotyBlue = (f: Finish) => f === "toty" || f === "totw"; + +export function duelThemes(challenger: Card, opponent: Card): { home: ResultTheme; away: ResultTheme } { + const home = resolveResultTheme(challenger); + const away = resolveResultTheme(opponent); + if (wearsTotyBlue(challenger.finish) && opponent.finish === "silver") return { home: TOTY_KIT, away }; + if (challenger.finish === "silver" && wearsTotyBlue(opponent.finish)) return { home, away: TOTY_KIT }; + return { home, away }; +} + +// Confetti palette per tier — gold for prestige, green always woven in (brand). +// Founders burst in their own accent. Consumed by the card reveal (the Duel +// deliberately keeps its full time clean — no confetti). +const CONFETTI: Partial> = { + toty: ["#e9cc74", "#d4af37", "#7fa8ff", "#ffffff", "#39d353"], + icon: ["#e9cc74", "#d4af37", "#f5f0e1", "#ffffff", "#39d353"], + totw: ["#39d353", "#e9cc74", "#ffffff", "#7fa8ff"], +}; + +export function confettiPalette(card: Card): string[] { + if (card.founder) return [card.founder.accent, "#ffffff", "#39d353"]; + return CONFETTI[card.finish] ?? ["#39d353", "#e9cc74", "#ffffff"]; +} diff --git a/config/whatsNew.ts b/config/whatsNew.ts new file mode 100644 index 0000000..e6a1f03 --- /dev/null +++ b/config/whatsNew.ts @@ -0,0 +1,24 @@ +import VsBurst from "../components/VsBurst"; + +export type WhatsNewItem = { + /** Unique, stable slug — forms the "already seen" key in sessionStorage. */ + id: string; + /** Headline, rendered in the Bebas display face — write it in CAPS. */ + title: string; + /** One–two sentences of plain info: what it does, where to find it. */ + body: string; + /** Flip to false to retire the item without deleting it. */ + show: boolean; + /** Optional icon name to show in the header. */ + icon?: React.ComponentType<{ size: number }>; +}; + +export const WHATS_NEW: WhatsNewItem[] = [ + { + id: "duels", + title: "DUEL A RIVAL", + body: "Take your card head-to-head against any dev. six stats, one winner", + show: true, + icon: VsBurst, + }, +]; diff --git a/hooks/useReveal.ts b/hooks/useReveal.ts index 05ad71f..47a6c1b 100644 --- a/hooks/useReveal.ts +++ b/hooks/useReveal.ts @@ -1,28 +1,61 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import type { Finish } from "@/lib/scoring/types"; -import { type RevealPhase, sequenceFor } from "@/lib/reveal"; +import { type DuelPhase, type RevealPhase, duelSequenceFor, sequenceFor } from "@/lib/reveal"; function prefersReducedMotion(): boolean { if (typeof window === "undefined" || !window.matchMedia) return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } +// Layout effect on the client so the first phase lands BEFORE the browser +// paints; plain effect on the server, where useLayoutEffect only warns and does +// nothing. Both reveal hooks drive their sequence through this. +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + // Drives the reveal phases from the pure sequencer. The animation renders off // the returned phase. The component is keyed by login at the call site, so a // new card remounts the hook — no mid-life finish swap to reconcile. export function useReveal(finish: Finish): RevealPhase { - // Lazy initializer: first phase computed once, no synchronous setState needed. - const [phase, setPhase] = useState( - () => sequenceFor(finish, false)[0]?.phase ?? "rise", - ); + const [phase, setPhase] = useState(() => sequenceFor(finish, false)[0]?.phase ?? "rise"); - useEffect(() => { + useIsomorphicLayoutEffect(() => { const steps = sequenceFor(finish, prefersReducedMotion()); + // Land the first step synchronously, before paint. On the animated path + // that's "rise" (already the initial state — a no-op set). Under reduced + // motion the whole sequence IS that one step ("freeze"), so we jump straight + // to the lit hero frame instead of stranding on the unlit "rise". + setPhase(steps[0].phase); const timers = steps.slice(1).map((s) => setTimeout(() => setPhase(s.phase), s.at)); return () => timers.forEach(clearTimeout); }, [finish]); return phase; } + +// Drives the Duel broadcast from the pure duel sequencer, plus a skip() that +// jumps straight to "settled" (tap-to-skip) and cancels every pending step. +export function useDuelReveal(): { phase: DuelPhase; skip: () => void } { + const [phase, setPhase] = useState(() => duelSequenceFor(false)[0]?.phase ?? { kind: "walkout" }); + const timers = useRef[]>([]); + + useIsomorphicLayoutEffect(() => { + const steps = duelSequenceFor(prefersReducedMotion()); + // Same contract as useReveal: the first step lands before paint. On the + // animated path that's "walkout" (already the initial state); under reduced + // motion it's the lone "settled" step, so the broadcast is skipped whole — + // no walkout, no flash of the masked kick-off frame. + setPhase(steps[0].phase); + timers.current = steps.slice(1).map((s) => setTimeout(() => setPhase(s.phase), s.at)); + return () => timers.current.forEach(clearTimeout); + }, []); + + const skip = useCallback(() => { + timers.current.forEach(clearTimeout); + timers.current = []; + setPhase({ kind: "settled" }); + }, []); + + return { phase, skip }; +} diff --git a/hooks/useShareActions.ts b/hooks/useShareActions.ts new file mode 100644 index 0000000..497ff6d --- /dev/null +++ b/hooks/useShareActions.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +// Client wiring for the share row, shared by CardActions and DuelView. The DATA +// (payload strings, intent URLs) lives in lib/share; this hook owns only the +// gestures. +export function useShareActions({ + getSharePayload, + getIntentUrl, + getCopyUrl, +}: { + /** What to hand navigator.share — may be async (e.g. render a file first). */ + getSharePayload: () => ShareData | Promise; + /** Fallback web-intent URL opened when native share is unavailable/fails. */ + getIntentUrl: () => string; + /** URL written to the clipboard by copyLink. */ + getCopyUrl: () => string; +}) { + const [linkCopied, setLinkCopied] = useState(false); + // Default FALSE: desktop (no Web Share) must never flash the CTA — the SSR + // HTML and first paint simply don't contain it. Supported platforms reveal + // it right after hydration, a frame nobody sees inside the page's load + // animations. (Default-true was tried and flashes on every desktop refresh.) + const [canNativeShare, setCanNativeShare] = useState(false); + const copiedTimer = useRef | null>(null); + + useEffect(() => { + const supported = + typeof navigator !== "undefined" && typeof navigator.share === "function"; + if (!supported) return; + // Deferred (not synchronous in the effect) so it can't cascade renders. + const t = setTimeout(() => setCanNativeShare(true), 0); + return () => clearTimeout(t); + }, []); + + const nativeShare = async () => { + try { + await navigator.share(await getSharePayload()); + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return; // user dismissed + window.open(getIntentUrl(), "_blank", "noopener,noreferrer"); + } + }; + + // Returns whether the write landed, so callers that show their own feedback + // (CardActions) never flash "copied" over a failed write; DuelView ignores + // the result and reads `linkCopied`, which only flips on success anyway. + const copyLink = async (): Promise => { + try { + await navigator.clipboard.writeText(getCopyUrl()); + setLinkCopied(true); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setLinkCopied(false), 1600); + return true; + } catch { + return false; // clipboard unavailable + } + }; + + return { canNativeShare, nativeShare, copyLink, linkCopied }; +} diff --git a/lib/duel.ts b/lib/duel.ts new file mode 100644 index 0000000..a83517e --- /dev/null +++ b/lib/duel.ts @@ -0,0 +1,145 @@ +import type { Card, StatKey } from "./scoring/types"; +import { METRIC_LABELS } from "./scoring/attributes"; +import { STAT_LABELS, STATS } from "./scoring/constants"; + +export type DuelSide = "challenger" | "opponent"; + +export interface DuelRow { + key: StatKey; + label: string; + challenger: number; + opponent: number; + /** Higher value takes the row; equal values score for neither side. */ + winner: DuelSide | null; +} + +export interface DuelReceipt { + label: string; + challenger: number; + opponent: number; +} + +export interface Duel { + challenger: Card; + opponent: Card; + rows: DuelRow[]; + score: Record; + /** null = Draw. */ + winner: DuelSide | null; + /** true when a level Scoreline was decided by Overall (penalties). */ + onPenalties: boolean; + /** Same login in both corners — a training match, always a Draw. */ + training: boolean; + receipts: DuelReceipt[]; + /** Playstyle names both sides earned (shared traits strip). */ + sharedPlaystyles: string[]; +} + +const RECEIPTS: { metric: string; label: string }[] = [ + { metric: METRIC_LABELS.commits, label: "Commits this year" }, + { metric: METRIC_LABELS.starsEarned, label: "Stars earned" }, + { metric: METRIC_LABELS.pullRequests, label: "Pull requests" }, + { metric: METRIC_LABELS.followers, label: "Followers" }, + { metric: METRIC_LABELS.codeReviews, label: "Code reviews" }, + { metric: METRIC_LABELS.contributions, label: "Lifetime contributions" }, +]; + +const metricValue = (card: Card, label: string): number => + card.report.metrics.find((m) => m.label === label)?.value ?? 0; + +// Winner-counting rule for a set of rows: how many each side has taken. The +// single source for the scoreline — computeDuel tallies the full six, DuelView +// tallies the rows the shootout has revealed so far for the live scoreboard. +export function tallyRows(rows: DuelRow[]): { a: number; b: number } { + return { + a: rows.filter((r) => r.winner === "challenger").length, + b: rows.filter((r) => r.winner === "opponent").length, + }; +} + +// Dominance (see CONTEXT.md) — how one-sided the duel reads. Scoreline-first: +// row wins carry most of the bar, stat margins season the rest, so a rout of +// narrow edges reads softer than a massacre but a 1–5 can NEVER show the loser +// as dominant. (Raw totals failed — FUT stats bunch ~45–95, every duel read +// ~50/50; pure margins failed too — one +21 blowout row nearly cancelled five +// narrow losses.) A won row's margin share is at least half, so the blend +// provably lands on the Scoreline winner's side of 50. +const FULL_ROW_GAP = 20; // a stat gap this big owns its row outright +const ROW_WEIGHT = 0.7; // the scoreline drives; margins season + +export function dominanceShare(rows: DuelRow[]): number { + if (rows.length === 0) return 50; + const { a, b } = tallyRows(rows); + const rowShare = (a + (rows.length - a - b) / 2) / rows.length; + const marginShare = + rows.reduce( + (t, r) => + t + + 0.5 + + Math.max( + -0.5, + Math.min(0.5, (r.challenger - r.opponent) / (2 * FULL_ROW_GAP)), + ), + 0, + ) / rows.length; + return Math.round((ROW_WEIGHT * rowShare + (1 - ROW_WEIGHT) * marginShare) * 100); +} + +export function computeDuel(challenger: Card, opponent: Card): Duel { + const training = + challenger.login.toLowerCase() === opponent.login.toLowerCase(); + + const rows: DuelRow[] = STATS.map((key) => { + const a = challenger.stats[key]; + const b = opponent.stats[key]; + return { + key, + label: STAT_LABELS[key], + challenger: a, + opponent: b, + winner: a === b ? null : a > b ? "challenger" : "opponent", + }; + }); + + const tally = tallyRows(rows); + const score: Record = { + challenger: tally.a, + opponent: tally.b, + }; + + // Scoreline first; penalties (Overall) only when level; Draw when both level. + let winner: DuelSide | null = null; + let onPenalties = false; + if (!training) { + if (score.challenger !== score.opponent) { + winner = score.challenger > score.opponent ? "challenger" : "opponent"; + } else if (challenger.overall !== opponent.overall) { + winner = + challenger.overall > opponent.overall ? "challenger" : "opponent"; + onPenalties = true; + } + } + + const receipts: DuelReceipt[] = RECEIPTS.map(({ metric, label }) => ({ + label, + challenger: metricValue(challenger, metric), + opponent: metricValue(opponent, metric), + })); + + const opponentStyles = new Set(opponent.report.playstyles.map((p) => p.name)); + const sharedPlaystyles = challenger.report.playstyles + .map((p) => p.name) + .filter((name) => opponentStyles.has(name)); + + return { + challenger, + opponent, + rows, + score, + winner, + onPenalties, + training, + receipts, + sharedPlaystyles, + }; +} diff --git a/lib/format.ts b/lib/format.ts index b91c0d0..d5f1c08 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,3 +1,8 @@ // Compact number formatting for display (1240 → "1.2k", 248723 → "249k"). export const formatCount = (n: number): string => n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, "") + "k" : String(Math.round(n)); + +// Fixed-precision rounding for deterministic geometry / imperative transforms — +// shared so radar, VS burst and tilt all round identically. +export const round2 = (n: number) => Math.round(n * 100) / 100; +export const round1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/og/renderCard.tsx b/lib/og/renderCard.tsx index 624036a..24566d5 100644 --- a/lib/og/renderCard.tsx +++ b/lib/og/renderCard.tsx @@ -114,12 +114,17 @@ async function avatarDataUri(url: string, bw: number, bh: number): Promise>), fileDataUri(publicFile(bgRel), "image/png"), avatarDataUri(card.avatarUrl, avW, avH), card.country ? fileDataUri(publicFile(`/badges/flags/${card.country}.png`), "image/png") : Promise.resolve(null), diff --git a/lib/radar.ts b/lib/radar.ts new file mode 100644 index 0000000..3c8424f --- /dev/null +++ b/lib/radar.ts @@ -0,0 +1,84 @@ +import { STAT_LABELS, STATS } from "./scoring/constants"; +import type { Stats } from "./scoring/types"; +import { round2 } from "./format"; + +export interface RadarVertex { + x: number; + y: number; +} + +export interface RadarLabel extends RadarVertex { + label: string; +} + +export interface RadarGeometry { + center: number; + radius: number; + /** Stat polygon corners, STATS order. */ + vertices: RadarVertex[]; + /** SVG points attribute for the stat polygon. */ + points: string; + /** Concentric hexagon outlines at 1/3, 2/3 and the full radius. */ + rings: string[]; + /** One hexagon wedge per axis: centre → edge-mid → vertex → edge-mid. */ + sectors: string[]; + /** One label per stat, anchored just outside the outer ring. */ + labels: RadarLabel[]; +} + +const RING_FRACTIONS = [1 / 3, 2 / 3, 1]; +const RADIUS_SHARE = 0.72; // of the half-size — the rest is label breathing room +const LABEL_GAP_SHARE = 0.1; // of the full size, beyond the outer ring + +// Axis i sits at -90° + i·60°: PAC up top, then clockwise with STATS. +const angleFor = (i: number) => ((-90 + i * 60) * Math.PI) / 180; + +// One axis' wedge of the hexagon: centre → edge midpoint → vertex → edge +// midpoint. Callers may pass a radius beyond the ring's (bigger hit zones). +export function radarSector( + center: number, + radius: number, + axis: number, +): string { + const mid = radius * Math.cos(Math.PI / 6); // hexagon edge midpoints sit at R·cos30° + const theta = angleFor(axis); + const pt = (r: number, a: number) => + `${round2(center + r * Math.cos(a))},${round2(center + r * Math.sin(a))}`; + return [ + `${center},${center}`, + pt(mid, theta - Math.PI / 6), + pt(radius, theta), + pt(mid, theta + Math.PI / 6), + ].join(" "); +} + +export function radarGeometry(stats: Stats, size: number): RadarGeometry { + const center = size / 2; + const radius = center * RADIUS_SHARE; + + const at = (r: number, i: number): RadarVertex => ({ + x: round2(center + r * Math.cos(angleFor(i))), + y: round2(center + r * Math.sin(angleFor(i))), + }); + const outline = (r: number) => + STATS.map((_, i) => at(r, i)) + .map((v) => `${v.x},${v.y}`) + .join(" "); + + const vertices = STATS.map((key, i) => + at((radius * Math.min(Math.max(stats[key], 0), 99)) / 99, i), + ); + + return { + center, + radius, + vertices, + points: vertices.map((v) => `${v.x},${v.y}`).join(" "), + rings: RING_FRACTIONS.map((f) => outline(radius * f)), + sectors: STATS.map((_, i) => radarSector(center, radius, i)), + labels: STATS.map((key, i) => ({ + label: STAT_LABELS[key], + ...at(radius + size * LABEL_GAP_SHARE, i), + })), + }; +} diff --git a/lib/reveal.ts b/lib/reveal.ts index dffd5d7..60ba7cb 100644 --- a/lib/reveal.ts +++ b/lib/reveal.ts @@ -16,13 +16,21 @@ export interface RevealStep { // Tiers that earn the full spectacle (TOTY + Icon/Legend). TOTW (in-form) also // gets a modest burst because it is, by definition, a rare "event" card. -const BURST_TIERS: ReadonlySet = new Set(["toty", "icon", "totw", "founder"]); +const BURST_TIERS: ReadonlySet = new Set([ + "toty", + "icon", + "totw", + "founder", +]); export function hasBurst(finish: Finish): boolean { return BURST_TIERS.has(finish); } -export function sequenceFor(finish: Finish, reducedMotion: boolean): RevealStep[] { +export function sequenceFor( + finish: Finish, + reducedMotion: boolean, +): RevealStep[] { // Accessibility: collapse straight to the hero frame — same payoff, no motion. if (reducedMotion) return [{ phase: "freeze", at: 0 }]; @@ -43,7 +51,53 @@ export function sequenceFor(finish: Finish, reducedMotion: boolean): RevealStep[ // Total wall-clock duration of the sequence (ms) — handy for callers that want // to know when the reveal has settled. -export function sequenceDuration(finish: Finish, reducedMotion: boolean): number { +export function sequenceDuration( + finish: Finish, + reducedMotion: boolean, +): number { const steps = sequenceFor(finish, reducedMotion); return steps[steps.length - 1]?.at ?? 0; } + +export type DuelPhase = + | { kind: "walkout" } + | { kind: "row"; row: number } // rows 0..5 resolved up to and including `row` + | { kind: "result" } + | { kind: "settled" }; + +export interface DuelStep { + phase: DuelPhase; + at: number; // ms offset from reveal start +} + +const DUEL_ROWS = 6; +const DUEL_FIRST_ROW_AT = 1500; // after both walkouts land +const DUEL_ROW_GAP = 480; +const DUEL_RESULT_GAP = 700; // beat between the last row and the FT stamp +const DUEL_SETTLE_GAP = 900; + +export function duelSequenceFor(reducedMotion: boolean): DuelStep[] { + if (reducedMotion) return [{ phase: { kind: "settled" }, at: 0 }]; + const steps: DuelStep[] = [{ phase: { kind: "walkout" }, at: 0 }]; + for (let row = 0; row < DUEL_ROWS; row++) { + steps.push({ + phase: { kind: "row", row }, + at: DUEL_FIRST_ROW_AT + row * DUEL_ROW_GAP, + }); + } + const lastRowAt = DUEL_FIRST_ROW_AT + (DUEL_ROWS - 1) * DUEL_ROW_GAP; + steps.push({ phase: { kind: "result" }, at: lastRowAt + DUEL_RESULT_GAP }); + steps.push({ + phase: { kind: "settled" }, + at: lastRowAt + DUEL_RESULT_GAP + DUEL_SETTLE_GAP, + }); + return steps; +} + +// How many stat rows are resolved (0–6) in a given phase — the scoreboard and +// the row list both render off this single number. +export function resolvedRows(phase: DuelPhase): number { + if (phase.kind === "row") return phase.row + 1; + if (phase.kind === "result" || phase.kind === "settled") return DUEL_ROWS; + return 0; +} diff --git a/lib/scoring/attributes.ts b/lib/scoring/attributes.ts index dd2109a..850c941 100644 --- a/lib/scoring/attributes.ts +++ b/lib/scoring/attributes.ts @@ -65,17 +65,32 @@ interface MetricDef { value: (s: Signals) => number; } +// Canonical metric display labels — the single source for every surface that +// looks a metric up by label (the scout report renders them; lib/duel reads +// receipts back through them). Renaming here flows everywhere at compile time. +export const METRIC_LABELS = { + commits: "Commits", + starsEarned: "Stars earned", + topRepoReach: "Top repo reach", + pullRequests: "Pull requests", + followers: "Followers", + languages: "Languages", + issues: "Issues", + codeReviews: "Code reviews", + contributions: "Contributions", +} as const; + // Core metrics — always shown (a few zeros are fine). const CORE_METRICS: MetricDef[] = [ - { label: "Commits", unit: "commits", ref: 3_000, value: (s) => s.recent_commits }, - { label: "Stars earned", unit: "stars", ref: 200_000, value: (s) => s.total_stars_owned }, - { label: "Top repo reach", unit: "stars", ref: 150_000, value: (s) => s.max_repo_stars }, - { label: "Pull requests", unit: "PRs", ref: 2_000, value: (s) => s.prs_to_others }, - { label: "Followers", unit: "followers", ref: 100_000, value: (s) => s.followers }, - { label: "Languages", unit: "languages", ref: 15, value: (s) => s.languages }, - { label: "Issues", unit: "issues", ref: 1_500, value: (s) => s.issues_closed }, - { label: "Code reviews", unit: "reviews", ref: 2_000, value: (s) => s.reviews }, - { label: "Contributions", unit: "contributions", ref: 50_000, value: (s) => s.total_contributions_lifetime }, + { label: METRIC_LABELS.commits, unit: "commits", ref: 3_000, value: (s) => s.recent_commits }, + { label: METRIC_LABELS.starsEarned, unit: "stars", ref: 200_000, value: (s) => s.total_stars_owned }, + { label: METRIC_LABELS.topRepoReach, unit: "stars", ref: 150_000, value: (s) => s.max_repo_stars }, + { label: METRIC_LABELS.pullRequests, unit: "PRs", ref: 2_000, value: (s) => s.prs_to_others }, + { label: METRIC_LABELS.followers, unit: "followers", ref: 100_000, value: (s) => s.followers }, + { label: METRIC_LABELS.languages, unit: "languages", ref: 15, value: (s) => s.languages }, + { label: METRIC_LABELS.issues, unit: "issues", ref: 1_500, value: (s) => s.issues_closed }, + { label: METRIC_LABELS.codeReviews, unit: "reviews", ref: 2_000, value: (s) => s.reviews }, + { label: METRIC_LABELS.contributions, unit: "contributions", ref: 50_000, value: (s) => s.total_contributions_lifetime }, ]; // Optional metrics — appended only to make up for zeroed core ones (see below). diff --git a/lib/scoring/constants.ts b/lib/scoring/constants.ts index cb40cdf..3f79e04 100644 --- a/lib/scoring/constants.ts +++ b/lib/scoring/constants.ts @@ -2,6 +2,17 @@ import type { Family, Finish, FounderMeta, StatKey, Stats } from "./types"; export const STATS: StatKey[] = ["pac", "sho", "pas", "dri", "def", "phy"]; +// Canonical stat → display abbreviation: the single source for any surface that +// labels the six stats (the Duel's shootout rows read these). +export const STAT_LABELS: Record = { + pac: "PAC", + sho: "SHO", + pas: "PAS", + dri: "DRI", + def: "DEF", + phy: "PHY", +}; + // The attacking/technical four share sub-skills in real FUT cards (dribbling and // pace pull from the same agility/balance traits, etc.), so they're kept cohesive // — pulled toward their own group mean after the spike. DEF/PHY stay free: role diff --git a/lib/scout.ts b/lib/scout.ts index 179b480..9b631f6 100644 --- a/lib/scout.ts +++ b/lib/scout.ts @@ -1,7 +1,8 @@ import "server-only"; +import { cache } from "react"; import { redis } from "./redis"; import { buildCard } from "./scoring/engine"; -import { fetchProfile } from "./github/client"; +import { fetchProfile, type GithubError } from "./github/client"; import { signalsFromPayload } from "./github/signals"; import { SAMPLE_CARDS } from "./github/samples"; import type { Card } from "./scoring/types"; @@ -92,3 +93,18 @@ export async function scoutCard(username: string): Promise { inflight.set(login, pending); return pending; } + +// Request-memoised card load that returns the scout error instead of throwing, +// so a route's generateMetadata + Page — and a Duel's two corners — share one +// scout per request and render the failure state themselves. The cross-request +// Redis cache lives in scoutCard above; this cache() only dedupes within a +// single request/render pass. +export const loadCard = cache( + async (username: string): Promise<{ card: Card } | { error: GithubError }> => { + try { + return { card: await scoutCard(username) }; + } catch (e) { + return { error: e as GithubError }; + } + }, +); diff --git a/lib/share.ts b/lib/share.ts index 6b9d5a1..d13b769 100644 --- a/lib/share.ts +++ b/lib/share.ts @@ -46,20 +46,24 @@ export function shareMessage(card: Card): string { return `${shareText(card)}\n\nget scouted →`; } -// Per-platform intent URLs. X uses /intent/tweet (NOT /intent/post — the latter -// loops on mobile). LinkedIn honors only the url; its preview comes from OG tags. +// X (Twitter) web-intent composer — the single source for the tweet string. +// Uses /intent/tweet (NOT /intent/post — the latter loops on mobile); carries +// the prefilled body, the url, and the hashtag. +const tweetIntent = (text: string, url: string): string => + "https://twitter.com/intent/tweet?text=" + + encodeURIComponent(text) + + "&url=" + + encodeURIComponent(url) + + "&hashtags=GitFut"; + +// Per-platform intent URLs. LinkedIn honors only the url; its preview comes from +// OG tags. export function intentUrl(platform: SharePlatform, card: Card): string { const url = cardUrl(card); const text = shareMessage(card); switch (platform) { case "x": - return ( - "https://twitter.com/intent/tweet?text=" + - encodeURIComponent(text) + - "&url=" + - encodeURIComponent(url) + - "&hashtags=GitFut" - ); + return tweetIntent(text, url); case "linkedin": return ( "https://www.linkedin.com/sharing/share-offsite/?url=" + @@ -86,3 +90,39 @@ export function nativeSharePayload(card: Card): { title: string; text: string; u export function shareUrl(card: Card): string { return intentUrl("x", card); } + +// ---- Duel sharing ---- +// Score-free by design: the fixture poster never spoils the Result, and the +// default share text protects the same click ("full-time score inside"). +// Sharers who want to brag the score type it themselves. + +export function duelUrl(challenger: string, opponent: string): string { + return `${SITE}/${challenger}/vs/${opponent}`; +} + +const duelLines = (opponent: string): string[] => [ + `just dragged @${opponent} onto the pitch. full-time score inside.`, + `me vs @${opponent}, settled on github stats. someone got cooked.`, + `called out @${opponent} for a duel. the scoreline does the talking.`, + `six stats, no VAR. me vs @${opponent} — result inside.`, +]; + +export function duelShareMessage(challenger: string, opponent: string): string { + const pool = duelLines(opponent); + return `${pool[hash(`${challenger}/${opponent}`) % pool.length]}\n\nwatch the duel →`; +} + +export function duelIntentUrl(challenger: string, opponent: string): string { + return tweetIntent(duelShareMessage(challenger, opponent), duelUrl(challenger, opponent)); +} + +export function duelSharePayload( + challenger: string, + opponent: string, +): { title: string; text: string; url: string } { + return { + title: "GitFut Duel", + text: duelShareMessage(challenger, opponent), + url: duelUrl(challenger, opponent), + }; +} diff --git a/lib/vsBurst.ts b/lib/vsBurst.ts new file mode 100644 index 0000000..6eb158c --- /dev/null +++ b/lib/vsBurst.ts @@ -0,0 +1,159 @@ +import { round2 } from "./format"; + +// Pure, deterministic geometry for the VS lightning burst — path strings, the +// sliver polygon, and the particle spray. NO "use client" and no React hooks: +// this module is consumed by both the live DuelView and the Satori OG poster +// (app/[username]/vs/[opponent]/poster.png/route.tsx), so it must stay pure. +// Its sibling geometry lib is lib/radar.ts; the SVG dressing is the component's. + +type Pt = [number, number]; + +// ---- design grid ---- +export const VS_W = 120; +export const VS_H = 170; + +// ---- letterforms: blocky comic letters on a 60-tall grid ---- +const LETTER_H = 60; +const V_PTS: Pt[] = [ + [0, 0], + [16, 0], + [25, 36], + [34, 0], + [50, 0], + [32, 60], + [18, 60], +]; +const S_PTS: Pt[] = [ + [0, 0], + [44, 0], + [44, 13], + [13, 13], + [13, 24], + [44, 24], + [44, 60], + [0, 60], + [0, 47], + [31, 47], + [31, 36], + [0, 36], +]; + +// Italic shear, a rotation about the letter's own centre, then scale + place. +function letterPath( + pts: Pt[], + shear: number, + rotDeg: number, + scale: number, + ox: number, + oy: number, +): string { + const rot = (rotDeg * Math.PI) / 180; + const cos = Math.cos(rot); + const sin = Math.sin(rot); + const sheared = pts.map(([x, y]): Pt => [x + (LETTER_H - y) * shear, y]); + const cx = sheared.reduce((t, p) => t + p[0], 0) / sheared.length; + const cy = LETTER_H / 2; + return ( + sheared + .map(([x, y], i) => { + const rx = cx + (x - cx) * cos - (y - cy) * sin; + const ry = cy + (x - cx) * sin + (y - cy) * cos; + return `${i === 0 ? "M" : "L"}${round2(ox + rx * scale)} ${round2(oy + ry * scale)}`; + }) + .join("") + "Z" + ); +} + +// The letters: one uniform italic lean (like the art), S tucked tight against +// the V and dropped a beat lower, so the pair reads as one block the bolt +// disappears behind. +export const V_PATH = letterPath(V_PTS, 0.3, 0, 0.86, 16, 56); +export const S_PATH = letterPath(S_PTS, 0.3, 0, 0.86, 52, 66); + +// ---- the lightning sliver: bottom-left tip to top-right tip ---- +const T1: Pt = [14, 158]; +const T2: Pt = [106, 12]; +const DX = T2[0] - T1[0]; +const DY = T2[1] - T1[1]; +const LEN = Math.hypot(DX, DY); +const NX = -DY / LEN; +const NY = DX / LEN; + +// A sliver between two arbitrary tips: both sharp, widest at the centre. The +// in-box VS bolt and the full-canvas fixture-poster bolt share this shape. +export function sliverBetween(t1: Pt, t2: Pt, hw: number): string { + const cx = (t1[0] + t2[0]) / 2; + const cy = (t1[1] + t2[1]) / 2; + const dx = t2[0] - t1[0]; + const dy = t2[1] - t1[1]; + const len = Math.hypot(dx, dy); + const nx = -dy / len; + const ny = dx / len; + return [ + `${t1[0]},${t1[1]}`, + `${round2(cx + nx * hw)},${round2(cy + ny * hw)}`, + `${t2[0]},${t2[1]}`, + `${round2(cx - nx * hw)},${round2(cy - ny * hw)}`, + ].join(" "); +} + +// A sliver of the given half-width: both tips sharp, widest at the centre. +export const sliver = (hw: number): string => sliverBetween(T1, T2, hw); + +export interface Particle { + x: number; + y: number; + r: number; + o: number; + bright: boolean; +} + +// Particle spray clustered tight around both tips (deterministic — no +// Math.random, the same burst every render on server, client and poster). +export const PARTICLES: Particle[] = Array.from({ length: 26 }, (_, i) => { + const t = i < 13 ? 0.01 + i * 0.019 : 0.74 + (i - 13) * 0.019; + const off = (((i * 37) % 11) - 5) * 0.95; + return { + x: round2(T1[0] + DX * t + NX * off), + y: round2(T1[1] + DY * t + NY * off), + r: round2(0.4 + ((i * 53) % 12) / 12), + o: 0.35 + ((i * 29) % 45) / 100, + bright: i % 3 === 0, + }; +}); + +// Ember spray for the fixture poster's full-canvas bolt: deterministic like +// PARTICLES, but clustered around the run's MIDDLE (where the VS sits) — the +// tips sit off-canvas there, so tip clusters would never be seen. +export function particlesAlong( + t1: Pt, + t2: Pt, + count: number, + spread: number, + rBase: number, +): Particle[] { + const dx = t2[0] - t1[0]; + const dy = t2[1] - t1[1]; + const len = Math.hypot(dx, dy); + const nx = -dy / len; + const ny = dx / len; + return Array.from({ length: count }, (_, i) => { + const u = (i + 0.5) / count; + // pull every sample toward t=0.5 by a pseudo-random amount: dense near the + // VS, sparse toward the canvas edges + const t = 0.5 + (u - 0.5) * (0.35 + ((i * 31) % 8) / 10); + const off = (((i * 37) % 23) - 11) * spread; + return { + x: round2(t1[0] + dx * t + nx * off), + y: round2(t1[1] + dy * t + ny * off), + r: round2(rBase * (0.4 + ((i * 53) % 12) / 12)), + o: 0.35 + ((i * 29) % 45) / 100, + bright: i % 3 === 0, + }; + }); +} + +// Rendered box for a given height: width derived from the design aspect. +export function vsBurstBox(size: number): { w: number; h: number } { + return { w: Math.round((size * VS_W) / VS_H), h: size }; +} diff --git a/package-lock.json b/package-lock.json index e221ad3..da6978d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1", - "sharp": "^0.35.3" + "sharp": "^0.34.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -539,9 +539,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -551,19 +551,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -573,38 +573,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -618,9 +599,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -634,9 +615,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -653,9 +634,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -672,9 +653,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -691,9 +672,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], @@ -710,9 +691,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -729,9 +710,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -748,9 +729,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -767,9 +748,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -786,9 +767,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -801,19 +782,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -826,19 +807,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -851,19 +832,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], @@ -876,19 +857,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -901,19 +882,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -926,19 +907,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -951,19 +932,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -976,54 +957,38 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@emnapi/runtime": "^1.7.0" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1033,16 +998,16 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1052,16 +1017,16 @@ "win32" ], "engines": { - "node": "^20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1071,7 +1036,7 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -5917,633 +5882,71 @@ } } }, - "node_modules/next/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/next/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, - "node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/next/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/next/node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", - "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-releases": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", - "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/object-inspect": { @@ -7264,52 +6667,47 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.1.0", + "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "semver": "^7.7.3" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/sharp/node_modules/semver": { diff --git a/package.json b/package.json index 16c81c7..be80d2c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1", - "sharp": "^0.35.3" + "sharp": "^0.34.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/tests/duel.test.ts b/tests/duel.test.ts new file mode 100644 index 0000000..4a69f73 --- /dev/null +++ b/tests/duel.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { computeDuel, dominanceShare, tallyRows } from "@/lib/duel"; +import { duelSequenceFor, resolvedRows } from "@/lib/reveal"; +import type { Card, Stats } from "@/lib/scoring/types"; + +const mkCard = ( + login: string, + stats: Stats, + overall: number, + extras: { + playstyles?: string[]; + metrics?: { label: string; value: number }[]; + } = {}, +): Card => ({ + login, + name: login, + avatarUrl: `https://github.com/${login}.png`, + country: "", + club: "neutral", + stats, + position: "CM", + family: "Playmaker", + baseOVR: overall, + overall, + finish: "gold", + finishLabel: "GOLD", + archetype: "Mezzala", + archetypeBlurb: "", + legacy: { L: 0 }, + report: { + skillMoves: 3, + weakFoot: 3, + workRate: { attack: "Med", defense: "Med" }, + style: "Measured", + reasons: { skillMoves: "", weakFoot: "", workRate: "", style: "" }, + playstyles: (extras.playstyles ?? []).map((name) => ({ + name, + icon: "star", + plus: false, + reason: "", + })), + metrics: (extras.metrics ?? []).map((m) => ({ ...m, score: 50 })), + }, +}); + +const stats = ( + pac: number, + sho: number, + pas: number, + dri: number, + def: number, + phy: number, +): Stats => ({ + pac, + sho, + pas, + dri, + def, + phy, +}); + +describe("computeDuel", () => { + it("most stat wins takes the duel; equal rows score for neither side", () => { + const a = mkCard("a", stats(90, 90, 90, 90, 10, 50), 80); + const b = mkCard("b", stats(10, 10, 10, 10, 90, 50), 70); + const duel = computeDuel(a, b); + expect(duel.score).toEqual({ challenger: 4, opponent: 1 }); + expect(duel.rows.find((r) => r.key === "phy")?.winner).toBeNull(); // 50 = 50 + expect(duel.winner).toBe("challenger"); + expect(duel.onPenalties).toBe(false); + }); + + it("a 3–3 scoreline goes to penalties: higher overall takes it", () => { + const a = mkCard("a", stats(90, 90, 90, 10, 10, 10), 70); + const b = mkCard("b", stats(10, 10, 10, 90, 90, 90), 80); + const duel = computeDuel(a, b); + expect(duel.score).toEqual({ challenger: 3, opponent: 3 }); + expect(duel.winner).toBe("opponent"); + expect(duel.onPenalties).toBe(true); + }); + + it("the scoreline outranks overall: a lower-rated card can win the duel", () => { + const a = mkCard("a", stats(60, 60, 60, 60, 60, 10), 65); + const b = mkCard("b", stats(50, 50, 50, 50, 50, 95), 90); + const duel = computeDuel(a, b); + expect(duel.score).toEqual({ challenger: 5, opponent: 1 }); + expect(duel.winner).toBe("challenger"); + expect(duel.onPenalties).toBe(false); + }); + + it("level scoreline and level overall is a draw", () => { + const a = mkCard("a", stats(90, 90, 90, 10, 10, 10), 75); + const b = mkCard("b", stats(10, 10, 10, 90, 90, 90), 75); + const duel = computeDuel(a, b); + expect(duel.winner).toBeNull(); + expect(duel.onPenalties).toBe(false); + }); + + it("the same login in both corners is a training match (draw, flagged)", () => { + const a = mkCard("Same", stats(80, 70, 60, 50, 40, 30), 75); + const b = mkCard("same", stats(80, 70, 60, 50, 40, 30), 75); + const duel = computeDuel(a, b); + expect(duel.training).toBe(true); + expect(duel.winner).toBeNull(); + }); + + it("receipts are the fixed six rows; a metric hidden as zero reads back as 0", () => { + const a = mkCard("a", stats(50, 50, 50, 50, 50, 50), 70, { + metrics: [ + { label: "Commits", value: 1200 }, + { label: "Stars earned", value: 300 }, + // "Code reviews" absent — deriveMetrics hid it as a zero + ], + }); + const b = mkCard("b", stats(50, 50, 50, 50, 50, 50), 70); + const duel = computeDuel(a, b); + expect(duel.receipts.map((r) => r.label)).toEqual([ + "Commits this year", + "Stars earned", + "Pull requests", + "Followers", + "Code reviews", + "Lifetime contributions", + ]); + // The display label differs from the canonical metric key the value is read + // back through: "Commits this year" is looked up via METRIC_LABELS.commits. + expect( + duel.receipts.find((r) => r.label === "Commits this year")?.challenger, + ).toBe(1200); + expect( + duel.receipts.find((r) => r.label === "Code reviews")?.challenger, + ).toBe(0); + }); + + it("shared playstyles are the intersection, in the challenger's order", () => { + const a = mkCard("a", stats(50, 50, 50, 50, 50, 50), 70, { + playstyles: ["Workhorse", "Star Magnet", "Polyglot"], + }); + const b = mkCard("b", stats(50, 50, 50, 50, 50, 50), 70, { + playstyles: ["Polyglot", "Workhorse", "Veteran"], + }); + expect(computeDuel(a, b).sharedPlaystyles).toEqual([ + "Workhorse", + "Polyglot", + ]); + }); +}); + +describe("tallyRows", () => { + it("counts winners per side and ignores drawn rows", () => { + const a = mkCard("a", stats(90, 90, 90, 90, 10, 50), 80); + const b = mkCard("b", stats(10, 10, 10, 10, 90, 50), 70); + const { rows } = computeDuel(a, b); + // phy is 50–50 (a draw), so it counts for neither: 4 + 1 = 5, not 6. + expect(tallyRows(rows)).toEqual({ a: 4, b: 1 }); + }); + + it("tallies a progressive subset — the live scoreboard's rule", () => { + const a = mkCard("a", stats(90, 90, 90, 90, 10, 50), 80); + const b = mkCard("b", stats(10, 10, 10, 10, 90, 50), 70); + const { rows } = computeDuel(a, b); + // Only the rows the shootout has revealed so far count (first three here). + expect(tallyRows(rows.slice(0, 3))).toEqual({ a: 3, b: 0 }); + expect(tallyRows([])).toEqual({ a: 0, b: 0 }); + }); + + it("agrees with computeDuel's own scoreline over the full set", () => { + const a = mkCard("a", stats(90, 90, 90, 10, 10, 10), 70); + const b = mkCard("b", stats(10, 10, 10, 90, 90, 90), 80); + const duel = computeDuel(a, b); + expect(tallyRows(duel.rows)).toEqual({ + a: duel.score.challenger, + b: duel.score.opponent, + }); + }); +}); + +describe("dominanceShare", () => { + it("reads a rout as a rout — a 5–1 of narrow edges lands ~78%, not the raw totals' 52%", () => { + // Torvalds 82/92/87/77/58/95 vs Ferradji 76/89/83/78/47/80 (the 5–1 case + // where the raw-total split collapsed to 52/48). + const a = mkCard("torvalds", stats(82, 92, 87, 77, 58, 95), 96); + const b = mkCard("younesfdj", stats(76, 89, 83, 78, 47, 80), 93); + expect(dominanceShare(computeDuel(a, b).rows)).toBe(78); + }); + + it("can never sit on the wrong side of the scoreline — one +21 blowout row doesn't outweigh five narrow losses", () => { + // Oxhouss 70/64/69/70/41/83 vs Abdelrzz9 74/67/80/80/44/62: a 1–5 where the + // pure-margin metric read 45/55 IN THE LOSER'S FAVOUR. Scoreline-first, it + // must land well under 50. + const a = mkCard("oxhouss", stats(70, 64, 69, 70, 41, 83), 74); + const b = mkCard("abdelrzz9", stats(74, 67, 80, 80, 44, 62), 72); + const share = dominanceShare(computeDuel(a, b).rows); + expect(share).toBe(25); + expect(share).toBeLessThan(50); + }); + + it("is 50 for a dead-even duel and for no resolved rows", () => { + const a = mkCard("a", stats(70, 70, 70, 70, 70, 70), 75); + expect(dominanceShare(computeDuel(a, a).rows)).toBe(50); + expect(dominanceShare([])).toBe(50); + }); + + it("total domination is 100 (or 0 mirrored), never more", () => { + const a = mkCard("a", stats(99, 99, 99, 99, 99, 99), 90); + const b = mkCard("b", stats(10, 10, 10, 10, 10, 10), 50); + expect(dominanceShare(computeDuel(a, b).rows)).toBe(100); + expect(dominanceShare(computeDuel(b, a).rows)).toBe(0); + }); +}); + +describe("duel broadcast sequence", () => { + it("walks out, resolves all six rows in order, stamps, then settles", () => { + const steps = duelSequenceFor(false); + expect(steps[0].phase).toEqual({ kind: "walkout" }); + const rowSteps = steps.filter((s) => s.phase.kind === "row"); + expect( + rowSteps.map((s) => (s.phase.kind === "row" ? s.phase.row : -1)), + ).toEqual([0, 1, 2, 3, 4, 5]); + expect(steps[steps.length - 2].phase).toEqual({ kind: "result" }); + expect(steps[steps.length - 1].phase).toEqual({ kind: "settled" }); + for (let i = 1; i < steps.length; i++) { + expect(steps[i].at).toBeGreaterThan(steps[i - 1].at); + } + }); + + it("reduced motion collapses straight to settled at 0ms", () => { + expect(duelSequenceFor(true)).toEqual([ + { phase: { kind: "settled" }, at: 0 }, + ]); + }); + + it("resolvedRows maps every phase to the scoreboard's row count", () => { + expect(resolvedRows({ kind: "walkout" })).toBe(0); + expect(resolvedRows({ kind: "row", row: 2 })).toBe(3); + expect(resolvedRows({ kind: "result" })).toBe(6); + expect(resolvedRows({ kind: "settled" })).toBe(6); + }); +}); diff --git a/tests/duelThemes.test.ts b/tests/duelThemes.test.ts new file mode 100644 index 0000000..e7695e9 --- /dev/null +++ b/tests/duelThemes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { RESULT_THEME, duelThemes } from "@/components/finishTheme"; +import type { Card } from "@/lib/scoring/types"; +import type { Finish } from "@/lib/scoring/types"; + +// The Duel kit-clash rule is deliberately surgical: ONLY a toty/totw vs silver +// pairing recolors (their inks are near-twins), and it's always the toty side +// that swaps to its saturated tier blue. Every other matchup — including +// same-tier duels and the gold/icon near-twins — keeps its true tier inks. + +const card = (finish: Finish): Card => ({ finish }) as Card; +const TOTY_BLUE = "#7fa8ff"; + +describe("duelThemes (kit clash)", () => { + it("toty vs silver: the toty side wears the saturated tier blue, silver stays silver", () => { + const { home, away } = duelThemes(card("toty"), card("silver")); + expect(home.ink).toBe(TOTY_BLUE); + expect(away).toEqual(RESULT_THEME.silver); + }); + + it("silver vs toty: same rule from the other corner", () => { + const { home, away } = duelThemes(card("silver"), card("toty")); + expect(home).toEqual(RESULT_THEME.silver); + expect(away.ink).toBe(TOTY_BLUE); + }); + + it("totw counts as toty blue (identical ink) vs silver", () => { + expect(duelThemes(card("totw"), card("silver")).home.ink).toBe(TOTY_BLUE); + expect(duelThemes(card("silver"), card("totw")).away.ink).toBe(TOTY_BLUE); + }); + + it("no other matchup is touched — not even the near-twin golds or same-tier duels", () => { + expect(duelThemes(card("gold"), card("icon"))).toEqual({ + home: RESULT_THEME.gold, + away: RESULT_THEME.icon, + }); + expect(duelThemes(card("gold"), card("gold"))).toEqual({ + home: RESULT_THEME.gold, + away: RESULT_THEME.gold, + }); + expect(duelThemes(card("toty"), card("gold"))).toEqual({ + home: RESULT_THEME.toty, + away: RESULT_THEME.gold, + }); + expect(duelThemes(card("toty"), card("toty"))).toEqual({ + home: RESULT_THEME.toty, + away: RESULT_THEME.toty, + }); + }); +}); diff --git a/tests/radar.test.ts b/tests/radar.test.ts new file mode 100644 index 0000000..606a529 --- /dev/null +++ b/tests/radar.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { radarGeometry, radarSector } from "@/lib/radar"; +import { STATS, STAT_LABELS } from "@/lib/scoring/constants"; +import type { Stats } from "@/lib/scoring/types"; + +// We test the GEOMETRY the radar encodes: six axes in STATS order starting at +// 12 o'clock, values mapped 0–99 onto the radius, rings as even fractions, and +// labels anchored just outside the outer ring. The SVG dressing is React's job. + +const stats = (pac: number, sho: number, pas: number, dri: number, def: number, phy: number): Stats => ({ + pac, + sho, + pas, + dri, + def, + phy, +}); + +const dist = (x: number, y: number, c: number) => Math.hypot(x - c, y - c); + +describe("radarGeometry", () => { + const size = 100; + const geo = radarGeometry(stats(99, 99, 99, 99, 99, 99), size); + const { center, radius } = geo; + + it("puts a maxed PAC (first axis) straight up at the full radius", () => { + const [pac] = geo.vertices; + expect(pac.x).toBeCloseTo(center, 1); + expect(pac.y).toBeCloseTo(center - radius, 1); + }); + + it("keeps the six vertices in STATS order, one per 60°", () => { + expect(geo.vertices).toHaveLength(STATS.length); + // All-equal stats make a regular hexagon: every vertex equidistant. + for (const v of geo.vertices) { + expect(dist(v.x, v.y, center)).toBeCloseTo(radius, 1); + } + // sho sits upper-right, phy upper-left (clockwise from the top). + expect(geo.vertices[1].x).toBeGreaterThan(center); + expect(geo.vertices[5].x).toBeLessThan(center); + }); + + it("scales a value linearly onto the radius", () => { + const half = radarGeometry(stats(50, 50, 50, 50, 50, 50), size); + for (const v of half.vertices) { + expect(dist(v.x, v.y, center)).toBeCloseTo(radius * (50 / 99), 1); + } + }); + + it("collapses zero stats to the center and clamps values above 99", () => { + const zero = radarGeometry(stats(0, 0, 0, 0, 0, 0), size); + for (const v of zero.vertices) { + expect(v.x).toBeCloseTo(center, 1); + expect(v.y).toBeCloseTo(center, 1); + } + const over = radarGeometry(stats(150, 99, 99, 99, 99, 99), size); + expect(dist(over.vertices[0].x, over.vertices[0].y, center)).toBeCloseTo(radius, 1); + }); + + it("draws three rings, the outer one at the full radius", () => { + expect(geo.rings).toHaveLength(3); + const outerFirst = geo.rings[2].split(" ")[0].split(",").map(Number); + expect(outerFirst[0]).toBeCloseTo(center, 1); + expect(outerFirst[1]).toBeCloseTo(center - radius, 1); + }); + + it("anchors one label per stat, in order, just outside the outer ring", () => { + expect(geo.labels.map((l) => l.label)).toEqual(STATS.map((k) => STAT_LABELS[k])); + for (const l of geo.labels) { + expect(dist(l.x, l.y, center)).toBeGreaterThan(radius); + expect(l.x).toBeGreaterThanOrEqual(0); + expect(l.y).toBeGreaterThanOrEqual(0); + expect(l.x).toBeLessThanOrEqual(size); + expect(l.y).toBeLessThanOrEqual(size); + } + }); + + it("joins the vertices into the polygon points attribute", () => { + expect(geo.points.split(" ")).toHaveLength(6); + expect(geo.points.startsWith(`${geo.vertices[0].x},${geo.vertices[0].y}`)).toBe(true); + }); + + it("cuts one sector wedge per axis: centre → edge-mid → vertex → edge-mid", () => { + expect(geo.sectors).toHaveLength(STATS.length); + const pts = geo.sectors[0].split(" ").map((p) => p.split(",").map(Number)); + expect(pts).toHaveLength(4); + expect(pts[0]).toEqual([center, center]); + // the wedge's tip is the top vertex; its flanks sit on the hexagon's edges + expect(pts[2][0]).toBeCloseTo(center, 1); + expect(pts[2][1]).toBeCloseTo(center - radius, 1); + const edgeMid = radius * Math.cos(Math.PI / 6); + expect(dist(pts[1][0], pts[1][1], center)).toBeCloseTo(edgeMid, 1); + expect(dist(pts[3][0], pts[3][1], center)).toBeCloseTo(edgeMid, 1); + }); + + it("radarSector scales to any radius (bigger interactive hit zones)", () => { + const pts = radarSector(center, radius + 17, 3) + .split(" ") + .map((p) => p.split(",").map(Number)); + // axis 3 = DRI, straight down + expect(pts[2][0]).toBeCloseTo(center, 1); + expect(pts[2][1]).toBeCloseTo(center + radius + 17, 1); + }); +}); diff --git a/tests/share.test.ts b/tests/share.test.ts index 7f43d53..794cec1 100644 --- a/tests/share.test.ts +++ b/tests/share.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import type { Card } from "@/lib/scoring/types"; -import { cardUrl, intentUrl, nativeSharePayload, shareMessage, shareText } from "@/lib/share"; +import { + cardUrl, + duelIntentUrl, + duelSharePayload, + duelShareMessage, + duelUrl, + intentUrl, + nativeSharePayload, + shareMessage, + shareText, +} from "@/lib/share"; // We test the share DECISIONS: correct platform endpoints, well-formed encoded // URLs, stable per-login text, brag-led message. Not the React wiring. @@ -90,3 +100,42 @@ describe("share service", () => { expect(shareMessage(card())).toContain(shareText(card())); }); }); + +describe("duel share service", () => { + it("builds the canonical duel URL from both corners", () => { + expect(duelUrl("younesfdj", "torvalds")).toBe("https://gitfut.com/younesfdj/vs/torvalds"); + }); + + it("message is deterministic per matchup, @-mentions the opponent, and never spoils the score", () => { + const a = duelShareMessage("younesfdj", "torvalds"); + expect(a).toBe(duelShareMessage("younesfdj", "torvalds")); + expect(a).toContain("@torvalds"); + // Score-free by design — the poster and page never reveal the result. + expect(a).not.toMatch(/\d+\s*[–-]\s*\d+/); + }); + + it("swapping corners can change the line (matchup-seeded, not opponent-only)", () => { + const lines = new Set([ + duelShareMessage("a", "b"), + duelShareMessage("b", "a"), + duelShareMessage("c", "d"), + duelShareMessage("e", "f"), + ]); + expect(lines.size).toBeGreaterThan(1); + }); + + it("X intent uses /intent/tweet (NOT /intent/post) with the duel url + hashtag", () => { + const u = duelIntentUrl("younesfdj", "torvalds"); + expect(u).toContain("https://twitter.com/intent/tweet?"); + expect(u).not.toContain("/intent/post"); + expect(u).toContain("hashtags=GitFut"); + expect(u).toContain(encodeURIComponent("https://gitfut.com/younesfdj/vs/torvalds")); + }); + + it("native payload carries the title, the message, and the duel url", () => { + const p = duelSharePayload("younesfdj", "torvalds"); + expect(p.title).toBe("GitFut Duel"); + expect(p.url).toBe("https://gitfut.com/younesfdj/vs/torvalds"); + expect(p.text).toBe(duelShareMessage("younesfdj", "torvalds")); + }); +}); diff --git a/tests/vsBurst.test.ts b/tests/vsBurst.test.ts new file mode 100644 index 0000000..918f6b6 --- /dev/null +++ b/tests/vsBurst.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + PARTICLES, + S_PATH, + V_PATH, + VS_H, + VS_W, + sliver, + vsBurstBox, +} from "@/lib/vsBurst"; + +// We test the pure GEOMETRY the VS burst encodes — the lightning sliver, the +// letter paths, the deterministic particle spray, and the size→box mapping. +// The SVG dressing (fills, opacities) is the component's job. This geometry is +// rendered by both the live DuelView and the Satori OG poster, so it must stay +// byte-stable and framework-free. + +const pts = (poly: string) => poly.split(" ").map((p) => p.split(",").map(Number)); +const dist = (a: number[], b: number[]) => Math.hypot(a[0] - b[0], a[1] - b[1]); + +describe("sliver", () => { + // Fixed tips from the design grid: bottom-left → top-right. + const T1 = [14, 158]; + const T2 = [106, 12]; + const CENTER = [(T1[0] + T2[0]) / 2, (T1[1] + T2[1]) / 2]; // 60, 85 + + it("is a 4-point kite: both tips sharp, a mid point either side", () => { + const p = pts(sliver(7)); + expect(p).toHaveLength(4); + expect(p[0]).toEqual(T1); + expect(p[2]).toEqual(T2); + }); + + it("collapses to a zero-width spike at the centre when half-width is 0", () => { + const p = pts(sliver(0)); + expect(p[1]).toEqual(CENTER); + expect(p[3]).toEqual(CENTER); + }); + + it("mirrors the two flanks about the centre and widens with half-width", () => { + const p = pts(sliver(7)); + // p1 and p3 are reflections of each other through the centre. + expect(p[1][0] + p[3][0]).toBeCloseTo(2 * CENTER[0], 5); + expect(p[1][1] + p[3][1]).toBeCloseTo(2 * CENTER[1], 5); + // a wider sliver pushes the flank further from the tip axis. + expect(dist(pts(sliver(9))[1], CENTER)).toBeGreaterThan( + dist(pts(sliver(3))[1], CENTER), + ); + }); + + it("rounds coordinates to 2 decimals (deterministic across renders)", () => { + for (const c of pts(sliver(3.8)).flat()) { + expect(Number.isInteger(Math.round(c * 100))).toBe(true); + expect(c).toBe(Math.round(c * 100) / 100); + } + }); +}); + +describe("letter paths", () => { + const anchors = (d: string) => d.match(/[ML]/g) ?? []; + + it("V is a closed 7-point polygon path", () => { + expect(V_PATH.startsWith("M")).toBe(true); + expect(V_PATH.endsWith("Z")).toBe(true); + expect(anchors(V_PATH)).toHaveLength(7); // one M + six L, from V_PTS + }); + + it("S is a closed 12-point polygon path", () => { + expect(S_PATH.startsWith("M")).toBe(true); + expect(S_PATH.endsWith("Z")).toBe(true); + expect(anchors(S_PATH)).toHaveLength(12); // one M + eleven L, from S_PTS + }); +}); + +describe("PARTICLES", () => { + it("is a deterministic spray of 26 particles", () => { + expect(PARTICLES).toHaveLength(26); + }); + + it("marks every third particle bright and keeps values in range", () => { + PARTICLES.forEach((p, i) => { + expect(p.bright).toBe(i % 3 === 0); + expect(p.r).toBeGreaterThan(0); + expect(p.o).toBeGreaterThanOrEqual(0.35); + expect(p.o).toBeLessThan(0.8); + // clustered near the two tips, so within the design grid + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.x).toBeLessThanOrEqual(VS_W); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.y).toBeLessThanOrEqual(VS_H); + }); + }); + + it("rounds x/y/r to 2 decimals", () => { + for (const p of PARTICLES) { + for (const c of [p.x, p.y, p.r]) { + expect(c).toBe(Math.round(c * 100) / 100); + } + } + }); +}); + +describe("vsBurstBox", () => { + it("derives width from the design aspect, height = size", () => { + // at the native height the box is exactly the design grid + expect(vsBurstBox(VS_H)).toEqual({ w: VS_W, h: VS_H }); + // the header (104) and the poster (264) sizes used in the app + expect(vsBurstBox(104)).toEqual({ w: Math.round((104 * VS_W) / VS_H), h: 104 }); + expect(vsBurstBox(264)).toEqual({ w: Math.round((264 * VS_W) / VS_H), h: 264 }); + }); +});