# wimp - Work Log Web image manipulation tool. In-browser cropping, templates, transforms, and high-quality export. Everything runs locally; images never leave the browser. This log records what was built, the key decisions, and how it was verified. See ARCHITECTURE.md for the design and VERIFICATION.md for the test evidence. Outstanding work lives in docs/deferred/. ## Goal (from the brief) Three layers, kept separate for sanity: 1. Interactive editing UI (selection, crop handles, rotation, zoom/pan) on the main thread, talking to a canvas. 2. Pixel processing (the actual byte transform) in a Web Worker so the UI never freezes on a large photo. 3. Encoding (final JPEG/WebP/AVIF/PNG) in a worker using dedicated codec WASM for quality control. Plus built-in templates ("crop for og:image cover", "passport photo", etc.) as plain data. ## Stack chosen - TypeScript, React 19.2 + React Compiler (babel-plugin-react-compiler 1.0). - Vite 8 (Rolldown/Oxc). React Compiler is layered in via @rolldown/plugin-babel - reactCompilerPreset (plugin-react v6 dropped its own babel option). - Tailwind CSS v4 via @tailwindcss/vite, with design tokens in CSS @theme (no tailwind.config.js, no postcss.config). - react-easy-crop 5.5 for the interactive crop UI. - wasm-vips 0.0.17 (libvips compiled to WASM) for high-quality resampling. - jSquash codecs (@jsquash/jpeg, /webp, /png, /avif) for encoding. - Node 22, npm. ## Build order followed 1. Vite + React 19 + TS + Tailwind v4 + React Compiler skeleton; drag/drop, click, and clipboard-paste file loader showing the image in the cropper. 2. react-easy-crop wired with aspect-locked crop and an "Original" (source ratio) mode, plus zoom. 3. Template data type; presets hardcoded (OG, Twitter/X, Instagram square and portrait, Story, Avatar, Favicon, US passport). Selecting one sets aspect, output size, and encoder defaults. 4. Export worker: source bytes are transferred (not copied) to the worker, decoded via createImageBitmap, transformed on an OffscreenCanvas, and a Blob is returned. 5. jSquash encoding with a quality slider; native canvas convertToBlob as a fallback. 6. Rotate, flip, brightness/contrast/saturation (live), then a second wave of features (below). ## What got built Layer 1 (UI, main thread): - react-easy-crop with crop, zoom, rotation, and flip. Flip and rotation use the Cropper "transform" prop following react-easy-crop's official recipe, so the preview matches the export. - Live color preview via CSS filter on the cropped media (mediaStyle). The same filter string drives the canvas export, so what you see is what you get. - Template picker popover (fixed-positioned, measured from the trigger so it is never clipped by the scrollable tray). - Panels: Crop and frame (aspect presets + zoom), Transform (90 deg buttons, rotation slider, flip H/V), Effects (one-click looks), Adjust (sliders), Export (format, quality, output info, size cap, result actions). - Dark theme; two side trays with the canvas centered; responsive drawers below the lg breakpoint. Layer 2 (pixel processing, worker): - Stage 1: paint the rotated and flipped full image into its rotation bounding box (matches react-easy-crop's coordinate space). - Stage 2: crop the selected region and resample to the target size. libvips (lanczos3) is used for meaningful downscales when available; otherwise the canvas resampler with imageSmoothingQuality 'high'. Color adjustments are baked in via the shared filter string. JPEG is flattened onto white. - Optional "limit long edge" cap scales the output down proportionally. Layer 3 (encoding, worker): - jSquash MozJPEG / WebP / OxiPNG / AVIF, dynamically imported per format so a codec only loads when used. Falls back to OffscreenCanvas.convertToBlob if a codec fails to load. Templates: a plain data array (src/templates.ts) of the shape { id, name, group, description, aspect, output, encoder, framing? }. ## Key decisions - React Compiler: plugin-react v6 no longer takes a babel option (Vite 8 uses Oxc). The compiler runs as a babel preset through @rolldown/plugin-babel: plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()]. Confirmed active by finding useMemoCache / \_\_COMPILER_RUNTIME in the prod bundle. - TypeScript config: a single non-composite tsconfig with lib = [ESNext, DOM, DOM.Iterable, WebWorker] and skipLibCheck. This lets the worker use worker globals while the app uses DOM globals; the duplicate-lib identifier errors live in the .d.ts files and are suppressed by skipLibCheck. Build script is "tsc && vite build". - wasm-vips as progressive enhancement, not a hard dependency. It is lazily imported inside the worker, initialized once with a timeout, and cached. Any failure - or a browser without cross-origin isolation - resolves to null and the canvas resampler takes over. The engine actually used (vips or canvas) is reported back and shown in the UI. This honors the request for vips while keeping correctness independent of it. - Cross-origin isolation: the dev and preview servers send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp so SharedArrayBuffer is available for the threaded WASM builds (vips, jSquash mt). A production host must send the same headers to get the vips path; otherwise it falls back to canvas. - "Free crop": react-easy-crop is aspect-based and has no resizable crop box. "Original" maps the crop aspect to the source ratio; other ratios are presets. True corner-drag free crop would need react-konva (see docs/deferred). - Bundler config: jSquash and wasm-vips are listed in optimizeDeps.exclude (they ship their own .wasm and spawn their own workers); build.target is esnext for top-level await; worker.format is 'es'. - EXIF orientation: the worker decodes with createImageBitmap(blob, { imageOrientation: 'from-image' }) so exports match the cropper's , which applies EXIF automatically. ## Commit log (branch claude/happy-heisenberg-TJROI) - 94200a8 Build wimp: in-browser image editor (React 19 + compiler, Tailwind v4) - a2e1fd5 Clipboard items index loop (DataTransferItemList) + inline favicon - 4dac6cb wasm-vips high-quality resampling + EXIF orientation fix - 4015169 Effects presets, output size cap, result actions, keyboard shortcuts - 3073ad5 Label the template trigger for a11y and reliable targeting - df2149f Responsive editor (slide-in drawers below lg) - 07df4fd YouTube thumbnail, LinkedIn cover, web banner templates - f1b98de Face-framing guide overlay for identity templates ## Verification summary Verified end to end in a real headless Chromium (Playwright), not just by building. All four codecs encode, vips drives downscales, the canvas fallback path works, exact output dimensions are produced, keyboard shortcuts and the responsive drawers work, and the framing guide renders only for identity templates. Zero console or page errors; crossOriginIsolated was true. Full matrix and reproduction steps are in VERIFICATION.md. ## How to run - npm install - npm run dev (http://localhost:5173) - npm run build (tsc + vite build, output in dist/) - npm run preview (serves dist/ with the COOP/COEP headers)