video: cooler person hero + perf pass on search/person pages

Person hero glow-up:
- Cinematic ambient — blurred portrait + an accent colour mesh + vignette, masked
  to fade into the page (was a flat wash).
- Portrait gets a slowly-rotating accent gradient ring (masked donut, GPU
  transform) and a gentle float; an accent ring + glow frame.
- An accent role tagline ('ACTOR' / 'DIRECTOR' …) above a gradient-filled name,
  plus a credits-count chip. Honors prefers-reduced-motion.

Performance:
- Long filmography grids use content-visibility:auto + contain-intrinsic-size so
  the browser skips off-screen cards (cheap scroll), and skip replaying the
  entrance animation as cards recycle.
- Hero layers are static (painted once); only two tiny composited transforms
  animate. Posters/photos stay on small TMDB sizes + lazy-load; trending cached;
  search debounced + request-sequenced.

Pure visual/perf layer — same data + isolation. Shell/JS tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-15 08:37:32 -07:00
parent d6d0dc537c
commit 769dc62b87
4 changed files with 93 additions and 20 deletions

View file

@ -410,6 +410,9 @@ def test_person_subpage_and_module_isolated():
assert "themoviedb.org" not in src and "imdb.com" not in src
# The person page is a registered detail route (reload / back / new-tab work).
assert "video-person-detail" in _JS
# Cinematic hero bits: ambient backdrop, rotating accent ring, role tagline.
assert "data-vp-ambient" in _INDEX and "vp-photo-ring" in _INDEX
assert "data-vp-role" in _INDEX
def test_detail_keeps_preview_items_in_app():

View file

@ -1033,9 +1033,15 @@
<div class="loading-spinner"></div><div class="loading-text">Loading&hellip;</div>
</div>
<div class="vp-hero">
<div class="vp-photo-wrap"><img class="vp-photo" data-vp-photo alt="" hidden>
<span class="vp-photo-ph" data-vp-photo-ph aria-hidden="true">&#128100;</span></div>
<div class="vp-photo-wrap">
<span class="vp-photo-ring" aria-hidden="true"></span>
<div class="vp-photo-frame">
<img class="vp-photo" data-vp-photo alt="" hidden>
<span class="vp-photo-ph" data-vp-photo-ph aria-hidden="true">&#128100;</span>
</div>
</div>
<div class="vp-hero-info">
<div class="vp-role" data-vp-role hidden></div>
<h1 class="vp-name" data-vp-name></h1>
<div class="vp-meta" data-vp-meta></div>
</div>

View file

@ -17,6 +17,10 @@
var data = null;
var currentId = null;
var tab = 'all'; // all | movie | show
var ROLE_NOUNS = {
Acting: 'Actor', Directing: 'Director', Writing: 'Writer', Production: 'Producer',
Sound: 'Composer', Camera: 'Cinematographer', Editing: 'Editor', Creator: 'Creator',
};
function esc(s) {
return String(s == null ? '' : s)
@ -106,10 +110,19 @@
if (amb) amb.style.setProperty('--vp-bg', d.photo ? "url('" + d.photo + "')" : 'none');
setText('[data-vp-name]', d.name);
// Role tagline (a friendlier noun than the raw department).
var role = q('[data-vp-role]');
if (role) {
var noun = ROLE_NOUNS[d.known_for] || d.known_for || '';
role.textContent = noun; role.hidden = !noun;
}
var meta = [];
if (d.known_for) meta.push(d.known_for);
var ls = lifespan(d); if (ls) meta.push(ls);
if (d.place_of_birth) meta.push(d.place_of_birth);
var n = (d.credits || []).length;
if (n) meta.push(n + (n === 1 ? ' credit' : ' credits'));
var m = q('[data-vp-meta]');
if (m) m.innerHTML = meta.map(function (x) { return '<span>' + esc(x) + '</span>'; }).join('');

View file

@ -970,42 +970,80 @@ body[data-side="video"] .dashboard-header-sweep {
--vd-accent-rgb: var(--accent-rgb, 88, 101, 242);
position: relative; margin: -40px -40px -90px; min-height: 80vh;
}
/* Cinematic hero: a blurred-portrait wash + an accent colour mesh, capped by a
vignette and a fade into the page. Layers are static (painted once) so the
hero stays cheap. */
.vp-ambient {
position: absolute; inset: 0 0 auto 0; height: 520px; z-index: 0; overflow: hidden;
position: absolute; inset: 0 0 auto 0; height: 560px; z-index: 0; overflow: hidden;
-webkit-mask-image: linear-gradient(180deg, #000 62%, transparent);
mask-image: linear-gradient(180deg, #000 62%, transparent);
}
.vp-ambient::before {
content: ''; position: absolute; inset: -60px; background-size: cover; background-position: center 20%;
background-image: var(--vp-bg, none); filter: blur(46px) saturate(1.25); transform: scale(1.18);
opacity: 0.45;
content: ''; position: absolute; inset: -80px; background-size: cover; background-position: center 18%;
background-image: var(--vp-bg, none); filter: blur(54px) saturate(1.35); transform: scale(1.2);
opacity: 0.5;
}
.vp-ambient::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(11,11,15,0.35) 0%, rgba(11,11,15,0.7) 55%, var(--bg-primary, #0b0b0f) 100%);
background:
radial-gradient(80% 90% at 16% -10%, rgba(var(--vd-accent-rgb), 0.42), transparent 60%),
radial-gradient(70% 80% at 100% 0%, rgba(var(--vd-accent-rgb), 0.2), transparent 65%),
radial-gradient(120% 80% at 50% 120%, rgba(0, 0, 0, 0.5), transparent 60%),
linear-gradient(180deg, rgba(11,11,15,0.25) 0%, rgba(11,11,15,0.62) 58%, var(--bg-primary, #0b0b0f) 100%);
}
.vp-page[data-has-bg="0"] .vp-ambient::before {
background-image: radial-gradient(80% 120% at 30% 0%, rgba(var(--vd-accent-rgb), 0.5), transparent 70%);
filter: none; opacity: 0.7;
background-image: radial-gradient(80% 120% at 30% 0%, rgba(var(--vd-accent-rgb), 0.55), transparent 70%);
filter: none; opacity: 0.8;
}
.vp-page .vd-back { z-index: 6; }
.vp-hero {
position: relative; z-index: 2; display: flex; gap: 36px; align-items: flex-end;
padding: 130px 56px 30px; animation: vdRise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both;
position: relative; z-index: 2; display: flex; gap: 40px; align-items: flex-end;
padding: 150px 56px 34px; animation: vdRise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes vdRise { from { opacity: 0; transform: translateY(26px); } to { opacity: 1; transform: none; } }
/* Portrait: a frame with a slowly-rotating accent gradient ring (GPU transform
cheap) + a gentle float. */
.vp-photo-wrap {
flex: 0 0 auto; width: 188px; height: 188px; border-radius: 50%; overflow: hidden;
background: linear-gradient(145deg, rgba(var(--vd-accent-rgb), 0.4), rgba(8, 8, 12, 0.9));
position: relative; flex: 0 0 auto; width: 200px; height: 200px;
animation: vp-float 7s ease-in-out infinite;
}
@keyframes vp-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-9px); } }
.vp-photo-ring {
position: absolute; inset: -7px; border-radius: 50%; z-index: 0;
background: conic-gradient(from 0deg,
rgba(var(--vd-accent-rgb), 0.95), rgba(var(--vd-accent-rgb), 0.04) 38%,
rgba(var(--vd-accent-rgb), 0.55) 72%, rgba(var(--vd-accent-rgb), 0.95));
-webkit-mask: radial-gradient(closest-side, transparent calc(100% - 9px), #000 calc(100% - 8px));
mask: radial-gradient(closest-side, transparent calc(100% - 9px), #000 calc(100% - 8px));
animation: vp-spin 18s linear infinite; will-change: transform; opacity: 0.9;
}
@keyframes vp-spin { to { transform: rotate(360deg); } }
.vp-photo-frame {
position: absolute; inset: 0; border-radius: 50%; overflow: hidden; z-index: 1;
background: linear-gradient(145deg, rgba(var(--vd-accent-rgb), 0.42), rgba(8, 8, 12, 0.92));
display: flex; align-items: center; justify-content: center;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6), 0 0 0 4px rgba(var(--vd-accent-rgb), 0.35),
0 0 40px rgba(var(--vd-accent-rgb), 0.3);
box-shadow: 0 22px 56px rgba(0, 0, 0, 0.62), 0 0 60px rgba(var(--vd-accent-rgb), 0.32),
inset 0 0 0 2px rgba(255, 255, 255, 0.07);
}
.vp-photo { width: 100%; height: 100%; object-fit: cover; }
.vp-photo-ph { font-size: 76px; opacity: 0.55; }
.vp-hero-info { flex: 1; min-width: 0; padding-bottom: 6px; }
.vp-photo-ph { font-size: 80px; opacity: 0.55; }
.vp-hero-info { flex: 1; min-width: 0; padding-bottom: 10px; }
.vp-role {
display: inline-block; margin-bottom: 12px; font-size: 12px; font-weight: 800;
letter-spacing: 1.4px; text-transform: uppercase; color: rgb(var(--vd-accent-rgb));
padding: 5px 13px; border-radius: 999px;
background: rgba(var(--vd-accent-rgb), 0.14); border: 1px solid rgba(var(--vd-accent-rgb), 0.34);
text-shadow: 0 0 18px rgba(var(--vd-accent-rgb), 0.5);
}
.vp-name {
margin: 0 0 12px; font-size: clamp(2.2em, 4.5vw, 3.4em); font-weight: 900; color: #fff;
line-height: 1; letter-spacing: -0.02em; text-shadow: 0 4px 30px rgba(0, 0, 0, 0.6);
margin: 0 0 14px; font-size: clamp(2.4em, 5vw, 3.8em); font-weight: 900;
line-height: 0.98; letter-spacing: -0.025em;
background: linear-gradient(180deg, #fff 55%, rgba(255, 255, 255, 0.72));
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 6px 30px rgba(0, 0, 0, 0.6));
}
.vp-meta { display: flex; flex-wrap: wrap; gap: 9px; }
.vp-meta span {
@ -1013,6 +1051,10 @@ body[data-side="video"] .dashboard-header-sweep {
padding: 5px 12px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14); backdrop-filter: blur(6px);
}
@media (prefers-reduced-motion: reduce) {
.vp-photo-wrap { animation: none; }
.vp-photo-ring { animation: none; }
}
.vp-body { position: relative; z-index: 2; padding: 16px 56px 60px; }
.vp-bio {
@ -1037,6 +1079,15 @@ body[data-side="video"] .dashboard-header-sweep {
.vp-known-rail::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.vp-known-rail .vsr-card { flex: 0 0 152px; width: 152px; scroll-snap-align: start; }
/* Long filmography grids can be hundreds of cards let the browser skip
rendering the off-screen ones (cheap scrolling) and don't replay the entrance
animation as cards recycle in and out of view. */
.vp-credits-section .vsr-grid > .vsr-card {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
animation: none;
}
.vp-credits-section { position: relative; z-index: 2; }
.vp-credits-head {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 20px;