From 3b3959f5abecbd793665396dc0c46acde7132d04 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 21 Feb 2026 15:22:39 +0100 Subject: [PATCH] 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 --- pnpm-lock.yaml | 7 +- website/package.json | 4 +- website/public/placeholder-favicon-16.png | Bin 0 -> 1163 bytes website/public/placeholder-favicon-32.png | Bin 0 -> 636 bytes website/public/placeholder-og-image.png | Bin 0 -> 1158 bytes website/public/placeholder-screenshot@2x.png | Bin 0 -> 380 bytes website/scripts/generate-placeholders.ts | 67 ++++++++++++++ website/src/components/markdown.tsx | 90 ++++++++++++++++++- website/src/routes/_index.tsx | 21 +++-- 9 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 website/public/placeholder-favicon-16.png create mode 100644 website/public/placeholder-favicon-32.png create mode 100644 website/public/placeholder-og-image.png create mode 100644 website/public/placeholder-screenshot@2x.png create mode 100644 website/scripts/generate-placeholders.ts 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 0000000000000000000000000000000000000000..0d386bd5b8f978b4aa5394d940aaae2193780458 GIT binary patch literal 1163 zcmV;61a$j}P){ zj~(oJ{?X9T@Vv3H@ue=VQ;Dm5RQaruRk`~^0DPBG!)cWdR&O14uxAYG2haGUs9xzy zT^D<+b5iAtzWc6o9%Vkmp~ihu@&N%vPURc=&};R_uxCI(hxI(B@(I)^YHMqgDu#Yb zoIcA6c4FA|nwy(nLyNuy*%vyAF$wWdL9&~ z--bEv=}*EY$QO_X0qAp4mPAAKi||ap`y&9@!5(%!TOw%^B`o%iJ|{fGFao$5a^V|x zJzJ;LdM@ZWiw6bA9PWJG#%Cgt$(P($TDSD7)hoGx7nB;= zPQpcvTrMYjdwViDIcY#+oKV&TfDVTuJlfpcG}#_C3Wb7UU~O$p#>U1B@M@hgvI>IL zK$D>-92^{&MDx>*=NM;@Cr3v|va+&bzM%}Ga==wVXdehxLw9$#jE|3-ok=fbV z@|GQS9!PQi=Lr7P({R{U>;40bm_s|BW8BYR*oi2?B}5;Fo(aQ@haEhEpxhBI82d+O z!hdlpu5=iAbSnNA_ktjRiuZ#5elx0cH?()UT#a|byuD+F-wmT~$0UfoFJx-G9cS$= d`(xPc_$Nbw(REquQ$hd$002ovPDHLkV1i3UA}jy^ literal 0 HcmV?d00001 diff --git a/website/public/placeholder-favicon-32.png b/website/public/placeholder-favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..425b1ad52df32547a3e60ccf1012ed8c3c5b1787 GIT binary patch literal 636 zcmV-?0)zdDP)lOb8>&lWrae4y&%Jp<;Zb#IZTcnhs9xYI2vfvVW;CDA z$uJD6)oN5M7LyXQW1?Y%3IMrhx7&>lhXbuvD;f@mRIOH@o_SM%#YEQ%6|h(=D99Yg zq5XdU5ZG?Fsa!5ULlr}S&14mTlfmscxy)?arp;zUlgWgd%_fyfB~>8~ll69Nf|$eY zs&+n~X}MfdqtQ?i;j$=|NI1kiu1%*?B`-t@Kp2p4h`FkrPNzp+Bq9KtAls2}sv#H* z2Fl6ER2KQmG0d*lYb^3u0^m|)yUZ?^3-$Z`U-ExZ{q4JBmjBD9!S+fglFnX_j}- z{Xn27NSaC6s2JEx>kkOVm^5clGncVdCMz?rjhGq|iNO63vuO~9iYYSJawgpKe4ovC z@1vX951VVUlAv^ZAo@K}gLx3& zJq0mT4)J3R#F=dn=XOJ!-w!c<2+9Q?6{L>*Cuy!^I5C;)L`Wh;i4&rd+CsIvp&TNB zk03`2Ax@M+{oy63qcu>+nxOvlAtBlbaRA~%FBco20uh*-2hvlnO-V~Yf_pAdZnII@Z^6hREHfjC(S_4FH1&%Q;7_o1HO&4uXOl zcyjnWg%>~iphVT>OSOL`UOCUy`hd8x?F4RLk_cq^t z7nJcYpp1PEae61j*hh3`FBk8MIMW~5rm*}34N`x=X~yq!;i6T-FN^4e-H9u!oS1r! zTT;i7xggU8`7kw@2l@DukVn=)K2-(zY%S!`M#x8N5$Y*N=wRWsdCJK$LNqYI$8@BV zAU!+|JJcV6xZZ)eUR<81EAQ`da-IkHn#cZpTpglQW1ARoJJrsB-5mQOdGhD{(0rM;9Q*FJqkFa;-^{b2 z*SZt~OKkA1TngXQx$tkY!M}AG0^3(mD-mdt5U6n=P_+!fs$2wL( zvmGO!N>GQ_Gr$hW6CDhGeUknq2lUA%nD0u3F3E({*|U&svtjOn1z4DwiHyZdke->1 zhtjh!Z@v}Q2Qx5p*1d>}H$Z1JL7$ietu6s+Gw;Eo#Sg=rl8Siaop>~B4&<&9D5K2` ze(-nuY5hMYVXRgQU0giEV}f3fJ1iEM5{)n?CnLpThQ*u=GnHgYoW5o-BtWaZ9dWU- z(+WRlG#EJ70)t+MTW`}M_x@B&?es!)y#V=4JCuuqH~1=o(C~-7a;5W_|OO*aZ2zIV%v2tX8ev3IWy=;C0rLde)F8R?-&cL@Pn|ltSz+hUh4S@|8DA z0Ley#%2-j$Sy5kMMXhE&Y$inW6A#5zJ$GVn%jOC!%eW1s1X7J%M3-C-3w z0Uo0Zc??)gJ@~07*qoM6N<$f&h~gy#N3J literal 0 HcmV?d00001 diff --git a/website/public/placeholder-screenshot@2x.png b/website/public/placeholder-screenshot@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fb654bc6dd3216f4ec15c77c9852ef3120a75e76 GIT binary patch literal 380 zcmV-?0fYXDP)B#%tcllOL`eRJ`a!&i@ulj&aHyaoFu2%cx zoDmor`sls;+>7lmPbx7p`@n8-RXbiFEpa+JVLU2lOEHOSI3^hdrT_o{32;bRa{vGf z6951U69E94oEQKA0L4i}K~#9!ebLbtf*=qCP;kvqAq1tcvj6|gW<6qM;=Jg?Wne)R zt(K)ys%rdDpZ_CH9}zA)WCn&s1>r&JEr2kVR { + 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 `}