This commit is contained in:
parent
cf509360e6
commit
0ab9889fc8
7 changed files with 535 additions and 131 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
134
src/components/VideoThumbnail.astro
Normal file
134
src/components/VideoThumbnail.astro
Normal 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} />
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue