Add video headers
Some checks failed
Deploy to Pages / build (push) Failing after 2m46s

This commit is contained in:
Andras Schmelczer 2026-06-03 08:15:01 +01:00
parent cf509360e6
commit 0ab9889fc8
7 changed files with 535 additions and 131 deletions

View file

@ -57,6 +57,7 @@ const ANALYTICS_SCRIPT_SRC_PATTERN =
function isSafeScriptTag(tag) {
if (tag.includes('data-theme-script')) return true;
if (tag.includes('data-thumbnail-iframe-script')) return true;
if (tag.includes('data-video-thumbnail-script')) return true;
if (ANALYTICS_SCRIPT_SRC_PATTERN.test(tag)) return true;
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
if (!typeMatch) return false;

View file

@ -1,13 +1,15 @@
---
import { Picture } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import { absoluteUrl } from '../lib/site';
import VideoThumbnail from './VideoThumbnail.astro';
import { absoluteUrl, getHeaderVideo } from '../lib/site';
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const headerVideo = getHeaderVideo(post);
const demoLink = post.data.links.find(
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
);
@ -54,66 +56,81 @@ for (const root of document.querySelectorAll('.post-thumbnail--iframe')) {
`;
---
<div
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
data-uncropped-preview
data-preview-label={post.data.title}
>
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
quality="high"
loading="eager"
fetchpriority="high"
decoding="async"
/>
{
headerVideo ? (
<VideoThumbnail
class="post-thumbnail post-thumbnail--video"
variant="banner"
poster={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
video={headerVideo}
previewLabel={post.data.title}
widths={[640, 960, 1280, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager"
fetchpriority="high"
/>
) : (
<div
class:list={['post-thumbnail', iframeSrc && 'post-thumbnail--iframe']}
style={iframeSrc ? `--post-thumbnail-aspect: ${aspectRatio}` : undefined}
data-uncropped-preview
data-preview-label={post.data.title}
>
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
quality="high"
loading="eager"
fetchpriority="high"
decoding="async"
/>
{
iframeSrc && (
<>
<button
class="post-thumbnail__play"
type="button"
data-thumbnail-iframe-trigger
data-iframe-src={iframeSrc}
aria-label={`Play ${demoLink?.label.toLowerCase() ?? 'demo'}`}
aria-expanded="false"
>
<span class="post-thumbnail__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M8 5v14l11-7z" />
</svg>
</span>
<span class="sr-only">Play {demoLink?.label.toLowerCase() ?? 'demo'}</span>
</button>
<iframe
class="post-thumbnail__iframe"
data-thumbnail-iframe-frame
title={iframeTitle}
src="about:blank"
allow="fullscreen; webgpu"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
tabindex="0"
hidden
/>
<noscript>
<p class="post-thumbnail__noscript">
<a href={iframeSrc}>Open {demoLink?.label.toLowerCase() ?? 'demo'}</a>
</p>
</noscript>
</>
)
}
</div>
{iframeSrc && (
<>
<button
class="post-thumbnail__play"
type="button"
data-thumbnail-iframe-trigger
data-iframe-src={iframeSrc}
aria-label={`Play ${demoLink?.label.toLowerCase() ?? 'demo'}`}
aria-expanded="false"
>
<span class="post-thumbnail__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M8 5v14l11-7z" />
</svg>
</span>
<span class="sr-only">Play {demoLink?.label.toLowerCase() ?? 'demo'}</span>
</button>
<iframe
class="post-thumbnail__iframe"
data-thumbnail-iframe-frame
title={iframeTitle}
src="about:blank"
allow="fullscreen; webgpu"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
tabindex="0"
hidden
/>
<noscript>
<p class="post-thumbnail__noscript">
<a href={iframeSrc}>Open {demoLink?.label.toLowerCase() ?? 'demo'}</a>
</p>
</noscript>
</>
)}
</div>
)
}
{
iframeSrc && (
iframeSrc && !headerVideo && (
<script is:inline data-thumbnail-iframe-script set:html={iframeThumbnailScript} />
)
}

View file

@ -2,8 +2,9 @@
import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro';
import ProjectLinks from './ProjectLinks.astro';
import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
import VideoThumbnail from './VideoThumbnail.astro';
import type { HeaderVideo } from '../lib/site';
import { PROJECT_THUMBNAIL, articlePath, entrySlug, getHeaderVideo } from '../lib/site';
interface Props {
projects: CollectionEntry<'projects'>[];
@ -19,15 +20,36 @@ const {
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
} = Astro.props;
function isExternal(url: string) {
return /^https?:\/\//.test(url);
}
// The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
// Drafts are skipped because their article page is not built.
// Drafts are skipped because their article page is not built. A project may
// have no essay (no article) just as an article may have no project; the
// relationship is optional in both directions.
const essayHrefs = new Map<string, string>();
// When the linked article has a header video, the card thumbnail becomes a
// click-to-play poster (the card body still opens the project site).
const essayVideos = new Map<string, HeaderVideo>();
for (const project of projects) {
const essay = project.data.essay;
if (!essay) continue;
const resolved = await getEntry(essay);
if (resolved && !resolved.data.draft) essayHrefs.set(project.id, articlePath(resolved));
if (!resolved || resolved.data.draft) continue;
essayHrefs.set(project.id, articlePath(resolved));
const headerVideo = getHeaderVideo(resolved);
if (headerVideo) essayVideos.set(project.id, headerVideo);
}
// The whole card opens the project's website: the first link that isn't a
// download (Source / Live / Demo / Site / package page, in author order).
// The Open button is that link, and its overlay makes the entire card
// clickable. Projects without such a link have no Open button and are not
// clickable; their article, if any, is reachable through the Article link.
function websiteUrl(project: CollectionEntry<'projects'>) {
return project.data.links.find((link) => !link.download)?.url;
}
---
@ -37,36 +59,71 @@ for (const project of projects) {
const anchor = entrySlug(project);
const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id);
const primaryHref = essayHref ?? project.data.links[0]?.url;
const headerVideo = essayVideos.get(project.id);
const website = websiteUrl(project);
const websiteExternal = website ? isExternal(website) : false;
const eager = index < eagerThumbnailCount;
return (
<li class="project-card" id={anchor}>
<EntryThumbnail
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
href={primaryHref}
class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
ariaLabel={`Open project: ${project.data.title}`}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager && index === 0 ? 'high' : undefined}
/>
{headerVideo ? (
<VideoThumbnail
class="entry-thumbnail project-thumbnail"
variant="card"
poster={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
video={headerVideo}
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager && index === 0 ? 'high' : undefined}
/>
) : (
<EntryThumbnail
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager && index === 0 ? 'high' : undefined}
/>
)}
<article class="project-card__summary">
<h3 id={titleId}>
{primaryHref ? (
<a href={primaryHref}>{project.data.title}</a>
) : (
project.data.title
)}
{essayHref && <span class="project-essay-badge">Article</span>}
</h3>
<p class="project-description">{project.data.description}</p>
<p class="project-meta">
{project.data.period} · {project.data.technologies.join(', ')}
</p>
{project.data.links.length > 0 && <ProjectLinks links={project.data.links} />}
<div class="project-card__head">
<h3 id={titleId}>{project.data.title}</h3>
<p class="project-description">{project.data.description}</p>
</div>
{(essayHref || website) && (
<div class="project-card__actions">
{essayHref && (
<a
class="project-article-link"
href={essayHref}
aria-label={`Read the article about ${project.data.title}`}
>
Article
<span aria-hidden="true">→</span>
</a>
)}
{website && (
<a
class="project-card__open"
href={website}
rel={websiteExternal ? 'noopener noreferrer' : undefined}
target={websiteExternal ? '_blank' : undefined}
aria-label={
websiteExternal
? `Open the ${project.data.title} site in a new tab`
: `Open ${project.data.title}`
}
>
Open
<span aria-hidden="true">↗</span>
</a>
)}
</div>
)}
</article>
</li>
);

View file

@ -0,0 +1,134 @@
---
import type { ImageMetadata } from 'astro';
import { Picture } from 'astro:assets';
import type { HeaderVideo } from '../lib/site';
interface Props {
// Still image shown until playback starts.
poster: ImageMetadata;
alt: string;
video: HeaderVideo;
widths: number[];
sizes: string;
loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto';
// `banner` keeps the poster's own aspect ratio (article hero); `card` is a
// compact badge over a cover-cropped thumbnail (project list).
variant?: 'banner' | 'card';
// When set, the wrapper joins the no-crop preview contract checked in QA.
previewLabel?: string;
class?: string;
}
const {
poster,
alt,
video,
widths,
sizes,
loading = 'lazy',
fetchpriority,
variant = 'banner',
previewLabel,
class: extraClass,
} = Astro.props;
const aspectRatio = `${poster.width} / ${poster.height}`;
// Swap the poster for the <video> and start playback on click. Guarded so the
// duplicate inline copies emitted per instance only wire listeners up once.
const playScript = `
(function () {
if (window.__videoThumbnailInit) return;
window.__videoThumbnailInit = true;
function wireOne(root) {
var button = root.querySelector('[data-video-play]');
var video = root.querySelector('[data-video]');
if (!(button instanceof HTMLButtonElement) || !(video instanceof HTMLVideoElement)) return;
button.addEventListener('click', function () {
video.hidden = false;
video.setAttribute('controls', '');
root.classList.add('is-playing');
var played = video.play();
if (played && typeof played.catch === 'function') played.catch(function () {});
video.focus();
}, { once: true });
}
function wireAll() {
var roots = document.querySelectorAll('.video-thumbnail');
for (var i = 0; i < roots.length; i++) wireOne(roots[i]);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireAll);
} else {
wireAll();
}
})();
`;
---
<div
class:list={['video-thumbnail', `video-thumbnail--${variant}`, extraClass]}
style={variant === 'banner' ? `--video-aspect: ${aspectRatio}` : undefined}
data-uncropped-preview={previewLabel ? '' : undefined}
data-preview-label={previewLabel}
>
<Picture
src={poster}
alt={alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={widths}
sizes={sizes}
quality="high"
loading={loading}
fetchpriority={fetchpriority}
decoding="async"
/>
<button
class="video-thumbnail__play"
type="button"
data-video-play
aria-label={`Play video: ${video.alt ?? alt}`}
>
<span class="video-thumbnail__play-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M8 5v14l11-7z"></path>
</svg>
</span>
<span class="sr-only">Play video</span>
</button>
<video
class="video-thumbnail__video"
data-video
preload="none"
playsinline
poster={video.poster?.src}
aria-label={video.alt}
hidden
>
{video.webm && <source src={video.webm} type="video/webm" />}
{video.mp4 && <source src={video.mp4} type="video/mp4" />}
{
video.captions && (
<track
kind="captions"
src={video.captions}
srclang="en"
label={video.captionsLabel}
default
/>
)
}
</video>
<noscript>
<p class="video-thumbnail__noscript">
<a href={video.mp4 ?? video.webm}>Play video</a>
</p>
</noscript>
</div>
<script is:inline data-video-thumbnail-script set:html={playScript} />

View file

@ -15,6 +15,7 @@ import {
buildPersonJsonLd,
buildBreadcrumbTrail,
formatDate,
getHeaderVideo,
getPublishedPosts,
getRelatedPosts,
optimizeOgImage,
@ -52,11 +53,15 @@ const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
const h2Headings = headings.filter((h) => h.depth === 2);
const showToc = h2Headings.length >= 3;
// Don't repeat the banner image at the end; PostThumbnail already rendered it.
// Don't repeat header media at the end; PostThumbnail already rendered the
// banner image and, when present, plays the header video inline.
const thumbnailSrc = post.data.thumbnail.src.src;
const trailingMedia = post.data.media.filter(
(item) => item.type === 'video' || item.src.src !== thumbnailSrc
);
const headerVideo = getHeaderVideo(post);
const trailingMedia = post.data.media.filter((item) => {
if (item === headerVideo) return false;
if (item.type === 'video') return true;
return item.src.src !== thumbnailSrc;
});
const personId = absoluteUrl('/about/#person');

View file

@ -102,6 +102,21 @@ export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
);
}
type PostMediaItem = CollectionEntry<'posts'>['data']['media'][number];
export type HeaderVideo = Extract<PostMediaItem, { type: 'video' }>;
// A post has a "header video" when one of its media items is a video whose
// poster is the same image as the post thumbnail. That video can stand in for
// the static banner: the header shows the poster, then plays inline on click.
// Posts without such a video keep the plain image header.
export function getHeaderVideo(post: CollectionEntry<'posts'>): HeaderVideo | undefined {
const thumbnailSrc = post.data.thumbnail.src.src;
return post.data.media.find(
(item): item is HeaderVideo =>
item.type === 'video' && item.poster?.src === thumbnailSrc
);
}
export function adjacentPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>
@ -163,7 +178,7 @@ export const ARTICLE_THUMBNAIL = {
export const PROJECT_THUMBNAIL = {
widths: [320, 480, 640, 800, 960, 1200, 1280],
sizes:
'(max-width: 700px) calc(100vw - 40px), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
'(max-width: 700px) calc((100vw - 40px - 0.75rem) / 2), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
};
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by

View file

@ -630,8 +630,12 @@
line-height: var(--leading-snug);
}
.article-list .entry-title,
.project-list h3 a {
.project-list h3 {
color: var(--color-fg);
font-weight: var(--weight-semibold);
}
.article-list .entry-title {
display: inline-flex;
align-items: center;
min-height: 28px;
@ -640,8 +644,7 @@
text-decoration: none;
}
.article-list .entry-title:hover,
.project-list h3 a:hover {
.article-list .entry-title:hover {
color: var(--color-link-hover);
}
@ -776,6 +779,7 @@
}
.project-card {
position: relative;
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
@ -809,7 +813,8 @@
grid-area: summary;
display: flex;
flex-direction: column;
gap: var(--space-1);
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
padding: var(--space-3) var(--space-4);
}
@ -824,29 +829,78 @@
line-height: var(--leading-snug);
}
.project-card .project-meta {
color: var(--color-muted);
font-size: var(--fs-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.project-card__head {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.project-essay-badge {
.project-card__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
margin-top: var(--space-3);
}
/* Stretched link: the Open control's overlay covers the whole card so any
click opens the project website. The Article link sits above the overlay
via a higher z-index and stays independently clickable. */
.project-card__open::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
/* Cover the whole card, not the Open button's pill radius. The card's own
overflow:hidden + border-radius clips this to the rounded card shape. */
border-radius: 0;
}
.project-card__open {
margin-left: auto;
display: inline-flex;
align-items: center;
margin-left: var(--space-2);
padding: 0.1em 0.5em;
background: var(--color-callout-bg);
border: 1px solid var(--color-rule);
gap: 0.3em;
min-height: 36px;
padding: 0.25em 0.85em;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
color: var(--color-muted);
font-size: var(--fs-xs);
background: var(--color-bg);
color: var(--color-link);
font-size: var(--fs-caption);
font-weight: var(--weight-semibold);
text-decoration: none;
transition:
background-color 150ms ease,
border-color 150ms ease,
color 150ms ease;
}
.project-card:hover .project-card__open,
.project-card:focus-within .project-card__open {
border-color: var(--color-link);
background: var(--color-callout-bg);
color: var(--color-link-hover);
}
.project-article-link {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 0.3em;
min-height: 36px;
color: var(--color-link);
font-size: var(--fs-caption);
font-weight: var(--weight-medium);
text-transform: uppercase;
letter-spacing: 0.08em;
vertical-align: 0.15em;
text-decoration: none;
}
.project-article-link:hover,
.project-article-link:focus-visible {
color: var(--color-link-hover);
text-decoration: underline;
}
.project-links {
@ -877,16 +931,6 @@
font-size: 0.85em;
}
.project-card .project-links {
gap: 0 var(--space-3);
margin-top: auto;
font-size: var(--fs-caption);
}
.project-card .project-links a {
min-height: 44px;
}
.post > .post-media,
.facts {
max-width: var(--measure);
@ -1117,6 +1161,141 @@
font-size: var(--fs-caption);
}
/* Click-to-play header video: a poster with a play button that swaps in an
inline <video> on click. Shared by the article banner and project cards. */
.video-thumbnail {
position: relative;
display: block;
overflow: hidden;
}
.video-thumbnail picture,
.video-thumbnail img {
display: block;
width: 100%;
height: 100%;
}
.video-thumbnail__video {
/* The global `video { display: block }` reset (author origin) overrides the
UA `[hidden]` rule, so hide the idle video explicitly to keep it from
swallowing the play button's click. */
display: none;
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.video-thumbnail.is-playing .video-thumbnail__video {
display: block;
}
.video-thumbnail__play {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
border: 0;
padding: 0;
display: grid;
place-items: center;
background: color-mix(in oklch, #000 22%, transparent);
color: var(--color-fg);
cursor: pointer;
}
.video-thumbnail__play:hover,
.video-thumbnail__play:focus-visible {
background: color-mix(in oklch, #000 30%, transparent);
}
.video-thumbnail__play-icon {
width: clamp(3.25rem, 9vw, 4.75rem);
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--color-rule-strong);
border-radius: var(--radius-pill);
background: color-mix(in oklch, var(--color-bg) 88%, transparent);
box-shadow: 0 0.75rem 2rem color-mix(in oklch, #000 28%, transparent);
transition:
background-color 150ms ease,
transform 150ms ease;
}
.video-thumbnail__play:hover .video-thumbnail__play-icon,
.video-thumbnail__play:focus-visible .video-thumbnail__play-icon {
background: var(--color-bg);
transform: scale(1.04);
}
.video-thumbnail__play svg {
width: 42%;
height: 42%;
transform: translateX(8%);
fill: currentColor;
}
.video-thumbnail.is-playing picture,
.video-thumbnail.is-playing .video-thumbnail__play {
display: none;
}
.video-thumbnail__noscript {
position: absolute;
inset-inline: var(--space-3);
inset-block-end: var(--space-3);
z-index: 3;
margin: 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg);
font-size: var(--fs-caption);
}
/* Banner variant: keep the poster's own aspect ratio inside a rounded frame.
Cover with a matching ratio is not a crop, so the no-crop preview QA passes. */
.video-thumbnail--banner {
aspect-ratio: var(--video-aspect, 16 / 9);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
background: var(--color-code-bg);
}
.video-thumbnail--banner img {
object-fit: cover;
}
.video-thumbnail--banner.post-thumbnail img {
border: 0;
border-radius: 0;
}
/* Card variant: only the centered badge is the play button, so a click
anywhere else on the thumbnail falls through to the card's Open link. */
.video-thumbnail--card .video-thumbnail__play {
inset: auto;
top: 50%;
left: 50%;
width: auto;
background: transparent;
transform: translate(-50%, -50%);
}
.video-thumbnail--card .video-thumbnail__play:hover,
.video-thumbnail--card .video-thumbnail__play:focus-visible {
background: transparent;
}
.video-thumbnail--card .video-thumbnail__play-icon {
width: clamp(2.25rem, 14vw, 3rem);
box-shadow: 0 0.5rem 1.25rem color-mix(in oklch, #000 32%, transparent);
}
.prose {
max-inline-size: var(--measure);
line-height: var(--leading-prose);
@ -1459,11 +1638,6 @@
overflow-wrap: anywhere;
}
.project-card h3 a {
min-height: 44px;
min-width: 44px;
}
.post-toc ol {
list-style: none;
padding: 0;
@ -1771,8 +1945,9 @@
padding-block-start: 0;
}
.project-card .project-meta {
-webkit-line-clamp: 3;
.project-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.project-card__summary {