# Resize & Rotate/Flip UX Redesign Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip. **Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes. **Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react **Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md` --- ### Task 1: Create SideBySideComparison Component **Files:** - Create: `apps/web/src/components/common/side-by-side-comparison.tsx` - [ ] **Step 1: Create the component file** ```tsx import { useState } from "react"; interface SideBySideComparisonProps { beforeSrc: string; afterSrc: string; beforeSize?: number; afterSize?: number; } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } export function SideBySideComparison({ beforeSrc, afterSrc, beforeSize, afterSize, }: SideBySideComparisonProps) { const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null); const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null); const savingsPercent = beforeSize && afterSize && beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100).toFixed(1) : null; const checkerboard = { backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)`, backgroundSize: "16px 16px", backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px", }; return (
{/* Side-by-side images */}
{/* Original */}
Original
Original { const img = e.currentTarget; setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight }); }} />
{beforeDims && (

{beforeDims.w} × {beforeDims.h}

)} {beforeSize != null &&

{formatSize(beforeSize)}

}
{/* Resized */}
Resized
Resized { const img = e.currentTarget; setAfterDims({ w: img.naturalWidth, h: img.naturalHeight }); }} />
{afterDims && (

{afterDims.w} × {afterDims.h}

)} {afterSize != null &&

{formatSize(afterSize)}

}
{/* Size savings */} {savingsPercent !== null && (

0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`} > {Number(savingsPercent) > 0 ? `${savingsPercent}% smaller` : `${Math.abs(Number(savingsPercent))}% larger`}

)}
); } ``` - [ ] **Step 2: Verify it compiles** Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add apps/web/src/components/common/side-by-side-comparison.tsx git commit -m "feat: add SideBySideComparison component for resize results" ``` --- ### Task 2: Rewrite ResizeSettings with Tab-Based UI **Files:** - Modify: `apps/web/src/components/tools/resize-settings.tsx` - [ ] **Step 1: Rewrite resize-settings.tsx with three tabs** Replace the entire file content with: ```tsx import { useState } from "react"; import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { Download, Link, Unlink } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; type ResizeTab = "presets" | "custom" | "scale"; type FitMode = "cover" | "contain" | "fill"; const FIT_LABELS: Record = { cover: "Crop to fit", contain: "Fit inside", fill: "Stretch", }; // Group presets by platform const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; export function ResizeSettings() { const { files } = useFileStore(); const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("resize"); const [tab, setTab] = useState("presets"); const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); const [height, setHeight] = useState(""); const [percentage, setPercentage] = useState("50"); const [fit, setFit] = useState("cover"); const [lockAspect, setLockAspect] = useState(true); const [withoutEnlargement, setWithoutEnlargement] = useState(false); const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => { const key = `${preset.platform}-${preset.name}`; if (selectedPreset === key) { setSelectedPreset(null); setWidth(""); setHeight(""); } else { setSelectedPreset(key); setWidth(String(preset.width)); setHeight(String(preset.height)); } }; const handleProcess = () => { const settings: Record = {}; if (tab === "scale") { settings.percentage = Number(percentage); } else { if (width) settings.width = Number(width); if (height) settings.height = Number(height); settings.fit = tab === "presets" ? "cover" : fit; settings.withoutEnlargement = withoutEnlargement; } processFiles(files, settings); }; const hasFile = files.length > 0; const canProcess = hasFile && !processing && (tab === "scale" ? Number(percentage) > 0 : Boolean(width) || Boolean(height)); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (canProcess) handleProcess(); }; const tabClass = (t: ResizeTab) => `flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`; return (
{/* Tab selector */}
{/* Presets tab */} {tab === "presets" && (
{platforms.map((platform) => (

{platform}

{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { const key = `${preset.platform}-${preset.name}`; const isSelected = selectedPreset === key; return ( ); })}
))} {/* Don't enlarge */}
)} {/* Custom Size tab */} {tab === "custom" && (
setWidth(e.target.value)} placeholder="Auto" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
setHeight(e.target.value)} placeholder="Auto" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
{/* Fit mode */}
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( ))}
{/* Don't enlarge */}
)} {/* Scale tab */} {tab === "scale" && (
setPercentage(e.target.value)} min={1} className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
{[25, 50, 75].map((pct) => ( ))}
)} {/* Error */} {error &&

{error}

} {/* Process button */} {processing ? ( ) : ( )} {/* Download */} {downloadUrl && ( Download )} ); } ``` - [ ] **Step 2: Verify it compiles** Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add apps/web/src/components/tools/resize-settings.tsx git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)" ``` --- ### Task 3: Add CSS Transform Props to ImageViewer **Files:** - Modify: `apps/web/src/components/common/image-viewer.tsx` - [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them** Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided. Changes to make: 1. Update the interface (line 5-8): ```tsx interface ImageViewerProps { src: string; filename: string; fileSize: number; cssRotate?: number; cssFlipH?: boolean; cssFlipV?: boolean; } ``` 2. Update the component destructuring (line 14): ```tsx export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) { ``` 3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms: ```tsx const previewTransform = [ cssRotate ? `rotate(${cssRotate}deg)` : "", cssFlipH ? "scaleX(-1)" : "", cssFlipV ? "scaleY(-1)" : "", ] .filter(Boolean) .join(" "); const imageStyle = fitMode === "fit" ? { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" as const, ...(previewTransform && { transform: previewTransform }), } : { transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`, transformOrigin: "center center", }; ``` - [ ] **Step 2: Verify it compiles** Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add apps/web/src/components/common/image-viewer.tsx git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview" ``` --- ### Task 4: Update RotateSettings with Live Preview Callback **Files:** - Modify: `apps/web/src/components/tools/rotate-settings.tsx` - [ ] **Step 1: Add onPreviewTransform callback prop and change button label** The component needs to: 1. Accept an optional `onPreviewTransform` callback 2. Call it on every state change (angle, flipH, flipV) via useEffect 3. Change the submit button label from "Rotate" to "Apply" Replace the entire file: ```tsx import { useState, useEffect } from "react"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { Download, RotateCcw, RotateCw, FlipHorizontal, FlipVertical, } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; export interface PreviewTransform { rotate: number; flipH: boolean; flipV: boolean; } interface RotateSettingsProps { onPreviewTransform?: (transform: PreviewTransform) => void; } export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { const { files } = useFileStore(); const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("rotate"); const [angle, setAngle] = useState(0); const [flipH, setFlipH] = useState(false); const [flipV, setFlipV] = useState(false); // Emit preview transform on every change useEffect(() => { onPreviewTransform?.({ rotate: angle, flipH, flipV }); }, [angle, flipH, flipV, onPreviewTransform]); const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360); const rotateRight = () => setAngle((a) => (a + 90) % 360); const handleProcess = () => { processFiles(files, { angle, horizontal: flipH, vertical: flipV, }); }; const hasFile = files.length > 0; const hasChanges = angle !== 0 || flipH || flipV; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (hasFile && hasChanges && !processing) handleProcess(); }; return (
{/* Quick rotate buttons */}
{/* Angle slider */}
{angle} deg
setAngle(Number(e.target.value))} className="w-full mt-1" />
{/* Flip buttons */}
{/* Error */} {error &&

{error}

} {/* Process */} {processing ? ( ) : ( )} {/* Download */} {downloadUrl && ( Download )} ); } ``` - [ ] **Step 2: Verify it compiles** Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add apps/web/src/components/tools/rotate-settings.tsx git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply" ``` --- ### Task 5: Update tool-page.tsx for Conditional Rendering **Files:** - Modify: `apps/web/src/pages/tool-page.tsx` - [ ] **Step 1: Add imports, preview transform state, and conditional result rendering** Changes to make in `tool-page.tsx`: 1. Add imports at the top (after existing imports, around line 8): ```tsx import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; ``` 2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63): ```tsx const SIDE_BY_SIDE_TOOLS = new Set(["resize"]); const LIVE_PREVIEW_TOOLS = new Set(["rotate"]); ``` 3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65): ```tsx function ToolSettingsPanel({ toolId, onPreviewTransform, }: { toolId: string; onPreviewTransform?: (t: PreviewTransform) => void; }) { // Phase 2: Core tools if (toolId === "resize") return ; if (toolId === "crop") return ; if (toolId === "rotate") return ; ``` (Rest of ToolSettingsPanel stays identical) 4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177): ```tsx const [previewTransform, setPreviewTransform] = useState(null); ``` 5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback: ```tsx ``` 6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372): ```tsx {isNoDropzone ? (

Configure settings and generate.

) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( ) : hasProcessed && originalBlobUrl ? ( ) : hasFile && originalBlobUrl ? ( ) : ( )} ``` **Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed. - [ ] **Step 2: Verify it compiles** Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` Expected: Build succeeds - [ ] **Step 3: Commit** ```bash git add apps/web/src/pages/tool-page.tsx git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate" ``` --- ### Task 6: Docker Rebuild and Test **Files:** - No file changes — build and run existing Docker setup - [ ] **Step 1: Build Docker image** ```bash cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image docker compose -f docker/docker-compose.yml build ``` - [ ] **Step 2: Start the container** ```bash docker compose -f docker/docker-compose.yml up -d ``` - [ ] **Step 3: Verify the app is running** ```bash curl -f http://localhost:1349/api/v1/health ``` Expected: Health check passes - [ ] **Step 4: Report to user for UI testing** App is running at `http://localhost:1349`. User can test: - Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view - Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer