From 05097799b6a912461a2790e271ad82a6b04c63db Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Sun, 5 Jul 2026 23:30:29 +0100 Subject: [PATCH 1/6] feat(duel): user-vs-user card duels at //vs/ feat(theme): blue-black midnight palette --- .../vs/[opponent]/opengraph-image.tsx | 233 ++++++ app/[username]/vs/[opponent]/page.tsx | 131 ++++ app/api/card-image/[username]/route.tsx | 2 +- app/global-error.tsx | 4 +- app/globals.css | 92 ++- app/layout.tsx | 2 +- app/opengraph-image.tsx | 2 +- app/u/[username]/opengraph-image.tsx | 4 +- app/u/[username]/page.tsx | 15 +- components/BrandIcons.tsx | 18 + components/CardActions.tsx | 92 +-- components/DuelButton.tsx | 138 ++++ components/DuelView.tsx | 677 ++++++++++++++++++ components/ResultView.tsx | 28 +- components/ScoutReport.tsx | 2 +- components/StatRadar.tsx | 183 +++++ components/TiltCard.tsx | 67 ++ components/VsBurst.tsx | 90 +++ components/finishTheme.ts | 32 + hooks/useReveal.ts | 47 +- hooks/useShareActions.ts | 61 ++ lib/duel.ts | 145 ++++ lib/format.ts | 5 + lib/og/renderCard.tsx | 9 +- lib/radar.ts | 84 +++ lib/reveal.ts | 60 +- lib/scoring/attributes.ts | 33 +- lib/scoring/constants.ts | 11 + lib/scout.ts | 18 +- lib/share.ts | 58 +- lib/vsBurst.ts | 118 +++ tests/duel.test.ts | 239 +++++++ tests/duelThemes.test.ts | 50 ++ tests/radar.test.ts | 104 +++ tests/share.test.ts | 51 +- tests/vsBurst.test.ts | 111 +++ 36 files changed, 2870 insertions(+), 146 deletions(-) create mode 100644 app/[username]/vs/[opponent]/opengraph-image.tsx create mode 100644 app/[username]/vs/[opponent]/page.tsx create mode 100644 components/BrandIcons.tsx create mode 100644 components/DuelButton.tsx create mode 100644 components/DuelView.tsx create mode 100644 components/StatRadar.tsx create mode 100644 components/TiltCard.tsx create mode 100644 components/VsBurst.tsx create mode 100644 hooks/useShareActions.ts create mode 100644 lib/duel.ts create mode 100644 lib/radar.ts create mode 100644 lib/vsBurst.ts create mode 100644 tests/duel.test.ts create mode 100644 tests/duelThemes.test.ts create mode 100644 tests/radar.test.ts create mode 100644 tests/vsBurst.test.ts diff --git a/app/[username]/vs/[opponent]/opengraph-image.tsx b/app/[username]/vs/[opponent]/opengraph-image.tsx new file mode 100644 index 0000000..a404908 --- /dev/null +++ b/app/[username]/vs/[opponent]/opengraph-image.tsx @@ -0,0 +1,233 @@ +import { ImageResponse } from "next/og"; +import { after } from "next/server"; +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 VsBurst from "@/components/VsBurst"; +import type { Card } from "@/lib/scoring/types"; + +export const runtime = "nodejs"; +export const alt = + "GitFut Scout Duel fixture poster — two player cards facing off"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +const CARD_W = 300; +const TILT = 7; // degrees each card leans toward the centre line + +const CACHE = { + "Cache-Control": + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", +}; + +async function tryCard(username: string): Promise { + try { + const card = await scoutCard(username); + return { ...card, country: pickFlag(null, card.country) ?? "" }; + } catch { + return null; + } +} + +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 = await loadCardFonts(); + return new ImageResponse( +
+
+ SCOUT DUEL +
+
+ @{username} vs @{opponent} +
+
+ watch the duel at +
+
+ gitfut.com +
+
, + { ...size, fonts, 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] = await Promise.all([ + loadCardAssets(a, CARD_W), + loadCardAssets(b, CARD_W, false), + ]); + + return new ImageResponse( +
+ {/* halfway line under the VS */} +
+ + {/* challenger corner — leaning into the centre line */} +
+ {cardTree(a, aAssets, CARD_W)} +
+ + {/* spacer holding the centre gap; the VS overlay below paints over it + (Satori has no z-index — paint order is document order, so the + centre line is rendered LAST to sit above both leaning cards) */} +
+ + {/* opponent corner — mirrored lean */} +
+ {cardTree(b, bAssets, CARD_W)} +
+ + {/* the centre line: eyebrow, VS, the address — painted last, on top */} +
+
+ SCOUT DUEL +
+ {/* the VS burst — same component as the page header (Satori-safe) */} +
+ +
+
+ who takes it? +
+
+ gitfut.com +
+
+
, + { ...size, fonts: aAssets.fonts, headers: CACHE }, + ); +} diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx new file mode 100644 index 0000000..3437496 --- /dev/null +++ b/app/[username]/vs/[opponent]/page.tsx @@ -0,0 +1,131 @@ +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) { + 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/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..bd904c1 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% { 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/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..1d79066 100644 --- a/components/CardActions.tsx +++ b/components/CardActions.tsx @@ -1,11 +1,13 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { toBlob, toPng } from "html-to-image"; import { Check, 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,22 +16,6 @@ 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 ( - - - - ); -} - -function LinkedInLogo({ size = 16 }: { size?: number }) { - return ( - - - - ); -} - interface ExportAction { id: string; label: string; @@ -116,21 +102,6 @@ export default function CardActions({ 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); - }, []); // 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, @@ -143,6 +114,29 @@ export default function CardActions({ const shareCard = card.country && card.country !== canonicalCountry ? card : { ...card, country: "" }; + // Share-row 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, linkCopied } = 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] })) { + return { ...payload, files: [file] }; + } + } + } + return payload; + }, + getIntentUrl: () => intentUrl("x", shareCard), + getCopyUrl: () => cardUrl(shareCard), + }); + const runExport = async (a: ExportAction) => { const node = targetRef.current; if (!node || busy) return; @@ -161,29 +155,6 @@ export default function CardActions({ } }; - // 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 { - 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; - } - } - } - await navigator.share(payload); - } catch (e) { - if (e instanceof Error && e.name === "AbortError") return; // user dismissed - window.open(intentUrl("x", shareCard), "_blank", "noopener,noreferrer"); - } - }; - // 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. @@ -243,17 +214,6 @@ export default function CardActions({ } }; - 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 */ - } - }; - return (
{/* primary — native share sheet (shown only where it's supported, so it diff --git a/components/DuelButton.tsx b/components/DuelButton.tsx new file mode 100644 index 0000000..4654d17 --- /dev/null +++ b/components/DuelButton.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Swords } from "lucide-react"; + +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 formRef = useRef(null); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [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)}`, + ), + ); + }; + + // Shared strip chrome: pitch-dark panel, brand edge, centre-circle motif. + const strip = + "relative w-full overflow-hidden rounded-xl border border-brand/40 bg-[#0a1710] shadow-[inset_0_1px_0_rgba(57,211,83,.18),0_8px_24px_-10px_rgba(57,211,83,.35)]"; + + const centreCircle = ( + + {/* halfway line + centre circle — the fixture happens on a pitch */} + + + + + ); + + if (!open) { + return ( + + ); + } + + return ( +
{ + if (isPending) return; + setTimeout(() => { + if (!formRef.current?.contains(document.activeElement)) setOpen(false); + }, 0); + }} + className={`${strip} flex h-[50px] items-center gap-[10px] px-[12px]`} + > + {centreCircle} + + @{login} + + + VS + + setOpponent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setOpen(false); + }} + placeholder="their username" + autoComplete="off" + spellCheck={false} + aria-label="Opponent's GitHub username" + className="font-mono relative 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 focus:border-brand focus:shadow-[0_0_0_3px_rgba(57,211,83,.15)]" + /> + +
+ ); +} 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/ResultView.tsx b/components/ResultView.tsx index 812050e..2d3225d 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 HowItWorksModal from "./HowItWorksModal"; 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"; @@ -32,13 +33,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, @@ -66,16 +60,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"; @@ -89,7 +78,7 @@ export default function ResultView({ aria-hidden className="pointer-events-none fixed inset-0 -z-10" style={{ - background: `radial-gradient(120% 80% at 50% -10%, ${theme.glow}, transparent 55%), #0d1117`, + background: `radial-gradient(120% 80% at 50% -10%, ${theme.glow}, transparent 55%), #02001e`, opacity: ignited ? 0.9 : 0.4, transition: "opacity 1s ease", }} @@ -176,13 +165,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..887d14e --- /dev/null +++ b/components/VsBurst.tsx @@ -0,0 +1,90 @@ +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. +const FILL = "#26a641"; +const RIM = "#56e06b"; +const GLOW = "#39d353"; +const CORE = "#eaffe8"; + +export default function VsBurst({ size }: { 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/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/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..71c3e5f --- /dev/null +++ b/hooks/useShareActions.ts @@ -0,0 +1,61 @@ +"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: the deferred "is Web Share available" probe, the native-share call +// with its AbortError → intent-window fallback, and copy-to-clipboard with a +// timed "copied" flag. Callers pass lazy getters so a payload can be built at +// gesture time (e.g. CardActions attaching the freshly rendered card image). +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 true so supported browsers render the CTA with no layout shift; the + // effect hides it where Web Share is missing so it never degrades into a + // duplicate X-share. + const [canNativeShare, setCanNativeShare] = useState(true); + const copiedTimer = useRef | null>(null); + + useEffect(() => { + // 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 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"); + } + }; + + const copyLink = async () => { + try { + await navigator.clipboard.writeText(getCopyUrl()); + setLinkCopied(true); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setLinkCopied(false), 1600); + } catch { + /* clipboard unavailable — silent */ + } + }; + + 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..b55e953 --- /dev/null +++ b/lib/vsBurst.ts @@ -0,0 +1,118 @@ +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]/opengraph-image.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 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; + +// A sliver of the given half-width: both tips sharp, widest at the centre. +export const sliver = (hw: number): string => + [ + `${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(" "); + +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, + }; +}); + +// 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/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 }); + }); +}); From a752907fdaabb5f701015564f06e78bd43aefdf1 Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Mon, 6 Jul 2026 02:17:10 +0100 Subject: [PATCH 2/6] feat(card): split-button export menu --- components/CardActions.tsx | 452 ++++++++++++++++++++----------------- components/ResultView.tsx | 6 +- hooks/useShareActions.ts | 29 +-- 3 files changed, 263 insertions(+), 224 deletions(-) diff --git a/components/CardActions.tsx b/components/CardActions.tsx index 1d79066..1088b58 100644 --- a/components/CardActions.tsx +++ b/components/CardActions.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +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"; @@ -16,73 +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; -interface ExportAction { - id: string; - label: string; - title: string; - done: string; - icon: typeof Download; - run: (node: HTMLElement, card: Card) => Promise; -} +// 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"; -// 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 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" }, +}; + +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]"; -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 = ""; }, }); @@ -95,30 +58,49 @@ 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 [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: "" }; - // Share-row gestures (native sheet + copy link) — shared with DuelView. The + // 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, linkCopied } = useShareActions({ + const { canNativeShare, nativeShare, copyLink } = useShareActions({ getSharePayload: async () => { const node = targetRef.current; const payload = nativeSharePayload(shareCard); @@ -137,33 +119,111 @@ export default function CardActions({ getCopyUrl: () => cardUrl(shareCard), }); - const runExport = async (a: ExportAction) => { - const node = targetRef.current; - if (!node || busy) return; - setBusy(a.id); + // 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 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); + await run(); + finish(id); } catch (e) { - console.error("[gitfut] card export failed:", e); - setError(`${a.label} failed: ${e instanceof Error ? e.message : String(e)}`); + 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"); @@ -171,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; @@ -191,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"); @@ -203,21 +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"); + }); + + // 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/ResultView.tsx b/components/ResultView.tsx index 2d3225d..69aebd4 100644 --- a/components/ResultView.tsx +++ b/components/ResultView.tsx @@ -73,7 +73,9 @@ 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. */}
diff --git a/hooks/useShareActions.ts b/hooks/useShareActions.ts index 71c3e5f..497ff6d 100644 --- a/hooks/useShareActions.ts +++ b/hooks/useShareActions.ts @@ -4,10 +4,7 @@ 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: the deferred "is Web Share available" probe, the native-share call -// with its AbortError → intent-window fallback, and copy-to-clipboard with a -// timed "copied" flag. Callers pass lazy getters so a payload can be built at -// gesture time (e.g. CardActions attaching the freshly rendered card image). +// gestures. export function useShareActions({ getSharePayload, getIntentUrl, @@ -21,19 +18,19 @@ export function useShareActions({ getCopyUrl: () => string; }) { const [linkCopied, setLinkCopied] = useState(false); - // Default true so supported browsers render the CTA with no layout shift; the - // effect hides it where Web Share is missing so it never degrades into a - // duplicate X-share. - const [canNativeShare, setCanNativeShare] = useState(true); + // 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(() => { - // 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); + if (!supported) return; + // Deferred (not synchronous in the effect) so it can't cascade renders. + const t = setTimeout(() => setCanNativeShare(true), 0); return () => clearTimeout(t); }, []); @@ -46,14 +43,18 @@ export function useShareActions({ } }; - const copyLink = async () => { + // 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 { - /* clipboard unavailable — silent */ + return false; // clipboard unavailable } }; From 9f53d479c62453fc88db57eb5ec2e1bd60d0d170 Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Mon, 6 Jul 2026 02:45:56 +0100 Subject: [PATCH 3/6] fix(og): fix duel og image issue --- app/[username]/vs/[opponent]/page.tsx | 12 +- .../route.tsx} | 27 +- package-lock.json | 970 ++++-------------- package.json | 2 +- 4 files changed, 212 insertions(+), 799 deletions(-) rename app/[username]/vs/[opponent]/{opengraph-image.tsx => poster.png/route.tsx} (88%) diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx index 3437496..dd0d2a1 100644 --- a/app/[username]/vs/[opponent]/page.tsx +++ b/app/[username]/vs/[opponent]/page.tsx @@ -27,13 +27,23 @@ 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 poster is an explicit image route (poster.png/route.tsx), NOT + // a file-convention opengraph-image: Turbopack dev doesn't register that + // convention under two dynamic segments, so it must be wired here. + const poster = { + url: `/${a.card.login}/vs/${b.card.login}/poster.png`, + width: 1200, + height: 630, + alt: "GitFut Scout Duel fixture poster — two player cards facing off", + }; 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" }, + openGraph: { images: [poster] }, + twitter: { card: "summary_large_image", images: [poster] }, }; } return { diff --git a/app/[username]/vs/[opponent]/opengraph-image.tsx b/app/[username]/vs/[opponent]/poster.png/route.tsx similarity index 88% rename from app/[username]/vs/[opponent]/opengraph-image.tsx rename to app/[username]/vs/[opponent]/poster.png/route.tsx index a404908..5b19fbc 100644 --- a/app/[username]/vs/[opponent]/opengraph-image.tsx +++ b/app/[username]/vs/[opponent]/poster.png/route.tsx @@ -9,11 +9,17 @@ import { resolveResultTheme } from "@/components/finishTheme"; import VsBurst from "@/components/VsBurst"; import type { Card } from "@/lib/scoring/types"; +// The Fixture poster, served as an explicit image route (//vs//poster.png) +// and wired into the duel page's metadata by generateMetadata. It is NOT a +// file-convention opengraph-image on purpose: Turbopack dev doesn't register +// metadata-image conventions under two dynamic segments (the single-segment +// card OGs register fine), so the poster 404'd in dev. An explicit route + +// explicit openGraph.images works identically everywhere — same pattern as +// /api/card-image. + export const runtime = "nodejs"; -export const alt = - "GitFut Scout Duel fixture poster — two player cards facing off"; -export const size = { width: 1200, height: 630 }; -export const contentType = "image/png"; + +const POSTER_SIZE = { width: 1200, height: 630 }; const CARD_W = 300; const TILT = 7; // degrees each card leans toward the centre line @@ -32,11 +38,10 @@ async function tryCard(username: string): Promise { } } -export default async function Image({ - params, -}: { - params: Promise<{ username: string; opponent: string }>; -}) { +export async function GET( + _req: Request, + { params }: { params: Promise<{ username: string; opponent: string }> }, +) { const { username, opponent } = await params; const [a, b] = await Promise.all([tryCard(username), tryCard(opponent)]); @@ -104,7 +109,7 @@ export default async function Image({ gitfut.com
, - { ...size, fonts, headers: CACHE }, + { ...POSTER_SIZE, fonts, headers: CACHE }, ); } @@ -228,6 +233,6 @@ export default async function Image({ , - { ...size, fonts: aAssets.fonts, headers: CACHE }, + { ...POSTER_SIZE, fonts: aAssets.fonts, headers: CACHE }, ); } 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", From 00dfb17f8830e6db1ebad29b635c533c8ff4c072 Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Mon, 6 Jul 2026 03:54:18 +0100 Subject: [PATCH 4/6] feat(og): improve og image for duel page --- .../vs/[opponent]/opengraph-image.tsx | 281 ++++++++++++++++++ app/[username]/vs/[opponent]/page.tsx | 15 +- .../vs/[opponent]/poster.png/route.tsx | 238 --------------- app/fonts/BebasNeue-Regular.ttf | Bin 0 -> 61400 bytes components/VsBurst.tsx | 13 +- lib/vsBurst.ts | 61 +++- 6 files changed, 345 insertions(+), 263 deletions(-) create mode 100644 app/[username]/vs/[opponent]/opengraph-image.tsx delete mode 100644 app/[username]/vs/[opponent]/poster.png/route.tsx create mode 100644 app/fonts/BebasNeue-Regular.ttf 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 index dd0d2a1..4f45375 100644 --- a/app/[username]/vs/[opponent]/page.tsx +++ b/app/[username]/vs/[opponent]/page.tsx @@ -27,23 +27,16 @@ 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 poster is an explicit image route (poster.png/route.tsx), NOT - // a file-convention opengraph-image: Turbopack dev doesn't register that - // convention under two dynamic segments, so it must be wired here. - const poster = { - url: `/${a.card.login}/vs/${b.card.login}/poster.png`, - width: 1200, - height: 630, - alt: "GitFut Scout Duel fixture poster — two player cards facing off", - }; + // 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}` }, - openGraph: { images: [poster] }, - twitter: { card: "summary_large_image", images: [poster] }, + twitter: { card: "summary_large_image" }, }; } return { diff --git a/app/[username]/vs/[opponent]/poster.png/route.tsx b/app/[username]/vs/[opponent]/poster.png/route.tsx deleted file mode 100644 index 5b19fbc..0000000 --- a/app/[username]/vs/[opponent]/poster.png/route.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { ImageResponse } from "next/og"; -import { after } from "next/server"; -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 VsBurst from "@/components/VsBurst"; -import type { Card } from "@/lib/scoring/types"; - -// The Fixture poster, served as an explicit image route (/
/vs//poster.png) -// and wired into the duel page's metadata by generateMetadata. It is NOT a -// file-convention opengraph-image on purpose: Turbopack dev doesn't register -// metadata-image conventions under two dynamic segments (the single-segment -// card OGs register fine), so the poster 404'd in dev. An explicit route + -// explicit openGraph.images works identically everywhere — same pattern as -// /api/card-image. - -export const runtime = "nodejs"; - -const POSTER_SIZE = { width: 1200, height: 630 }; - -const CARD_W = 300; -const TILT = 7; // degrees each card leans toward the centre line - -const CACHE = { - "Cache-Control": - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", -}; - -async function tryCard(username: string): Promise { - try { - const card = await scoutCard(username); - return { ...card, country: pickFlag(null, card.country) ?? "" }; - } catch { - return null; - } -} - -export async function GET( - _req: Request, - { 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 = await loadCardFonts(); - return new ImageResponse( -
-
- SCOUT DUEL -
-
- @{username} vs @{opponent} -
-
- watch the duel at -
-
- gitfut.com -
-
, - { ...POSTER_SIZE, fonts, 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] = await Promise.all([ - loadCardAssets(a, CARD_W), - loadCardAssets(b, CARD_W, false), - ]); - - return new ImageResponse( -
- {/* halfway line under the VS */} -
- - {/* challenger corner — leaning into the centre line */} -
- {cardTree(a, aAssets, CARD_W)} -
- - {/* spacer holding the centre gap; the VS overlay below paints over it - (Satori has no z-index — paint order is document order, so the - centre line is rendered LAST to sit above both leaning cards) */} -
- - {/* opponent corner — mirrored lean */} -
- {cardTree(b, bAssets, CARD_W)} -
- - {/* the centre line: eyebrow, VS, the address — painted last, on top */} -
-
- SCOUT DUEL -
- {/* the VS burst — same component as the page header (Satori-safe) */} -
- -
-
- who takes it? -
-
- gitfut.com -
-
-
, - { ...POSTER_SIZE, fonts: aAssets.fonts, headers: CACHE }, - ); -} diff --git a/app/fonts/BebasNeue-Regular.ttf b/app/fonts/BebasNeue-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c328c6e08b20a20a1de47d823e007ee73812a438 GIT binary patch literal 61400 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 literal 0 HcmV?d00001 diff --git a/components/VsBurst.tsx b/components/VsBurst.tsx index 887d14e..e70826a 100644 --- a/components/VsBurst.tsx +++ b/components/VsBurst.tsx @@ -9,10 +9,15 @@ import { } from "@/lib/vsBurst"; // Brand greens (globals.css): letters brand-mid, rim brand-hi, glow brand. -const FILL = "#26a641"; -const RIM = "#56e06b"; -const GLOW = "#39d353"; -const CORE = "#eaffe8"; +// 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 }: { size: number }) { const { w, h } = vsBurstBox(size); diff --git a/lib/vsBurst.ts b/lib/vsBurst.ts index b55e953..6eb158c 100644 --- a/lib/vsBurst.ts +++ b/lib/vsBurst.ts @@ -3,7 +3,7 @@ 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]/opengraph-image.tsx), so it must stay pure. +// (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]; @@ -73,22 +73,32 @@ 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 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; -// A sliver of the given half-width: both tips sharp, widest at the centre. -export const sliver = (hw: number): string => - [ - `${T1[0]},${T1[1]}`, - `${round2(CX + NX * hw)},${round2(CY + NY * hw)}`, - `${T2[0]},${T2[1]}`, - `${round2(CX - NX * hw)},${round2(CY - NY * hw)}`, +// 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; @@ -112,6 +122,37 @@ export const PARTICLES: Particle[] = Array.from({ length: 26 }, (_, i) => { }; }); +// 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 }; From f270082f60774fff0de837cc76813bf371fdfbdd Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Tue, 7 Jul 2026 01:14:41 +0100 Subject: [PATCH 5/6] feat(duel): refactor duel button ui --- app/globals.css | 26 ++++ components/DuelButton.tsx | 246 +++++++++++++++++++++++--------------- 2 files changed, 174 insertions(+), 98 deletions(-) diff --git a/app/globals.css b/app/globals.css index bd904c1..d07707b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -297,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/components/DuelButton.tsx b/components/DuelButton.tsx index 4654d17..42bc3a0 100644 --- a/components/DuelButton.tsx +++ b/components/DuelButton.tsx @@ -2,7 +2,14 @@ import { useEffect, useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { ArrowRight, Swords } from "lucide-react"; + +// 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(); @@ -10,10 +17,27 @@ export default function DuelButton({ login }: { login: string }) { const [opponent, setOpponent] = useState(""); const [isPending, startTransition] = useTransition(); const inputRef = useRef(null); - const formRef = 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?.focus(); + 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) => { @@ -27,112 +51,138 @@ export default function DuelButton({ login }: { login: string }) { ); }; - // Shared strip chrome: pitch-dark panel, brand edge, centre-circle motif. - const strip = - "relative w-full overflow-hidden rounded-xl border border-brand/40 bg-[#0a1710] shadow-[inset_0_1px_0_rgba(57,211,83,.18),0_8px_24px_-10px_rgba(57,211,83,.35)]"; + // 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); + }; - const centreCircle = ( - - {/* halfway line + centre circle — the fixture happens on a pitch */} - - - - - ); + // 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"; - if (!open) { - return ( + return ( +
- ); - } - return ( -
{ - if (isPending) return; - setTimeout(() => { - if (!formRef.current?.contains(document.activeElement)) setOpen(false); - }, 0); - }} - className={`${strip} flex h-[50px] items-center gap-[10px] px-[12px]`} - > - {centreCircle} - - @{login} - - - VS - - setOpponent(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setOpen(false); - }} - placeholder="their username" - autoComplete="off" - spellCheck={false} - aria-label="Opponent's GitHub username" - className="font-mono relative 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 focus:border-brand focus:shadow-[0_0_0_3px_rgba(57,211,83,.15)]" - /> - -
+ {/* 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]" + /> + +
+ )} +
); } From 7b78368c0050ebe3a7a3b26c902226dc4296f621 Mon Sep 17 00:00:00 2001 From: Younesfdj Date: Tue, 7 Jul 2026 14:36:23 +0100 Subject: [PATCH 6/6] feat(whats-new): add whats new modal, configurable with one config file --- components/AppShell.tsx | 5 + components/HowItWorksModal.tsx | 4 +- components/VsBurst.tsx | 2 +- components/WhatsNew.tsx | 197 +++++++++++++++++++++++++++++++++ config/whatsNew.ts | 24 ++++ 5 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 components/WhatsNew.tsx create mode 100644 config/whatsNew.ts 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/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/VsBurst.tsx b/components/VsBurst.tsx index e70826a..58c8a52 100644 --- a/components/VsBurst.tsx +++ b/components/VsBurst.tsx @@ -19,7 +19,7 @@ export const VS_PALETTE = { } as const; const { fill: FILL, rim: RIM, glow: GLOW, core: CORE } = VS_PALETTE; -export default function VsBurst({ size }: { size: number }) { +export default function VsBurst({ size = 10 }: { size: number }) { const { w, h } = vsBurstBox(size); return ( 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/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, + }, +];