ghassan 3e18bd2150 Match videos: composed VS-card OG preview + richer share metadata
Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.

- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
  1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
  through a small wrapper (bin/browsershot-node.sh) that raises the fd
  limit and reads its full env, since PHP-FPM's stripped env otherwise
  makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
  silently drops OG images above roughly 300 KB, which is why music
  thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
  picture is guaranteed to exist before the user copies the share URL;
  edits regenerate and old stamped variants are cleaned. The og:image
  URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
  the video page HTML inline instead of a 302 — some crawlers don't
  follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
  emoji + fighter names (blue vs red), event, weight, match#, court,
  referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
  (:has(.dropdown-menu.show) promotes the active card above siblings
  and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
  .vs-mini-static class so the OG render always captures the final
  frame instead of a random mid-animation moment.

Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-11 12:34:40 +03:00

605 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{-- ══════════════════════════════════════════════════════════════════════
VS MINI the arena intro card rendered INSIDE a video-card thumbnail.
No countdown / no skip button; identical layout to the full-page VS
intro (fixed 1920×1080 stage, transform-scaled to fit the thumb).
Behavior:
- Sits at z-index 1 (above the still image, BELOW the hover video
which uses z-index 2 + .active { opacity: 1 }).
- On mouseleave from the thumb, animations restart so the intro
plays again the next time the user's mouse is off the card.
════════════════════════════════════════════════════════════════════════ --}}
@php
$vsm_hd = $video->sportsMatch?->headerData();
$vsm_red = $vsm_hd['red'] ?? null;
$vsm_blue = $vsm_hd['blue'] ?? null;
$vsm_show = $vsm_hd && (($vsm_red['name'] ?? null) || ($vsm_blue['name'] ?? null));
@endphp
@if ($vsm_show)
@php
$vsm_flag = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
$vsm_cname = fn (?string $c) => \App\Data\Countries::name($c);
$vsm_img = fn(?string $p) => $p ? route('media.thumbnail', $p) : null;
$vsm_str = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
// Every placeholder from the design is always in the DOM; empty ones
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
// CSS below. Same treatment as the full-page VS.
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
$vsm_stage = $vsm_str($vsm_hd['stage'] ?? '');
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
$vsm_ref = $vsm_str($vsm_hd['referee']['name'] ?? '');
// Per-fighter chips (Record / Rank / Stats) — fields aren't in
// headerData() today, so fall back to raw participants JSON.
$vsm_parts = $video->sportsMatch?->participants ?? [];
$vsm_redRecord = $vsm_str($vsm_parts['p2_record'] ?? '');
$vsm_redRank = $vsm_str($vsm_parts['p2_rank'] ?? '');
$vsm_redStats = $vsm_str($vsm_parts['p2_stats'] ?? '');
$vsm_blueRecord = $vsm_str($vsm_parts['p1_record'] ?? '');
$vsm_blueRank = $vsm_str($vsm_parts['p1_rank'] ?? '');
$vsm_blueStats = $vsm_str($vsm_parts['p1_stats'] ?? '');
$vsm_redName = $vsm_str($vsm_red['name'] ?? '');
$vsm_blueName = $vsm_str($vsm_blue['name'] ?? '');
$vsm_redClub = $vsm_str($vsm_red['club'] ?? '');
$vsm_blueClub = $vsm_str($vsm_blue['club'] ?? '');
$vsm_redFlag = $vsm_str($vsm_red['flag'] ?? '');
$vsm_blueFlag = $vsm_str($vsm_blue['flag'] ?? '');
$vsm_redLogo = $vsm_img($vsm_red['club_logo'] ?? null);
$vsm_blueLogo = $vsm_img($vsm_blue['club_logo'] ?? null);
@endphp
<div class="vs-mini vs-mini-run" data-vs-mini>
<div class="vs-mini-stage">
{{-- RED panel --}}
<div class="vs-mini-panel vs-mini-panel-red">
<div class="vs-mini-photo"
@if($vsm_img($vsm_red['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_red['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-red"></div>
<div class="vs-mini-fade"></div>
</div>
{{-- BLUE panel --}}
<div class="vs-mini-panel vs-mini-panel-blue">
<div class="vs-mini-photo vs-mini-photo-rev"
@if($vsm_img($vsm_blue['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_blue['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-blue"></div>
<div class="vs-mini-fade"></div>
</div>
<div class="vs-mini-divider"></div>
{{-- RED identity (left) every placeholder always in DOM ── --}}
<div class="vs-mini-info vs-mini-info-red">
<div class="vs-mini-tag vs-mini-tag-red">AKA · RED</div>
<div class="vs-mini-flag-row">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_redFlag) }} {{ $vsm_redFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-red vsm-nullable">{{ $vsm_redFlag !== '' ? strtoupper($vsm_cname($vsm_redFlag) ?? $vsm_redFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_redName }}</div>
<div class="vs-mini-club-row">
<div class="vs-mini-logo {{ $vsm_redLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_redLogo) style="background-image:url('{{ $vsm_redLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_redClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_redRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redStats }}</div>
</div>
</div>
{{-- BLUE identity (right) ── --}}
<div class="vs-mini-info vs-mini-info-blue">
<div class="vs-mini-tag vs-mini-tag-blue">AO · BLUE</div>
<div class="vs-mini-flag-row vs-mini-flag-row-r">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_blueFlag) }} {{ $vsm_blueFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-blue vsm-nullable">{{ $vsm_blueFlag !== '' ? strtoupper($vsm_cname($vsm_blueFlag) ?? $vsm_blueFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_blueName }}</div>
<div class="vs-mini-club-row vs-mini-club-row-r">
<div class="vs-mini-logo {{ $vsm_blueLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_blueLogo) style="background-image:url('{{ $vsm_blueLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_blueClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_blueRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueStats }}</div>
</div>
</div>
{{-- Top: event + stage + weight (all in DOM) --}}
<div class="vs-mini-top">
<div class="vs-mini-top-event vsm-nullable">{{ $vsm_event }}</div>
<div class="vs-mini-top-stage-row {{ $vsm_stage === '' ? 'vsm-nullable-hide' : '' }}">
<div class="vs-mini-top-line vs-mini-top-line-l"></div>
<div class="vs-mini-top-stage">{{ $vsm_stage }}</div>
<div class="vs-mini-top-line vs-mini-top-line-r"></div>
</div>
<div class="vs-mini-top-weight vsm-nullable">{{ $vsm_weight }}</div>
</div>
{{-- Center VS --}}
<div class="vs-mini-center">
<div class="vs-mini-word">VS
<div class="vs-mini-shine-wrap"><div class="vs-mini-shine"></div></div>
</div>
</div>
{{-- .vs-mini-flash removed the intro's full-thumb white flash at
t=1.5s was firing on every mouseleave-triggered replay, which
read as strobing on the left edge of the card. --}}
{{-- Bottom chips — Match / Court / Referee always in DOM; each hides
via data-vsm-empty and the flanking diamonds collapse via :has(). --}}
<div class="vs-mini-bottom">
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_matchNo === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Match</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_matchNo }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-mc"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_court === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Court</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_court }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-cr"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_ref === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Referee</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_ref }}</span>
</div>
</div>
</div>
</div>
@endif
@once
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
<style>
/* ══ VS MINI — arena intro inside a video-card thumbnail.
Same 1920×1080 canvas as the full-page overlay, transform-scaled
to fit the thumb. Every child in ABSOLUTE PIXELS on that canvas.
z-index 1: below the hover <video> (which we bump to 5 for match
cards), above the still <img>. ══ */
.yt-video-thumb .vs-mini {
position: absolute; inset: 0; z-index: 1;
background: #050507;
font-family: 'Barlow Condensed', sans-serif;
color: #e8e6e0;
overflow: hidden;
pointer-events: none;
--vs-mini-scale: 0.5;
--vs-mini-w: 1920px;
--vs-mini-h: 1080px;
}
/* When a match card is hovered, the VS mini EXPLODES outward — the
inverse of its implode entrance. Each part flies away in the direction
opposite to where it came from, clearing the stage for the video.
The whole overlay fades to fully hidden by the time the panels are
off-screen (~0.6s), so the video takes over cleanly. */
.yt-video-card:hover .yt-video-thumb .vs-mini {
animation: vsmFadeOut .55s .25s ease forwards;
}
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-red { animation: vsmExplodeL .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-blue { animation: vsmExplodeR .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-red { animation: vsmExplodeInfoL .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-blue { animation: vsmExplodeInfoR .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-top { animation: vsmExplodeTop .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-bottom { animation: vsmExplodeBot .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-center { animation: vsmExplodeVS .6s cubic-bezier(.34,1.56,.64,1) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-divider { animation: vsmDividerOut .35s ease forwards !important; }
/* Force the hover-preview video above the VS mini's stacking context.
The default rule sets video { z-index: 2 } but the VS mini itself
is z-index 1 + a stacking context, so we bump the video to be safe. */
.yt-video-thumb:has(.vs-mini) video { z-index: 5 !important; background: transparent !important; }
.vs-mini .vs-mini-stage {
position: absolute;
left: 50%; top: 50%;
width: var(--vs-mini-w); height: var(--vs-mini-h);
transform: translate(-50%, -50%) scale(var(--vs-mini-scale));
transform-origin: center center;
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
/* Hidden until JS's fit() has measured the parent and set the real
--vs-mini-scale. Without this, a card whose parent had 0 height at
registration time (grid still resolving on mobile) would paint the
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
thumb — clipping to just the center (looks like a broken card). */
visibility: hidden;
}
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
render every element in its final state. The intro relies on ~1.3s
of CSS animation-delays; on mobile it was fragile — anything that
interrupted timing (late layout resolve, ResizeObserver refires
when the browser toolbar hides/shows, portrait class toggles that
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
On mobile users are scrolling, not hovering to watch a preview,
so the intro adds no value — show the final composition instead. ══ */
/* Same "no-animation, final-state" treatment when the parent explicitly
requests it — used by the OG-image render route so Browsershot
screenshots the composed final frame regardless of what Chromium
thinks its hover capability is. */
.vs-mini-static,
.vs-mini-static .vs-mini-panel,
.vs-mini-static .vs-mini-info,
.vs-mini-static .vs-mini-top,
.vs-mini-static .vs-mini-center,
.vs-mini-static .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
.vs-mini-static .vs-mini-photo,
.vs-mini-static .vs-mini-word,
.vs-mini-static .vs-mini-shine { animation: none !important; }
@media (hover: none) {
.vs-mini,
.vs-mini .vs-mini-panel,
.vs-mini .vs-mini-info,
.vs-mini .vs-mini-top,
.vs-mini .vs-mini-center,
.vs-mini .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
/* Also skip the ambient photo drift + VS pulse/shine — they were
running fine, but there's no need to burn cycles on animation
loops for a scrolling grid on mobile. */
.vs-mini .vs-mini-photo,
.vs-mini .vs-mini-word,
.vs-mini .vs-mini-shine { animation: none !important; }
}
/* Animations only run while the .vs-mini-run class is set (removed +
re-added on mouseleave to force a replay from the intro). */
/* ── Panels ── */
.vs-mini .vs-mini-panel { position: absolute; overflow: hidden; opacity: 0; }
.vs-mini .vs-mini-panel-red {
inset: 0 auto 0 0; width: 56%;
background: oklch(0.28 0.09 25);
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
}
.vs-mini .vs-mini-panel-blue {
inset: 0 0 0 auto; width: 56%;
background: oklch(0.28 0.09 255);
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
}
.vs-mini.vs-mini-run .vs-mini-panel-red { animation: vsmPanelL .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-panel-blue { animation: vsmPanelR .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-photo {
position: absolute; inset: 0;
background-size: cover; background-position: center;
animation: vsmDrift 18s ease-in-out infinite;
}
.vs-mini .vs-mini-photo-rev { animation-direction: reverse; }
.vs-mini .vs-mini-scrim-red { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-scrim-blue { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-fade {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
}
/* ── Divider ── */
.vs-mini .vs-mini-divider {
position: absolute; top: -6%; bottom: -6%; left: 50%;
width: 3px; margin-left: -1.5px;
transform: rotate(10.15deg);
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
pointer-events: none; filter: blur(1px);
}
/* ── Fighter identity (fixed px on 1920×1080 canvas) ── */
.vs-mini .vs-mini-info {
position: absolute; z-index: 6; max-width: 44%;
display: flex; flex-direction: column; gap: 13px;
opacity: 0;
}
.vs-mini .vs-mini-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; }
.vs-mini .vs-mini-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; }
.vs-mini.vs-mini-run .vs-mini-info-red { animation: vsmRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-info-blue { animation: vsmRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-tag {
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
letter-spacing: .35em; color: #fff;
padding: 5.4px 15.1px 5.4px 18.9px;
}
.vs-mini .vs-mini-tag-red { background: oklch(0.55 0.20 25); }
.vs-mini .vs-mini-tag-blue { background: oklch(0.50 0.16 255); }
.vs-mini .vs-mini-flag-row { display: flex; align-items: center; gap: 15.1px; }
.vs-mini .vs-mini-flag-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-flag {
width: 56.2px; height: auto; aspect-ratio: 4/3;
background-size: cover !important; background-position: center !important;
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 4px 18px rgba(0,0,0,.6);
display: inline-block; line-height: 0;
}
.vs-mini .vs-mini-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
.vs-mini .vs-mini-country-red { color: oklch(0.85 0.05 25); }
.vs-mini .vs-mini-country-blue { color: oklch(0.85 0.05 255); }
.vs-mini .vs-mini-name {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 71.3px; line-height: .95;
text-transform: uppercase; color: #fff;
text-shadow: 0 6px 30px rgba(0,0,0,.8);
}
.vs-mini .vs-mini-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
.vs-mini .vs-mini-club-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-logo {
width: 69.1px; height: 69.1px;
border-radius: 50%;
background: rgba(255,255,255,.06);
background-size: cover; background-position: center;
border: 1px solid rgba(255,255,255,.2);
}
.vs-mini .vs-mini-club {
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
color: rgba(232,230,224,.9);
}
/* Fighter chips row (Record / Rank / Stats) — matches full-page VS. */
.vs-mini .vs-mini-chips-row { display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px; }
.vs-mini .vs-mini-info-blue .vs-mini-chips-row { justify-content: flex-end; }
.vs-mini .vs-mini-chip-fighter {
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
padding: 5.4px 14px;
background: rgba(10,10,14,.62);
border: 1px solid rgba(255,255,255,.25);
color: #fff;
}
.vs-mini .vs-mini-chip-fighter-gold {
border-color: oklch(0.85 0.16 85 / .6);
color: oklch(0.87 0.14 85);
}
/* ══ Placeholder hiding — parity with the full-page VS. ══ */
.vs-mini .vsm-nullable:empty { display: none; }
.vs-mini .vsm-nullable-hide { display: none; }
.vs-mini .vs-mini-chip.vsm-nullable-chip[data-vsm-empty="1"] { display: none; }
.vs-mini .vs-mini-bottom .vs-mini-diamond-mc:has(+ .vs-mini-chip[data-vsm-empty="1"]),
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-mc,
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-cr,
.vs-mini .vs-mini-bottom .vs-mini-diamond-cr:has(+ .vs-mini-chip[data-vsm-empty="1"]) { display: none; }
/* ── Top block ── */
.vs-mini .vs-mini-top {
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
z-index: 8; width: 92%; pointer-events: none; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-top { animation: vsmDropIn .8s .5s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-top-event {
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
letter-spacing: .42em; text-transform: uppercase;
color: rgba(232,230,224,.92); text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
.vs-mini .vs-mini-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
.vs-mini .vs-mini-top-line { height: 2px; width: 64.8px; }
.vs-mini .vs-mini-top-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-stage {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 38.9px;
letter-spacing: .30em; padding-left: .3em;
color: oklch(0.85 0.16 85); text-transform: uppercase;
}
.vs-mini .vs-mini-top-weight {
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
color: #fff; text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
/* ── Center VS ── */
.vs-mini .vs-mini-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
z-index: 7; pointer-events: none;
display: flex; align-items: center; justify-content: center;
opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-center { animation: vsmSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-word {
position: relative;
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 183.6px; font-style: italic;
color: #fffdf5; line-height: 1;
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
animation: vsmPulse 2.4s ease-in-out infinite;
overflow: visible;
}
.vs-mini .vs-mini-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
.vs-mini .vs-mini-shine {
position: absolute; top: 0; bottom: 0; width: 34%;
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
animation: vsmShine 5s ease-in-out infinite;
}
/* .vs-mini-flash rules removed with the element itself. */
/* ── Bottom chips ── */
.vs-mini .vs-mini-bottom {
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
display: flex; gap: 17.3px; z-index: 8; align-items: center;
flex-wrap: wrap; justify-content: center; max-width: 94%; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-bottom { animation: vsmRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-chip {
display: flex; align-items: baseline; gap: 8.6px;
background: rgba(10,10,14,.72);
border: 1px solid oklch(0.85 0.16 85 / .45);
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
}
.vs-mini .vs-mini-chip-lbl {
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .30em; text-transform: uppercase;
color: rgba(232,230,224,.65);
}
.vs-mini .vs-mini-chip-val {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 34.6px; color: #fff;
}
.vs-mini .vs-mini-diamond {
width: 6px; height: 6px; transform: rotate(45deg);
background: oklch(0.85 0.16 85);
}
/* ── Portrait canvas (rare — vertical match cards) ── */
.vs-mini.vs-mini-portrait { --vs-mini-w: 1080px; --vs-mini-h: 1920px; }
.vs-mini.vs-mini-portrait .vs-mini-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); }
.vs-mini.vs-mini-portrait .vs-mini-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); }
.vs-mini.vs-mini-portrait .vs-mini-divider {
left: -4%; right: -4%; top: 50%; bottom: auto;
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
transform: rotate(-4.48deg);
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
}
.vs-mini.vs-mini-portrait .vs-mini-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
.vs-mini.vs-mini-portrait .vs-mini-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-red { animation-name: vsmPanelT; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-blue { animation-name: vsmPanelB; }
/* Hide the shimmer skeleton on match cards — the VS mini fills the thumb. */
.yt-video-thumb:has(.vs-mini)::before { display: none; }
/* Animations */
@keyframes vsmPulse {
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
}
@keyframes vsmShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
@keyframes vsmDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
@keyframes vsmPanelL { from { opacity: 1; transform: translateX(-105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelR { from { opacity: 1; transform: translateX( 105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelT { from { opacity: 1; transform: translateY(-105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmPanelB { from { opacity: 1; transform: translateY( 105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmSlam {
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
80% { transform: translate(-50%,-52%) scale(1.06); }
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
}
/* ══ EXPLODE — the inverse of the implode entrance. Each piece flies
OUT in the direction opposite to where it came from, then the
whole mini fades to zero opacity while the video takes the stage. ══ */
@keyframes vsmFadeOut { to { opacity: 0; } }
@keyframes vsmExplodeL { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX(-140%) scale(1.05); } }
@keyframes vsmExplodeR { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX( 140%) scale(1.05); } }
@keyframes vsmExplodeInfoL { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate(-70%, 40%) scale(.85); } }
@keyframes vsmExplodeInfoR { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate( 70%, 40%) scale(.85); } }
@keyframes vsmExplodeTop { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%,-120%) scale(.9); } }
@keyframes vsmExplodeBot { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%, 120%) scale(.9); } }
@keyframes vsmExplodeVS {
0% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); filter: blur(0); }
40% { opacity: 1; transform: translate(-50%,-52%) scale(1.3) rotate(-2deg); filter: blur(1px); }
100% { opacity: 0; transform: translate(-50%,-52%) scale(4) rotate(6deg); filter: blur(8px); }
}
@keyframes vsmDividerOut { to { opacity: 0; transform: rotate(10.15deg) scaleY(0); } }
</style>
<script>
(function () {
if (window._vsMiniBound) return;
window._vsMiniBound = true;
// Fit the 1920×1080 (or 1080×1920) stage to whatever thumb size the
// card resolves to. One ResizeObserver watches every current + future
// thumb — cheap because the callback is a couple of arithmetic ops.
function fit(el) {
const host = el.parentElement;
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
const portrait = h > w * 1.05;
el.classList.toggle('vs-mini-portrait', portrait);
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
// Reveal the stage — hidden by default so the CSS's fallback
// scale never paints a pre-fit oversized/clipped stage.
el.classList.add('vs-mini-fit');
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
for (const e of entries) {
const el = e.target.querySelector(':scope > .vs-mini');
if (el) fit(el);
}
}) : null;
function register(el) {
fit(el);
if (ro) ro.observe(el.parentElement);
}
// Register everything already in the DOM.
document.querySelectorAll('.vs-mini').forEach(register);
// Register anything added later (SPA nav, infinite-scroll loads).
new MutationObserver(muts => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (n.matches?.('.vs-mini')) register(n);
n.querySelectorAll?.('.vs-mini').forEach(register);
}
}
}).observe(document.body, { childList: true, subtree: true });
// Restart entrance animations whenever the mouse leaves a card thumb
// that has a VS mini in it. Only bind on real hover devices — on touch,
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
// kept restarting animations and leaving cards frozen mid-intro
// (elements at opacity:0 during the 0.51.3s animation-delay window).
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
if (hasHover) {
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})();
</script>
@endonce