diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e34db31..1ca5341 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/website/package.json b/website/package.json index d59bb83..7ebbd3f 100644 --- a/website/package.json +++ b/website/package.json @@ -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", diff --git a/website/public/placeholder-favicon-16.png b/website/public/placeholder-favicon-16.png new file mode 100644 index 0000000..0d386bd Binary files /dev/null and b/website/public/placeholder-favicon-16.png differ diff --git a/website/public/placeholder-favicon-32.png b/website/public/placeholder-favicon-32.png new file mode 100644 index 0000000..425b1ad Binary files /dev/null and b/website/public/placeholder-favicon-32.png differ diff --git a/website/public/placeholder-og-image.png b/website/public/placeholder-og-image.png new file mode 100644 index 0000000..5cfdbea Binary files /dev/null and b/website/public/placeholder-og-image.png differ diff --git a/website/public/placeholder-screenshot@2x.png b/website/public/placeholder-screenshot@2x.png new file mode 100644 index 0000000..fb654bc Binary files /dev/null and b/website/public/placeholder-screenshot@2x.png differ diff --git a/website/scripts/generate-placeholders.ts b/website/scripts/generate-placeholders.ts new file mode 100644 index 0000000..61304c1 --- /dev/null +++ b/website/scripts/generate-placeholders.ts @@ -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(); diff --git a/website/src/components/markdown.tsx b/website/src/components/markdown.tsx index b993f51..02f4aee 100644 --- a/website/src/components/markdown.tsx +++ b/website/src/components/markdown.tsx @@ -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 ( +
+ {/* Placeholder: tiny image rendered with nearest-neighbor sampling */} + + {/* Real image: starts invisible, fades in over the placeholder */} + {alt} { + setLoaded(true); + }} + style={{ + position: "relative", + width: "100%", + height: "100%", + objectFit: "cover", + opacity: loaded ? 1 : 0, + transition: "opacity 0.4s ease", + zIndex: 1, + }} + /> +
+ ); +} + /* ========================================================================= Chart placeholder (dark box with animated line) ========================================================================= */ diff --git a/website/src/routes/_index.tsx b/website/src/routes/_index.tsx index 049c8f2..8742067 100644 --- a/website/src/routes/_index.tsx +++ b/website/src/routes/_index.tsx @@ -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() {

- Playwriter controlling Chrome with accessibility labels overlay

- Three steps and your agent is browsing. + Four steps and your agent is browsing.

    @@ -110,13 +112,22 @@ export default function IndexPage() { Install the{" "} Chrome extension -
  1. Click the extension icon on a tab — it turns green
  2. -
  3. Install CLI and run your first command:
  4. +
  5. Click the extension icon on a tab {" \u2014 "} it turns green
  6. +
  7. Install the CLI:
{dedent` npm i -g playwriter - playwriter -s 1 -e "await page.goto('https://example.com')" + `} + +

+ Then install the skill {" \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. +

+ + {dedent` + npx -y skills add remorses/playwriter `}