website: move placeholders to src/assets for Vite base64 inlining, add LazyVideo component
Placeholders are now generated into src/assets/placeholders/ instead of public/ so Vite's asset pipeline processes them. Since all are < 4KB, Vite auto-inlines them as base64 data URIs via assetsInlineLimit — zero extra HTTP requests for placeholders. - Static imports resolve to data:image/png;base64,... at build time - JSDoc on placeholder props documents why static imports are required (synchronous availability, Vite inlining) and warns against dynamic imports and public/ paths - New LazyVideo component: same pixelated poster pattern, uses native <video preload="none" loading="lazy"> for zero-JS lazy loading
This commit is contained in:
@@ -2,9 +2,14 @@
|
|||||||
* Generate pixelated placeholder images for the website.
|
* Generate pixelated placeholder images for the website.
|
||||||
*
|
*
|
||||||
* Scans website/public/ for all image files (png, jpg, jpeg, webp) and
|
* Scans website/public/ for all image files (png, jpg, jpeg, webp) and
|
||||||
* generates a tiny 32px-wide version of each. When displayed at full size
|
* generates a tiny 32px-wide version into src/assets/placeholders/.
|
||||||
* with CSS `image-rendering: pixelated` (nearest-neighbor / point sampling),
|
* When displayed at full size with CSS `image-rendering: pixelated`
|
||||||
* these produce a crisp mosaic effect instead of a blurry upscale.
|
* (nearest-neighbor / point sampling), these produce a crisp mosaic effect.
|
||||||
|
*
|
||||||
|
* Output goes to src/assets/placeholders/ (NOT public/) so that Vite's
|
||||||
|
* asset pipeline processes them. Since all placeholders are < 4KB, Vite
|
||||||
|
* automatically inlines them as base64 data URIs via assetsInlineLimit,
|
||||||
|
* eliminating extra HTTP requests.
|
||||||
*
|
*
|
||||||
* Skips files that already have the `placeholder-` prefix.
|
* Skips files that already have the `placeholder-` prefix.
|
||||||
* Skips regeneration if the placeholder is newer than the source image.
|
* Skips regeneration if the placeholder is newer than the source image.
|
||||||
@@ -17,11 +22,14 @@ import path from "node:path";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
|
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
|
||||||
|
const OUTPUT_DIR = path.resolve(import.meta.dirname, "../src/assets/placeholders");
|
||||||
const PLACEHOLDER_PREFIX = "placeholder-";
|
const PLACEHOLDER_PREFIX = "placeholder-";
|
||||||
const PLACEHOLDER_WIDTH = 32;
|
const PLACEHOLDER_WIDTH = 32;
|
||||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
const entries = fs.readdirSync(PUBLIC_DIR);
|
const entries = fs.readdirSync(PUBLIC_DIR);
|
||||||
|
|
||||||
const images = entries.filter((name) => {
|
const images = entries.filter((name) => {
|
||||||
@@ -41,7 +49,7 @@ async function main() {
|
|||||||
const inputPath = path.join(PUBLIC_DIR, name);
|
const inputPath = path.join(PUBLIC_DIR, name);
|
||||||
const ext = path.extname(name);
|
const ext = path.extname(name);
|
||||||
const base = path.basename(name, ext);
|
const base = path.basename(name, ext);
|
||||||
const outputPath = path.join(PUBLIC_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`);
|
const outputPath = path.join(OUTPUT_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`);
|
||||||
|
|
||||||
// Skip if placeholder already exists and is newer than source
|
// Skip if placeholder already exists and is newer than source
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 636 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -434,6 +434,20 @@ export function PixelatedImage({
|
|||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
|
/**
|
||||||
|
* URL of the tiny pixelated placeholder image. Use a static import so Vite
|
||||||
|
* inlines it as a base64 data URI (all placeholders are < 4KB, well under
|
||||||
|
* Vite's default assetsInlineLimit of 4096 bytes). This makes the
|
||||||
|
* placeholder available synchronously on first render with zero HTTP
|
||||||
|
* requests. Do NOT use dynamic imports or public/ paths — dynamic imports
|
||||||
|
* add a microtask delay, and public/ files bypass Vite's asset pipeline.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
|
||||||
|
* <PixelatedImage placeholder={placeholderScreenshot} src="/screenshot@2x.png" ... />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -504,6 +518,133 @@ export function PixelatedImage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Lazy video with pixelated poster placeholder
|
||||||
|
Same visual pattern as PixelatedImage but for <video> elements.
|
||||||
|
Poster layers (pixelated → real) show through the transparent video element.
|
||||||
|
Video uses native loading="lazy" + preload="none" so zero bytes are
|
||||||
|
downloaded until the element is near the viewport and the user clicks play.
|
||||||
|
No custom IntersectionObserver needed — all native HTML attributes.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
export function LazyVideo({
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
placeholderPoster,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
type = "video/mp4",
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
poster: string;
|
||||||
|
/**
|
||||||
|
* URL of the tiny pixelated poster placeholder. Use a static import so Vite
|
||||||
|
* inlines it as a base64 data URI (all placeholders are < 4KB, well under
|
||||||
|
* Vite's default assetsInlineLimit of 4096 bytes). This makes the
|
||||||
|
* placeholder available synchronously on first render with zero HTTP
|
||||||
|
* requests. Do NOT use dynamic imports or public/ paths — dynamic imports
|
||||||
|
* add a microtask delay, and public/ files bypass Vite's asset pipeline.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import placeholderPoster from "../assets/placeholders/placeholder-demo-poster.png";
|
||||||
|
* <LazyVideo placeholderPoster={placeholderPoster} poster="/demo-poster.png" ... />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
placeholderPoster: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
type?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [posterLoaded, setPosterLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Handles cached poster images (same pattern as PixelatedImage)
|
||||||
|
const posterRef = useCallback((img: HTMLImageElement | null) => {
|
||||||
|
if (img?.complete && img.naturalWidth > 0) {
|
||||||
|
setPosterLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: `${width}px`,
|
||||||
|
aspectRatio: `${width} / ${height}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pixelated poster placeholder: loads instantly (~500 bytes) */}
|
||||||
|
<img
|
||||||
|
src={placeholderPoster}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
imageRendering: "pixelated",
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Real poster: fades in over the pixelated placeholder */}
|
||||||
|
<img
|
||||||
|
ref={posterRef}
|
||||||
|
src={poster}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
onLoad={() => {
|
||||||
|
setPosterLoaded(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
opacity: posterLoaded ? 1 : 0,
|
||||||
|
transition: "opacity 0.4s ease",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Video: transparent until playing, native lazy + no preload.
|
||||||
|
Controls float on top of poster layers. No poster attr needed
|
||||||
|
because the img layers handle the visual placeholder.
|
||||||
|
loading="lazy" is a newer HTML attr not yet in React's TS types. */}
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="none"
|
||||||
|
{...{ loading: "lazy" } as React.VideoHTMLAttributes<HTMLVideoElement>}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
zIndex: 2,
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<source src={src} type={type} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
Chart placeholder (dark box with animated line)
|
Chart placeholder (dark box with animated line)
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Li,
|
Li,
|
||||||
PixelatedImage,
|
PixelatedImage,
|
||||||
} from "website/src/components/markdown";
|
} from "website/src/components/markdown";
|
||||||
|
import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png";
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
const title = "Playwriter - Control your Chrome with Playwright API";
|
const title = "Playwriter - Control your Chrome with Playwright API";
|
||||||
@@ -73,7 +74,7 @@ export default function IndexPage() {
|
|||||||
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
|
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<PixelatedImage
|
<PixelatedImage
|
||||||
src="/screenshot@2x.png"
|
src="/screenshot@2x.png"
|
||||||
placeholder="/placeholder-screenshot@2x.png"
|
placeholder={placeholderScreenshot}
|
||||||
alt="Playwriter controlling Chrome with accessibility labels overlay"
|
alt="Playwriter controlling Chrome with accessibility labels overlay"
|
||||||
width={1280}
|
width={1280}
|
||||||
height={800}
|
height={800}
|
||||||
|
|||||||
Reference in New Issue
Block a user