website: add pixelated image placeholders with fade-in transition
Build-time script generates tiny 32px-wide versions of all images in public/ using sharp. At runtime, PixelatedImage component renders these with CSS image-rendering: pixelated (nearest-neighbor / point sampling) for a crisp mosaic effect, then fades in the real image on load. - Auto-discovers images in public/, skips placeholder- prefixed files - Mtime check skips regeneration when placeholder is newer than source - Callback ref handles cached images where onLoad fires before mount - Explicit z-index prevents placeholder from covering the real image - Runs automatically as part of pnpm build in website package
This commit is contained in:
Generated
+4
-3
@@ -491,6 +491,9 @@ importers:
|
||||
'@xmorse/deployment-utils':
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.20.6)(yaml@2.6.0)
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
tailwindcss:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@@ -7856,8 +7859,7 @@ snapshots:
|
||||
|
||||
'@iarna/toml@2.2.5': {}
|
||||
|
||||
'@img/colour@1.0.0':
|
||||
optional: true
|
||||
'@img/colour@1.0.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
@@ -12869,7 +12871,6 @@ snapshots:
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"placeholders": "tsx scripts/generate-placeholders.ts",
|
||||
"build": "tsx scripts/generate-placeholders.ts && react-router build",
|
||||
"dev": "react-router dev --port 8044",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
@@ -39,6 +40,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@xmorse/deployment-utils": "^0.7.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
|
||||
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: 380 B |
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Generate pixelated placeholder images for the website.
|
||||
*
|
||||
* 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
|
||||
* with CSS `image-rendering: pixelated` (nearest-neighbor / point sampling),
|
||||
* these produce a crisp mosaic effect instead of a blurry upscale.
|
||||
*
|
||||
* Skips files that already have the `placeholder-` prefix.
|
||||
* Skips regeneration if the placeholder is newer than the source image.
|
||||
*
|
||||
* Usage: tsx website/scripts/generate-placeholders.ts
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
|
||||
const PLACEHOLDER_PREFIX = "placeholder-";
|
||||
const PLACEHOLDER_WIDTH = 32;
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||
|
||||
async function main() {
|
||||
const entries = fs.readdirSync(PUBLIC_DIR);
|
||||
|
||||
const images = entries.filter((name) => {
|
||||
if (name.startsWith(PLACEHOLDER_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
});
|
||||
|
||||
if (images.length === 0) {
|
||||
console.error("No images found in public/");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of images) {
|
||||
const inputPath = path.join(PUBLIC_DIR, name);
|
||||
const ext = path.extname(name);
|
||||
const base = path.basename(name, ext);
|
||||
const outputPath = path.join(PUBLIC_DIR, `${PLACEHOLDER_PREFIX}${base}${ext}`);
|
||||
|
||||
// Skip if placeholder already exists and is newer than source
|
||||
if (fs.existsSync(outputPath)) {
|
||||
const srcMtime = fs.statSync(inputPath).mtimeMs;
|
||||
const outMtime = fs.statSync(outputPath).mtimeMs;
|
||||
if (outMtime > srcMtime) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(inputPath)
|
||||
.resize(PLACEHOLDER_WIDTH)
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outputPath);
|
||||
|
||||
const stats = fs.statSync(outputPath);
|
||||
console.error(
|
||||
`Generated ${PLACEHOLDER_PREFIX}${base}${ext} (${PLACEHOLDER_WIDTH}px wide, ${stats.size} bytes)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -6,7 +6,7 @@
|
||||
* --link-accent, --page-border.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-jsx";
|
||||
import "prismjs/components/prism-tsx";
|
||||
@@ -416,6 +416,94 @@ export function CodeBlock({ children, lang = "jsx", lineHeight = "1.85" }: { chi
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Pixelated placeholder image
|
||||
Uses a tiny pre-generated image with CSS image-rendering: pixelated
|
||||
(nearest-neighbor / point sampling in GPU terms) for a crisp mosaic
|
||||
effect. The real image fades in on top once loaded — no flash because
|
||||
the placeholder stays underneath and the real image starts at opacity 0.
|
||||
========================================================================= */
|
||||
|
||||
export function PixelatedImage({
|
||||
src,
|
||||
placeholder,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
src: string;
|
||||
placeholder: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Handles both the normal onLoad event and the case where the image is
|
||||
// already cached (img.complete is true before React mounts the handler).
|
||||
const imgRef = useCallback((img: HTMLImageElement | null) => {
|
||||
if (img?.complete && img.naturalWidth > 0) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: `${width}px`,
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
overflow: "hidden",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Placeholder: tiny image rendered with nearest-neighbor sampling */}
|
||||
<img
|
||||
src={placeholder}
|
||||
alt=""
|
||||
aria-hidden
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
imageRendering: "pixelated",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Real image: starts invisible, fades in over the placeholder */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
}}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: "opacity 0.4s ease",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Chart placeholder (dark box with animated line)
|
||||
========================================================================= */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
List,
|
||||
OL,
|
||||
Li,
|
||||
PixelatedImage,
|
||||
} from "website/src/components/markdown";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
@@ -70,8 +71,9 @@ export default function IndexPage() {
|
||||
</P>
|
||||
|
||||
<div className="bleed" style={{ display: "flex", justifyContent: "center" }}>
|
||||
<img
|
||||
<PixelatedImage
|
||||
src="/screenshot@2x.png"
|
||||
placeholder="/placeholder-screenshot@2x.png"
|
||||
alt="Playwriter controlling Chrome with accessibility labels overlay"
|
||||
width={1280}
|
||||
height={800}
|
||||
@@ -102,7 +104,7 @@ export default function IndexPage() {
|
||||
<Section id="getting-started" title="Getting started">
|
||||
|
||||
<P>
|
||||
<strong>Three steps</strong> and your agent is browsing.
|
||||
<strong>Four steps</strong> and your agent is browsing.
|
||||
</P>
|
||||
|
||||
<OL>
|
||||
@@ -110,13 +112,22 @@ export default function IndexPage() {
|
||||
Install the{" "}
|
||||
<A href="https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe">Chrome extension</A>
|
||||
</Li>
|
||||
<Li>Click the extension icon on a tab — it turns green</Li>
|
||||
<Li>Install CLI and run your first command:</Li>
|
||||
<Li>Click the extension icon on a tab {" \u2014 "} it turns green</Li>
|
||||
<Li>Install the CLI:</Li>
|
||||
</OL>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
npm i -g playwriter
|
||||
playwriter -s 1 -e "await page.goto('https://example.com')"
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
Then install the <strong>skill</strong> {" \u2014 "} it teaches your agent how to use
|
||||
Playwriter: which selectors to use, how to avoid timeouts, how to
|
||||
read snapshots, and all available utilities.
|
||||
</P>
|
||||
|
||||
<CodeBlock lang="bash">{dedent`
|
||||
npx -y skills add remorses/playwriter
|
||||
`}</CodeBlock>
|
||||
|
||||
<P>
|
||||
|
||||
Reference in New Issue
Block a user