diff --git a/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md b/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md new file mode 100644 index 00000000..ea10e5c3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md @@ -0,0 +1,869 @@ +# 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