[object Object]

← back to Resize It

[overnight] pre-debate baseline

3d9cc3a9d22edb15ae71acfe0e0f1661d4d60ff2 · 2026-05-04 02:23:31 -0700 · SteveStudio2

Files touched

Diff

commit 3d9cc3a9d22edb15ae71acfe0e0f1661d4d60ff2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 02:23:31 2026 -0700

    [overnight] pre-debate baseline
---
 .env.example                         |   3 +
 .gitignore                           |  29 ++++
 CHANGES.md                           |  19 ++
 CODEX_POKE.md                        |  62 +++++++
 CODEX_REREVIEW.md                    |  29 ++++
 SKILL.md                             | 276 +++++++++++++++++++++++++++++
 scripts/analyze_proportions.py       | 115 +++++++++++++
 scripts/download_spoonflower.py      | 262 ++++++++++++++++++++++++++++
 scripts/resize_image.py              | 182 ++++++++++++++++++++
 scripts/spoonflower_full_workflow.py | 231 +++++++++++++++++++++++++
 server.py                            | 297 ++++++++++++++++++++++++++++++++
 web-interface.html                   | 325 +++++++++++++++++++++++++++++++++++
 12 files changed, 1830 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4ef4353
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+SPOONFLOWER_EMAIL=your-spoonflower-email@example.com
+SPOONFLOWER_PASSWORD=your-spoonflower-password
+GEMINI_API_KEY=your-gemini-api-key
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f2506d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+# secrets / env
+.env
+.env.*
+!.env.example
+*.session-secret
+*.private-key
+
+# python
+__pycache__/
+*.pyc
+*.pyo
+venv/
+.venv/
+
+# node
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+
+# build artifacts
+dist/
+build/
+.next/
+*.log
+
+# editor / OS
+.DS_Store
+.idea/
+.vscode/
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..ac83a6c
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,19 @@
+## Applied
+- `server.py:23` — removed hardcoded Spoonflower credential defaults and now fails fast when `SPOONFLOWER_EMAIL` or `SPOONFLOWER_PASSWORD` is missing — prevents source fallback secrets.
+- `server.py:18`, `server.py:66`, `server.py:154`, `server.py:197`, `server.py:226` — replaced hardcoded `/root/.claude/skills/resize-it` subprocess cwd values with `Path(__file__).resolve().parent` — makes scripts run from this checkout.
+- `server.py:119`, `server.py:174` — replaced `request.json` with `request.get_json(silent=True)` plus a 400 response for missing or malformed JSON — avoids 500s on bad request bodies.
+- `server.py:291` — changed advertised URL to localhost — removes public-IP guidance for a local tool.
+- `server.py:292` — changed Flask bind from `0.0.0.0`/`debug=True` to `127.0.0.1`/`debug=False` — avoids exposing the debug server.
+- `SKILL.md:89` — replaced embedded Gemini API key with `process.env.GEMINI_API_KEY` and a fail-fast check — removes the secret from docs/source.
+- `.env.example:1` — added placeholder environment variables for Spoonflower credentials and Gemini API key — documents required configuration without storing real secrets.
+
+## Deferred (needs Steve)
+- P0 — `.env:1` — real Spoonflower credentials may still exist locally — deferred because `.env` was explicitly left untouched and real credentials must be rotated by Steve.
+- P0 — `SKILL.md:89` — exposed Gemini API key needs revocation and history scrubbing if shared — source was cleaned, but rotation/history cleanup needs Steve.
+- P0 — `server.py:41`, `server.py:115`, `server.py:170` — no auth on local API endpoints that can use stored credentials — deferred because auth design/token ownership is outside the safe fix list.
+- P0 — `server.py:183` — Spoonflower URL validation is substring-only — deferred because stronger host validation was not in the safe fix list.
+- P0 — `server.py:208` — newest-image selection from global `/tmp` can cross requests — deferred because request-scoped temp workflow changes are broader than safe surgical fixes.
+- P1 — `server.py:52` — uploaded files can overwrite the same sanitized filename — deferred because filename/session behavior was not in the safe fix list.
+- P1 — `server.py:277` — download route can expose matching files in `/tmp` — deferred because allowlist/session mapping is a broader behavior change.
+- P1 — `scripts/spoonflower_full_workflow.py:201` — upload success is inferred after sleep/screenshot — deferred because adding confirmed save-state assertions needs workflow testing.
+- P1 — `web-interface.html:263` — frontend reads `error.message` while server returns `error` — deferred because public-facing HTML changes were out of scope.
diff --git a/CODEX_POKE.md b/CODEX_POKE.md
new file mode 100644
index 0000000..dbdca05
--- /dev/null
+++ b/CODEX_POKE.md
@@ -0,0 +1,62 @@
+## Failure mode
+Starting/importing `server.py` fails before `/` or `/api/resize` can run when `SPOONFLOWER_EMAIL` or `SPOONFLOWER_PASSWORD` is unset. The local resize path does not use Spoonflower credentials, but import-time validation blocks it anyway. `server.py:23-34`, `server.py:41-67`
+
+## Root cause
+`server.py:33-34` — the module raises `RuntimeError` during import if either Spoonflower env var is missing. That code runs before route handlers are usable, while the local upload/resize handler only shells out to `scripts/resize_image.py` and does not need those credentials. `server.py:23-34`, `server.py:41-67`
+
+## Origin
+REGRESSION — `CHANGES.md:2` says the recent patch removed hardcoded Spoonflower defaults and made startup fail fast when env vars are missing. `CODEX_REREVIEW.md:5` and `CODEX_REREVIEW.md:20` identify that as broken because unrelated local resize usage is blocked.
+
+## Proposed patch
+`server.py:26-34`
+```diff
+- missing_env = [
++ def missing_spoonflower_env():
++     return [
+-     name for name, value in {
+-         'SPOONFLOWER_EMAIL': SPOONFLOWER_EMAIL,
+-         'SPOONFLOWER_PASSWORD': SPOONFLOWER_PASSWORD,
+-     }.items()
+-     if not value
+- ]
+- if missing_env:
+-     raise RuntimeError(f"Missing required environment variables: {', '.join(missing_env)}")
++         name for name, value in {
++             'SPOONFLOWER_EMAIL': SPOONFLOWER_EMAIL,
++             'SPOONFLOWER_PASSWORD': SPOONFLOWER_PASSWORD,
++         }.items()
++         if not value
++     ]
+```
+
+`server.py:119-124`
+```diff
+  data = request.get_json(silent=True)
+  if data is None:
+      return jsonify({'error': 'Invalid or missing JSON body'}), 400
++ missing_env = missing_spoonflower_env()
++ if missing_env:
++     return jsonify({'error': f"Missing required environment variables: {', '.join(missing_env)}"}), 500
+
+  design_url = data.get('designUrl')
+```
+
+`server.py:174-178`
+```diff
+  data = request.get_json(silent=True)
+  if data is None:
+      return jsonify({'error': 'Invalid or missing JSON body'}), 400
++ missing_env = missing_spoonflower_env()
++ if missing_env:
++     return jsonify({'error': f"Missing required environment variables: {', '.join(missing_env)}"}), 500
+
+  url = data.get('url')
+```
+
+## Side-effects to handle
+- `server.py:115-155` and `server.py:170-198` are the only routes that pass `SPOONFLOWER_EMAIL` / `SPOONFLOWER_PASSWORD` to subprocesses, so credential validation should live there.
+- `server.py:39` still serves `web-interface.html` from `'.'`; that is a separate missed checkout-relative call-site noted in `CODEX_REREVIEW.md:24-25`.
+- `server.py:123` and `server.py:178` can still 500 if JSON is non-object, as noted in `CODEX_REREVIEW.md:9`.
+
+## Risks of the patch
+This changes missing Spoonflower credentials from startup failure to endpoint-time failure, so deployments that relied on fail-fast startup would no longer catch misconfiguration until a Spoonflower route is called. Existing `.env` files are still ignored because no dotenv loader is present.
\ No newline at end of file
diff --git a/CODEX_REREVIEW.md b/CODEX_REREVIEW.md
new file mode 100644
index 0000000..64f757b
--- /dev/null
+++ b/CODEX_REREVIEW.md
@@ -0,0 +1,29 @@
+## Verdict
+FIX BEFORE SHIP
+
+## Patches reviewed
+`server.py:23` — hardcoded Spoonflower credential defaults — removed, but validation now happens at import and blocks unrelated local resize usage — BROKEN
+
+`server.py:18`, `server.py:66`, `server.py:154`, `server.py:197`, `server.py:226` — hardcoded subprocess cwd — all subprocess calls now use `PROJECT_ROOT` — good
+
+`server.py:119`, `server.py:174` — bad/missing JSON caused 500s — malformed/missing bodies now 400; non-object JSON can still 500 at `.get()` — concern
+
+`server.py:291` — advertised public URL — now advertises localhost — good
+
+`server.py:292` — public debug bind — now loopback with debug disabled — good
+
+`SKILL.md:89` — embedded Gemini API key — now reads `process.env.GEMINI_API_KEY` and fails fast — good
+
+`.env.example:1` — missing config docs — placeholders added, but app does not load `.env` — concern
+
+## New issues introduced
+`server.py:33` — missing Spoonflower env vars now raise during module import, so `/` and `/api/resize` cannot run without Spoonflower credentials even though local resize does not use them — P1
+
+`server.py:23` — credentials are read only from process env; existing local `.env` will be ignored because no dotenv loader is present — P1
+
+## Missed call-sites (same problem elsewhere)
+`server.py:39` — project file serving still uses `send_from_directory('.', 'web-interface.html')`, so launching from outside the repo can still miss checkout-relative files
+
+## Recommendations
+- Move Spoonflower credential validation into `/api/spoonflower` and `/api/upload-to-spoonflower`, or load `.env` before the global check if startup must require credentials.
+- Validate `get_json()` results are dicts before calling `.get()` on both JSON routes.
\ No newline at end of file
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..12aca38
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,276 @@
+---
+name: resize-it
+description: Resize images to 24" width at 150 DPI while maintaining aspect ratio. Analyzes quality degradation from scaling and uses AI vision (Claude/Gemini) to detect proportion distortions. Includes Spoonflower design download automation with authentication. Use when users need to resize images to specific print dimensions with quality and proportion verification, or when downloading designs from Spoonflower.
+---
+
+# Resize It
+
+## Overview
+
+This skill resizes images to print-ready dimensions (24" width at 150 DPI) while maintaining aspect ratio and analyzing the result for quality degradation and proportion distortion. It combines deterministic image processing with AI-powered visual analysis to ensure users understand the impact of resizing operations. For Spoonflower designs, it includes automated download capabilities with authentication support.
+
+## Workflow
+
+Follow this workflow when a user requests image resizing:
+
+### 0. Download from Spoonflower (if applicable)
+
+**IMPORTANT URL REQUIREMENTS:**
+
+The user MUST provide a REAL, WORKING design page URL copied from their browser's address bar. Do NOT try to guess URL patterns from just a numeric ID.
+
+**Accept these canonical design URLs:**
+- `https://www.spoonflower.com/en/design/12345678-design-name-here`
+- `https://www.spoonflower.com/en/fabric/12345678-design-name-here`
+- `https://www.spoonflower.com/en/wallpaper/12345678-design-name-here`
+
+**REJECT these URLs (listing/artist pages, no File Options menu):**
+- `https://www.spoonflower.com/artists/designs/12345678`
+- `https://www.spoonflower.com/en/artists/wallpaper/12345678`
+
+If the user provides a Spoonflower URL, use the download script to authenticate and download the highest resolution file:
+
+```bash
+python3 scripts/download_spoonflower.py <spoonflower_url> <email> <password>
+```
+
+**Parameters:**
+- `spoonflower_url`: Full canonical URL to the Spoonflower design page (must include `/en/` and design slug)
+- `email`: Spoonflower account email
+- `password`: Spoonflower account password
+
+**How it works:**
+1. Logs in to Spoonflower first
+2. Navigates to the design page (must be owned by the logged-in account)
+3. Locates and clicks "File Options" button
+4. Downloads the highest resolution file available
+5. Saves to /tmp/ directory
+
+**Note:** Requires Playwright installation (`pip3 install playwright && python3 -m playwright install chromium`)
+
+**If the URL doesn't work:**
+- Ask the user to open their browser, log in to Spoonflower, navigate to the exact design where they see "File Options", and copy the full URL from the address bar.
+
+### 1. Initial Resize with Quality Analysis
+
+Execute the resize script to perform the image transformation:
+
+```bash
+python3 scripts/resize_image.py <input_image_path> [--output <output_path>] [--width 24] [--dpi 150] [--quality 95]
+```
+
+**Parameters:**
+- `input_image_path`: Path to the source image (required)
+- `--output`: Custom output path (optional, auto-generated if not provided)
+- `--width`: Target width in inches (default: 24)
+- `--dpi`: Resolution in dots per inch (default: 150)
+- `--quality`: JPEG quality 1-100 (default: 95)
+
+**Script Output:**
+The script outputs detailed information including:
+- Original and resized dimensions (pixels)
+- Physical dimensions (inches)
+- Scale factor
+- Quality warnings if upscaling or significant downscaling detected
+
+**Quality Warning Types:**
+- **Upscaling Warning**: Image is being enlarged, which may cause pixelation and quality loss
+- **Significant Downscaling Info**: Image is being reduced by more than 50%, some detail will be lost
+
+### 2. AI-Powered Proportion Analysis
+
+After resizing, use AI vision capabilities to analyze proportions:
+
+#### Gemini 3.0 Vision API (MANDATORY - ONLY USE THIS)
+
+Use Google Gemini 3.0 for fast, accurate image analysis:
+
+```typescript
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_API_KEY) {
+  throw new Error('Missing required environment variable: GEMINI_API_KEY');
+}
+
+async function analyzeWithGemini(imageBase64: string): Promise<any> {
+  const response = await fetch(
+    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`,
+    {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        contents: [{
+          parts: [
+            { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } },
+            { text: `Analyze this image for proportion/distortion issues.
+Return JSON: { "distorted": boolean, "type": "none|horizontal|vertical", "severity": "none|minor|severe", "notes": "..." }` }
+          ]
+        }],
+        generationConfig: { temperature: 0.1, maxOutputTokens: 300 }
+      })
+    }
+  );
+  return response.json();
+}
+```
+
+**Why Gemini 3.0 ONLY:**
+- Faster response times
+- Excellent at detecting subtle distortions
+- Cost-effective for batch processing
+- **MANDATORY** - Do NOT use Claude Vision
+
+#### Legacy Script (For Data Prep Only)
+
+The `analyze_proportions.py` script prepares image data and prompts for external AI API calls:
+
+```bash
+python3 scripts/analyze_proportions.py --original <original_path> --resized <resized_path> [--output-format json]
+```
+
+This generates structured data for API integration with external AI services.
+
+### 3. Report Results to User
+
+Combine findings from both the resize operation and proportion analysis:
+
+**Report Template:**
+```
+✅ Image resized successfully!
+
+**Dimensions:**
+- Original: [width]x[height]px
+- Resized: [width]x[height]px
+- Physical: [width]" x [height]" @ 150 DPI
+- Scale factor: [factor]x
+
+**Quality Assessment:**
+[Include any quality warnings from resize_image.py]
+
+**Proportion Analysis:**
+[Include AI vision analysis results]
+- Distortion detected: [yes/no]
+- Severity: [none/minor/moderate/severe]
+- Observations: [list key findings]
+- Recommendation: [action to take]
+
+**Output file:** [path]
+```
+
+## Script Details
+
+### download_spoonflower.py
+
+Automated Spoonflower design downloader with authentication support using Playwright browser automation.
+
+**Key Features:**
+- Automated browser-based authentication
+- Navigates to design page and signs in
+- Clicks "File Options" menu button
+- Downloads highest resolution file available
+- Saves files to /tmp/ directory with original filename
+
+**Dependencies:**
+- Playwright: `pip3 install playwright`
+- Chromium browser: `python3 -m playwright install chromium`
+
+**Usage:**
+```bash
+python3 scripts/download_spoonflower.py "https://www.spoonflower.com/artists/designs/XXXXXX" "email@example.com" "password"
+```
+
+**Note:** This script is particularly useful for accessing high-resolution files that require authentication to download.
+
+### resize_image.py
+
+Main resizing script with built-in quality analysis.
+
+**Key Features:**
+- High-quality Lanczos resampling for best results
+- Maintains aspect ratio automatically
+- Embeds DPI metadata in output file
+- Detects and warns about upscaling (>100% scale)
+- Alerts for significant downscaling (>50% reduction)
+- Supports JPEG and PNG formats with optimization
+- Auto-generates descriptive output filenames
+- Compatible with both old and new Pillow versions
+
+**Technical Details:**
+- Target dimensions: 24" × proportional height at 150 DPI = 3600px × calculated height
+- Resampling: LANCZOS (high-quality algorithm for both up and down scaling)
+- Output naming: `{original_name}_24x{height}in_150dpi.{ext}`
+
+### analyze_proportions.py
+
+Helper script for preparing proportion analysis data for AI vision APIs.
+
+**Key Features:**
+- Base64 encodes images for API transmission
+- Generates structured analysis prompts
+- Supports single image or before/after comparison
+- Outputs JSON format for API integration
+
+**Note:** For most use cases, direct visual analysis (Option A) using Claude's built-in vision capabilities is simpler and more efficient than using this script.
+
+## Dependencies
+
+### Required (for image resizing)
+
+The resize script requires Pillow (PIL):
+
+```bash
+pip3 install Pillow
+```
+
+Verify installation:
+```bash
+python3 -c "from PIL import Image; print('Pillow installed successfully')"
+```
+
+### Optional (for Spoonflower downloads)
+
+The Spoonflower download script requires Playwright:
+
+```bash
+pip3 install playwright
+python3 -m playwright install chromium
+```
+
+Verify installation:
+```bash
+python3 -c "from playwright.sync_api import sync_playwright; print('Playwright installed successfully')"
+```
+
+## Example Usage
+
+**User request:** "Resize this image to 24 inches for printing"
+
+**Workflow:**
+1. Run: `python3 scripts/resize_image.py /path/to/image.jpg`
+2. Read both original and resized images
+3. Analyze proportions using Claude's vision
+4. Report complete findings to user
+
+**User request:** "Will this image lose quality if I make it 24\" wide?"
+
+**Workflow:**
+1. Run resize script to get quality analysis
+2. Review scale factor and warnings
+3. Perform visual analysis if concerns detected
+4. Provide clear recommendation based on findings
+
+## Tips
+
+- **Always check scale factor**: Values > 1.0 indicate upscaling (quality concern)
+- **Upscaling > 20%**: Strong warning recommended, significant quality loss likely
+- **Visual analysis**: Especially important when upscaling or when user expresses proportion concerns
+- **Custom dimensions**: Script supports different widths and DPIs via parameters
+- **Format preservation**: Script maintains original format (JPEG/PNG) unless output extension specifies otherwise
+- **Quality parameter**: For JPEGs, adjust `--quality` (1-100) to balance file size vs quality
+
+## Limitations
+
+- Script requires Pillow library installation
+- Optimal for images already near target resolution
+- Significant upscaling cannot create detail that doesn't exist in the original
+- AI proportion analysis accuracy depends on image content complexity
+- Very small or very large images may require manual parameter adjustment
diff --git a/scripts/analyze_proportions.py b/scripts/analyze_proportions.py
new file mode 100755
index 0000000..1d3f2e5
--- /dev/null
+++ b/scripts/analyze_proportions.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+AI-powered image proportion analysis
+Analyzes images to detect distortion or incorrect proportions
+"""
+
+import sys
+import json
+import base64
+import argparse
+from pathlib import Path
+
+
+def encode_image_to_base64(image_path):
+    """Encode image to base64 for API transmission"""
+    with open(image_path, 'rb') as img_file:
+        return base64.b64encode(img_file.read()).decode('utf-8')
+
+
+def get_image_mime_type(image_path):
+    """Determine MIME type from file extension"""
+    suffix = Path(image_path).suffix.lower()
+    mime_types = {
+        '.jpg': 'image/jpeg',
+        '.jpeg': 'image/jpeg',
+        '.png': 'image/png',
+        '.gif': 'image/gif',
+        '.webp': 'image/webp',
+        '.bmp': 'image/bmp'
+    }
+    return mime_types.get(suffix, 'image/jpeg')
+
+
+def analyze_image_proportions_prompt(original_path=None, resized_path=None):
+    """
+    Generate analysis prompt for Claude/Gemini to analyze image proportions
+
+    Returns:
+        dict with prompt and image data for API call
+    """
+
+    analysis_request = {
+        "task": "image_proportion_analysis",
+        "instruction": """Analyze the provided image(s) for proportion and distortion issues.
+
+Please examine:
+1. **Aspect Ratio**: Are objects in the image proportioned correctly? Do circles look like circles, squares like squares?
+2. **Distortion**: Is there any stretching, squashing, or warping visible?
+3. **Expected Proportions**: Do people, objects, or text appear at natural proportions?
+4. **Comparison** (if two images provided): How does the resized image compare to the original? Is there any introduced distortion?
+
+Provide your analysis in JSON format with these fields:
+{
+  "has_distortion": boolean,
+  "distortion_type": "horizontal_stretch" | "vertical_stretch" | "horizontal_squash" | "vertical_squash" | "none" | "unknown",
+  "severity": "none" | "minor" | "moderate" | "severe",
+  "confidence": float (0-1),
+  "observations": [list of specific observations],
+  "recommendation": "string with recommended action"
+}
+
+Be thorough but objective in your analysis."""
+    }
+
+    images = []
+
+    if original_path:
+        images.append({
+            "label": "original",
+            "path": original_path,
+            "base64": encode_image_to_base64(original_path),
+            "mime_type": get_image_mime_type(original_path)
+        })
+
+    if resized_path:
+        images.append({
+            "label": "resized",
+            "path": resized_path,
+            "base64": encode_image_to_base64(resized_path),
+            "mime_type": get_image_mime_type(resized_path)
+        })
+
+    analysis_request["images"] = images
+
+    return analysis_request
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Prepare image proportion analysis for AI (Claude/Gemini)'
+    )
+    parser.add_argument('--original', help='Original image path')
+    parser.add_argument('--resized', help='Resized image path')
+    parser.add_argument('--output-format', choices=['json', 'prompt'], default='json',
+                        help='Output format: json (full data) or prompt (text prompt only)')
+
+    args = parser.parse_args()
+
+    if not args.original and not args.resized:
+        print("Error: Must provide at least one image (--original or --resized)")
+        sys.exit(1)
+
+    # Generate analysis request
+    analysis_data = analyze_image_proportions_prompt(args.original, args.resized)
+
+    if args.output_format == 'json':
+        # Output full JSON (includes base64 encoded images)
+        print(json.dumps(analysis_data, indent=2))
+    else:
+        # Output just the prompt text
+        print(analysis_data["instruction"])
+
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/download_spoonflower.py b/scripts/download_spoonflower.py
new file mode 100755
index 0000000..bcdf7ef
--- /dev/null
+++ b/scripts/download_spoonflower.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""
+Download design file from Spoonflower after authentication
+"""
+
+import sys
+import time
+from playwright.sync_api import sync_playwright
+
+def download_spoonflower_design(design_url, email, password, download_dir="/tmp", headless=True):
+    """
+    Sign in to Spoonflower and download the design file
+    """
+    with sync_playwright() as p:
+        browser = p.chromium.launch(headless=headless)
+        context = browser.new_context(
+            accept_downloads=True,
+            viewport={'width': 1920, 'height': 1080}
+        )
+        page = context.new_page()
+
+        # First, go to login page
+        print("🔐 Navigating to login page...")
+        page.goto("https://www.spoonflower.com/login", wait_until='networkidle', timeout=60000)
+        time.sleep(3)
+
+        # Fill in credentials - try multiple selectors
+        print("📧 Entering credentials...")
+        try:
+            # Try to find email input by label
+            email_selectors = [
+                'input[type="email"]',
+                'input[type="text"]',
+                'label:has-text("Email") + input',
+                'input[placeholder*="email" i]'
+            ]
+
+            email_filled = False
+            for selector in email_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=5000):
+                        page.locator(selector).first.fill(email)
+                        print(f"   Email filled with: {selector}")
+                        email_filled = True
+                        break
+                except:
+                    continue
+
+            if not email_filled:
+                print("❌ Could not find email field")
+                page.screenshot(path="/tmp/spoonflower_login_debug.png")
+                browser.close()
+                return None
+
+            # Find password input
+            password_selectors = [
+                'input[type="password"]',
+                'label:has-text("Password") + input'
+            ]
+
+            password_filled = False
+            for selector in password_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=5000):
+                        page.locator(selector).first.fill(password)
+                        print(f"   Password filled with: {selector}")
+                        password_filled = True
+                        break
+                except:
+                    continue
+
+            if not password_filled:
+                print("❌ Could not find password field")
+                page.screenshot(path="/tmp/spoonflower_login_debug.png")
+                browser.close()
+                return None
+
+            # Click submit
+            submit_selectors = [
+                'button[type="submit"]',
+                'button:has-text("Sign In")',
+                'button:has-text("Log In")',
+                'input[type="submit"]'
+            ]
+
+            for selector in submit_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=2000):
+                        page.locator(selector).first.click()
+                        print(f"   Clicked submit with: {selector}")
+                        break
+                except:
+                    continue
+
+            print("⏳ Waiting for login to complete...")
+            time.sleep(5)
+        except Exception as e:
+            print(f"❌ Login failed: {e}")
+            page.screenshot(path="/tmp/spoonflower_login_debug.png")
+            browser.close()
+            return None
+
+        # Now navigate to the design page after successful login
+        print(f"🌐 Navigating to design page: {design_url}...")
+        page.goto(design_url, wait_until='networkidle', timeout=60000)
+        time.sleep(5)
+
+        # Check if we got a 404
+        content = page.content()
+        if 'wrong turn' in content.lower() or ('404' in content and 'albuquerque' in content.lower()):
+            print("""
+❌ I can log in to Spoonflower, but I cannot access a valid design page from this URL.
+
+The design page returned a 404 error. This means:
+
+- The design does not exist at this URL, OR
+- The design has been deleted, OR
+- The logged-in account does not own this design, so Spoonflower hides the page.
+
+Valid Spoonflower design URLs that work with this tool:
+- https://www.spoonflower.com/artists/designs/12345678-design-name-here
+- https://www.spoonflower.com/en/design/12345678-design-name-here
+- https://www.spoonflower.com/en/fabric/12345678-design-name-here
+- https://www.spoonflower.com/en/wallpaper/12345678-design-name-here
+
+To continue, please:
+
+1. Open your browser and log in to Spoonflower with: info@designerwallcoverings.com
+2. Go to the exact design where you can see the "File options" button.
+3. Verify the design exists and you can see it.
+4. Copy the entire URL from your browser's address bar.
+5. Paste that full URL here and run this tool again.
+
+I can only download designs that:
+- Exist in your Spoonflower account
+- You have permission to access with the logged-in account
+- Have the "File Options" menu visible when you view them in a browser
+""")
+            page.screenshot(path="/tmp/spoonflower_404.png")
+            browser.close()
+            return None
+        else:
+            print(f"✅ Design page loaded successfully")
+
+        print("🔍 Looking for File Options button...")
+
+        # Wait extra time for the page to fully load
+        time.sleep(3)
+
+        # Take screenshot before looking for button
+        page.screenshot(path="/tmp/before_file_options.png")
+        print("   Screenshot saved: /tmp/before_file_options.png")
+
+        # Try multiple selectors for File Options
+        file_options_selectors = [
+            'button:has-text("File Options")',
+            'button.chakra-menu__menu-button:has-text("File Options")',
+            'button[id*="menu-button"]:has-text("File Options")',
+            'button[aria-haspopup="menu"]:has-text("File Options")',
+            '.chakra-menu__menu-button:has-text("File Options")',
+            'button.chakra-button:has-text("File Options")',
+            'text=File Options'
+        ]
+
+        file_options_clicked = False
+        for selector in file_options_selectors:
+            try:
+                print(f"   Trying selector: {selector}")
+                locator = page.locator(selector).first
+                if locator.is_visible(timeout=5000):
+                    print(f"   ✅ Found File Options with: {selector}")
+                    locator.scroll_into_view_if_needed()
+                    time.sleep(1)
+                    locator.click()
+                    file_options_clicked = True
+                    time.sleep(2)
+                    break
+            except Exception as e:
+                print(f"   ❌ Failed with {selector}: {str(e)[:50]}")
+                continue
+
+        if not file_options_clicked:
+            print("❌ Could not find File Options button")
+            # Take screenshot for debugging
+            page.screenshot(path="/tmp/spoonflower_debug.png", full_page=True)
+            print("   Full page screenshot saved to /tmp/spoonflower_debug.png")
+
+            # List all buttons on page for debugging
+            all_buttons = page.locator('button').all()
+            print(f"   Found {len(all_buttons)} total buttons on page")
+            for i, btn in enumerate(all_buttons[:20]):
+                try:
+                    text = btn.inner_text(timeout=1000)
+                    if text and len(text) < 100:
+                        print(f"   Button {i}: '{text}'")
+                except:
+                    pass
+
+            browser.close()
+            return None
+
+        print("📥 Looking for download option...")
+
+        # Try to find download link in menu
+        download_selectors = [
+            '[role="menuitem"]:has-text("Download")',
+            'button[role="menuitem"]:has-text("Download")',
+            '.chakra-menu__menuitem:has-text("Download")',
+            'text=Download Current File',
+            'text=Download',
+            'a:has-text("Download")',
+            'button:has-text("Download")'
+        ]
+
+        downloaded_file = None
+        for selector in download_selectors:
+            try:
+                if page.locator(selector).first.is_visible(timeout=3000):
+                    print(f"   Found download option with: {selector}")
+
+                    # Set up download handler
+                    with page.expect_download(timeout=30000) as download_info:
+                        page.locator(selector).first.click()
+                        download = download_info.value
+
+                        # Save the file
+                        suggested_name = download.suggested_filename
+                        download_path = f"{download_dir}/{suggested_name}"
+                        download.save_as(download_path)
+                        downloaded_file = download_path
+                        print(f"✅ Downloaded: {download_path}")
+                    break
+            except Exception as e:
+                print(f"   Trying next selector... ({e})")
+                continue
+
+        if not downloaded_file:
+            print("❌ Could not find or click download option")
+            page.screenshot(path="/tmp/spoonflower_menu_debug.png")
+            print("   Screenshot saved to /tmp/spoonflower_menu_debug.png")
+
+        browser.close()
+        return downloaded_file
+
+
+if __name__ == '__main__':
+    if len(sys.argv) < 4:
+        print("Usage: python3 download_spoonflower.py <design_url> <email> <password>")
+        sys.exit(1)
+
+    design_url = sys.argv[1]
+    email = sys.argv[2]
+    password = sys.argv[3]
+
+    result = download_spoonflower_design(design_url, email, password)
+
+    if result:
+        print(f"\n✅ Success! File downloaded to: {result}")
+        sys.exit(0)
+    else:
+        print(f"\n❌ Failed to download file")
+        sys.exit(1)
diff --git a/scripts/resize_image.py b/scripts/resize_image.py
new file mode 100755
index 0000000..a3c1d13
--- /dev/null
+++ b/scripts/resize_image.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""
+Image resizing script for Resize It skill
+Resizes images to 24" width at 150 DPI while maintaining aspect ratio
+"""
+
+import sys
+import json
+from pathlib import Path
+from PIL import Image
+import argparse
+
+
+def calculate_dimensions(target_width_inches=24, dpi=150):
+    """Calculate target pixel dimensions"""
+    target_width_px = int(target_width_inches * dpi)
+    return target_width_px
+
+
+def analyze_resize_quality(original_width, original_height, target_width):
+    """Analyze if resize will degrade quality"""
+    scale_factor = target_width / original_width
+
+    result = {
+        "original_width": original_width,
+        "original_height": original_height,
+        "target_width": target_width,
+        "target_height": int(original_height * scale_factor),
+        "scale_factor": scale_factor,
+        "is_upscaling": scale_factor > 1,
+        "quality_warning": None
+    }
+
+    if scale_factor > 1:
+        upscale_percentage = (scale_factor - 1) * 100
+        result["quality_warning"] = (
+            f"⚠️  WARNING: Image will be upscaled by {upscale_percentage:.1f}%. "
+            f"This may result in quality degradation and pixelation. "
+            f"Original: {original_width}x{original_height}px, "
+            f"Target: {result['target_width']}x{result['target_height']}px"
+        )
+    elif scale_factor < 0.5:
+        downscale_percentage = (1 - scale_factor) * 100
+        result["quality_warning"] = (
+            f"ℹ️  INFO: Image will be significantly downscaled by {downscale_percentage:.1f}%. "
+            f"Some detail will be lost. "
+            f"Original: {original_width}x{original_height}px, "
+            f"Target: {result['target_width']}x{result['target_height']}px"
+        )
+
+    return result
+
+
+def resize_image(input_path, output_path=None, target_width_inches=24, dpi=150, quality=95):
+    """
+    Resize image to specified width in inches at given DPI
+
+    Args:
+        input_path: Path to input image
+        output_path: Path for output image (optional, will generate if not provided)
+        target_width_inches: Target width in inches (default 24")
+        dpi: Dots per inch (default 150)
+        quality: JPEG quality 1-100 (default 95)
+
+    Returns:
+        dict with result information
+    """
+    try:
+        # Open image
+        img = Image.open(input_path)
+        original_format = img.format
+        original_width, original_height = img.size
+
+        # Calculate target dimensions
+        target_width_px = calculate_dimensions(target_width_inches, dpi)
+
+        # Analyze quality impact
+        analysis = analyze_resize_quality(original_width, original_height, target_width_px)
+
+        # Calculate target height maintaining aspect ratio
+        aspect_ratio = original_height / original_width
+        target_height_px = int(target_width_px * aspect_ratio)
+
+        # Perform resize using high-quality resampling
+        # Try newer API first, fall back to older API
+        try:
+            resample_method = Image.Resampling.LANCZOS
+        except AttributeError:
+            resample_method = Image.LANCZOS
+
+        resized_img = img.resize((target_width_px, target_height_px), resample_method)
+
+        # Generate output path if not provided
+        if output_path is None:
+            input_path_obj = Path(input_path)
+            output_path = str(input_path_obj.parent / f"{input_path_obj.stem}_24x{target_height_px//dpi}in_150dpi{input_path_obj.suffix}")
+
+        # Save with DPI metadata
+        save_kwargs = {'dpi': (dpi, dpi)}
+
+        # Add format-specific options
+        if original_format in ['JPEG', 'JPG'] or output_path.lower().endswith(('.jpg', '.jpeg')):
+            save_kwargs['quality'] = quality
+            save_kwargs['optimize'] = True
+        elif original_format == 'PNG' or output_path.lower().endswith('.png'):
+            save_kwargs['optimize'] = True
+
+        resized_img.save(output_path, **save_kwargs)
+
+        # Build result
+        result = {
+            "success": True,
+            "input_path": str(input_path),
+            "output_path": str(output_path),
+            "original_dimensions": {"width": original_width, "height": original_height},
+            "output_dimensions": {"width": target_width_px, "height": target_height_px},
+            "physical_dimensions": {
+                "width_inches": target_width_inches,
+                "height_inches": target_height_px / dpi,
+                "dpi": dpi
+            },
+            "scale_factor": analysis["scale_factor"],
+            "quality_warning": analysis["quality_warning"]
+        }
+
+        return result
+
+    except Exception as e:
+        return {
+            "success": False,
+            "error": str(e),
+            "input_path": str(input_path)
+        }
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Resize image to 24" width at 150 DPI maintaining aspect ratio'
+    )
+    parser.add_argument('input', help='Input image path')
+    parser.add_argument('-o', '--output', help='Output image path (optional)')
+    parser.add_argument('-w', '--width', type=float, default=24,
+                        help='Target width in inches (default: 24)')
+    parser.add_argument('-d', '--dpi', type=int, default=150,
+                        help='DPI resolution (default: 150)')
+    parser.add_argument('-q', '--quality', type=int, default=95,
+                        help='JPEG quality 1-100 (default: 95)')
+    parser.add_argument('--json', action='store_true',
+                        help='Output result as JSON')
+
+    args = parser.parse_args()
+
+    # Perform resize
+    result = resize_image(
+        args.input,
+        args.output,
+        args.width,
+        args.dpi,
+        args.quality
+    )
+
+    if args.json:
+        print(json.dumps(result, indent=2))
+    else:
+        if result["success"]:
+            print(f"✅ Image resized successfully!")
+            print(f"   Input: {result['input_path']}")
+            print(f"   Output: {result['output_path']}")
+            print(f"   Original: {result['original_dimensions']['width']}x{result['original_dimensions']['height']}px")
+            print(f"   Resized: {result['output_dimensions']['width']}x{result['output_dimensions']['height']}px")
+            print(f"   Physical: {result['physical_dimensions']['width_inches']:.1f}\" x {result['physical_dimensions']['height_inches']:.1f}\" @ {result['physical_dimensions']['dpi']} DPI")
+            print(f"   Scale: {result['scale_factor']:.2f}x")
+
+            if result.get('quality_warning'):
+                print(f"\n{result['quality_warning']}")
+        else:
+            print(f"❌ Error: {result['error']}")
+            sys.exit(1)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/spoonflower_full_workflow.py b/scripts/spoonflower_full_workflow.py
new file mode 100644
index 0000000..be98ece
--- /dev/null
+++ b/scripts/spoonflower_full_workflow.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+"""
+Complete Spoonflower workflow: Download, Resize, and Re-upload
+"""
+
+import sys
+import time
+import os
+from playwright.sync_api import sync_playwright
+
+def full_spoonflower_workflow(design_url, email, password, resized_image_path):
+    """
+    Complete workflow:
+    1. Login to Spoonflower
+    2. Navigate to design page
+    3. Click "File Options" > "Replace Current File"
+    4. Upload the resized image
+    5. Return the design URL for user to verify
+    """
+    with sync_playwright() as p:
+        browser = p.chromium.launch(headless=True)
+        context = browser.new_context(
+            accept_downloads=True,
+            viewport={'width': 1920, 'height': 1080}
+        )
+        page = context.new_page()
+
+        # Login first
+        print('🔐 Logging in to Spoonflower...')
+        page.goto('https://www.spoonflower.com/login', wait_until='networkidle')
+        time.sleep(2)
+
+        try:
+            # Find email input
+            email_selectors = [
+                'input[type="email"]',
+                'input[type="text"]',
+                'label:has-text("Email") + input',
+                'input[placeholder*="email" i]'
+            ]
+
+            for selector in email_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=5000):
+                        page.locator(selector).first.fill(email)
+                        print(f'   ✅ Email entered')
+                        break
+                except:
+                    continue
+
+            # Find password input
+            password_selectors = [
+                'input[type="password"]',
+                'label:has-text("Password") + input'
+            ]
+
+            for selector in password_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=5000):
+                        page.locator(selector).first.fill(password)
+                        print(f'   ✅ Password entered')
+                        break
+                except:
+                    continue
+
+            # Click submit
+            submit_selectors = [
+                'button[type="submit"]',
+                'button:has-text("Sign In")',
+                'button:has-text("Log In")',
+                'input[type="submit"]'
+            ]
+
+            for selector in submit_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=2000):
+                        page.locator(selector).first.click()
+                        print(f'   ✅ Login submitted')
+                        break
+                except:
+                    continue
+
+            print('⏳ Waiting for login to complete...')
+            time.sleep(5)
+
+        except Exception as e:
+            print(f'❌ Login failed: {e}')
+            browser.close()
+            return None
+
+        # Navigate to the design page
+        print(f'🌐 Navigating to design page: {design_url}...')
+        page.goto(design_url, wait_until='networkidle', timeout=60000)
+        time.sleep(5)
+
+        # Check if we got a 404
+        content = page.content()
+        if 'wrong turn' in content.lower() or ('404' in content and 'albuquerque' in content.lower()):
+            print('❌ Design page returned 404 error')
+            browser.close()
+            return None
+
+        print('✅ Design page loaded successfully')
+
+        # Find and click File Options button
+        print('🔍 Looking for File Options button...')
+        time.sleep(3)
+
+        file_options_selectors = [
+            'button:has-text("File Options")',
+            'button.chakra-menu__menu-button:has-text("File Options")',
+            'button[id*="menu-button"]:has-text("File Options")',
+            'button[aria-haspopup="menu"]:has-text("File Options")',
+        ]
+
+        file_options_clicked = False
+        for selector in file_options_selectors:
+            try:
+                locator = page.locator(selector).first
+                if locator.is_visible(timeout=5000):
+                    print(f'   ✅ Found File Options button')
+                    locator.scroll_into_view_if_needed()
+                    time.sleep(1)
+                    locator.click()
+                    file_options_clicked = True
+                    time.sleep(2)
+                    break
+            except Exception as e:
+                continue
+
+        if not file_options_clicked:
+            print('❌ Could not find File Options button')
+            page.screenshot(path='/tmp/no_file_options.png')
+            browser.close()
+            return None
+
+        # Click "Replace Current File" option
+        print('🔄 Looking for Replace Current File option...')
+
+        replace_selectors = [
+            '[role="menuitem"]:has-text("Replace")',
+            'button[role="menuitem"]:has-text("Replace")',
+            '.chakra-menu__menuitem:has-text("Replace")',
+            'text=Replace Current File',
+            'button:has-text("Replace Current File")'
+        ]
+
+        replace_clicked = False
+        for selector in replace_selectors:
+            try:
+                if page.locator(selector).first.is_visible(timeout=5000):
+                    print(f'   ✅ Found Replace option')
+                    page.locator(selector).first.click()
+                    replace_clicked = True
+                    time.sleep(2)
+                    break
+            except Exception as e:
+                continue
+
+        if not replace_clicked:
+            print('❌ Could not find Replace Current File option')
+            page.screenshot(path='/tmp/no_replace_option.png')
+            browser.close()
+            return None
+
+        # Find file input and upload the resized image
+        print(f'📤 Uploading resized image: {resized_image_path}...')
+
+        try:
+            # Wait for file input to appear
+            file_input = page.locator('input[type="file"]').first
+            file_input.set_input_files(resized_image_path)
+            print('   ✅ File selected for upload')
+            time.sleep(3)
+
+            # Look for upload/submit button if needed
+            upload_selectors = [
+                'button:has-text("Upload")',
+                'button:has-text("Submit")',
+                'button:has-text("Save")',
+                'button[type="submit"]'
+            ]
+
+            for selector in upload_selectors:
+                try:
+                    if page.locator(selector).first.is_visible(timeout=2000):
+                        page.locator(selector).first.click()
+                        print(f'   ✅ Upload initiated')
+                        break
+                except:
+                    continue
+
+            # Wait for upload to complete
+            print('⏳ Waiting for upload to complete...')
+            time.sleep(10)
+
+            # Check for success message or confirmation
+            page.screenshot(path='/tmp/after_upload.png')
+            print('   📸 Screenshot saved: /tmp/after_upload.png')
+
+            print(f'\n✅ Upload complete!')
+            print(f'🌐 View your design at: {design_url}')
+
+            browser.close()
+            return design_url
+
+        except Exception as e:
+            print(f'❌ Upload failed: {e}')
+            page.screenshot(path='/tmp/upload_error.png')
+            browser.close()
+            return None
+
+
+if __name__ == '__main__':
+    if len(sys.argv) < 4:
+        print('Usage: python3 spoonflower_full_workflow.py <design_url> <email> <password> <resized_image_path>')
+        sys.exit(1)
+
+    design_url = sys.argv[1]
+    email = sys.argv[2]
+    password = sys.argv[3]
+    resized_image_path = sys.argv[4]
+
+    result = full_spoonflower_workflow(design_url, email, password, resized_image_path)
+
+    if result:
+        print(f'\n✅ Success! Design updated at: {result}')
+        sys.exit(0)
+    else:
+        print(f'\n❌ Failed to complete workflow')
+        sys.exit(1)
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..7fa0686
--- /dev/null
+++ b/server.py
@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+"""
+Simple Flask server for Spoonflower image resizer web interface
+"""
+
+from flask import Flask, request, jsonify, send_file, send_from_directory
+from werkzeug.utils import secure_filename
+import os
+import subprocess
+import json
+from PIL import Image
+import tempfile
+from pathlib import Path
+
+app = Flask(__name__)
+app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024  # 100MB max file size
+
+PROJECT_ROOT = Path(__file__).resolve().parent
+UPLOAD_FOLDER = '/tmp/resize-uploads'
+os.makedirs(UPLOAD_FOLDER, exist_ok=True)
+
+# Spoonflower credentials
+SPOONFLOWER_EMAIL = os.getenv('SPOONFLOWER_EMAIL')
+SPOONFLOWER_PASSWORD = os.getenv('SPOONFLOWER_PASSWORD')
+
+def missing_spoonflower_env():
+    return [
+        name for name, value in {
+            'SPOONFLOWER_EMAIL': SPOONFLOWER_EMAIL,
+            'SPOONFLOWER_PASSWORD': SPOONFLOWER_PASSWORD,
+        }.items()
+        if not value
+    ]
+
+@app.route('/')
+def index():
+    """Serve the web interface"""
+    return send_from_directory('.', 'web-interface.html')
+
+@app.route('/api/resize', methods=['POST'])
+def resize_image():
+    """Handle local file upload and resize"""
+    try:
+        if 'file' not in request.files:
+            return jsonify({'error': 'No file provided'}), 400
+
+        file = request.files['file']
+        if file.filename == '':
+            return jsonify({'error': 'No file selected'}), 400
+
+        # Save uploaded file
+        filename = secure_filename(file.filename)
+        input_path = os.path.join(UPLOAD_FOLDER, filename)
+        file.save(input_path)
+
+        # Get original image info
+        with Image.open(input_path) as img:
+            original_width, original_height = img.size
+
+        # Resize using the script
+        result = subprocess.run(
+            ['python3', 'scripts/resize_image.py', input_path],
+            capture_output=True,
+            text=True,
+            cwd=PROJECT_ROOT
+        )
+
+        if result.returncode != 0:
+            return jsonify({'error': f'Resize failed: {result.stderr}'}), 500
+
+        # Find the output file
+        output_filename = filename.rsplit('.', 1)[0] + '_24x*_150dpi.*'
+        import glob
+        output_files = glob.glob(os.path.join(UPLOAD_FOLDER, output_filename))
+
+        if not output_files:
+            return jsonify({'error': 'Could not find resized image'}), 500
+
+        output_path = output_files[0]
+
+        # Get resized image info
+        with Image.open(output_path) as img:
+            resized_width, resized_height = img.size
+
+        # Calculate stats
+        scale_factor = resized_width / original_width
+        height_inches = resized_height / 150
+
+        warning = None
+        if scale_factor > 1.2:
+            warning = "Image upscaled significantly - may have quality loss"
+        elif scale_factor < 0.5:
+            warning = "Image downscaled significantly - some detail will be lost"
+
+        return jsonify({
+            'success': True,
+            'original': {
+                'width': original_width,
+                'height': original_height
+            },
+            'resized': {
+                'width': resized_width,
+                'height': resized_height,
+                'heightInches': round(height_inches, 2)
+            },
+            'scaleFactor': round(scale_factor, 2),
+            'warning': warning,
+            'downloadUrl': f'/api/download/{os.path.basename(output_path)}'
+        })
+
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+
+@app.route('/api/upload-to-spoonflower', methods=['POST'])
+def upload_to_spoonflower():
+    """Upload resized image back to Spoonflower"""
+    try:
+        data = request.get_json(silent=True)
+        if data is None:
+            return jsonify({'error': 'Invalid or missing JSON body'}), 400
+        missing_env = missing_spoonflower_env()
+        if missing_env:
+            return jsonify({'error': f"Missing required environment variables: {', '.join(missing_env)}"}), 500
+
+        design_url = data.get('designUrl')
+        resized_image_path = data.get('resizedImagePath')
+
+        if not design_url or not resized_image_path:
+            return jsonify({'error': 'Missing designUrl or resizedImagePath'}), 400
+
+        # Extract filename from download URL
+        filename = os.path.basename(resized_image_path.replace('/api/download/', ''))
+
+        # Find actual file path
+        actual_path = None
+        for directory in ['/tmp', UPLOAD_FOLDER]:
+            filepath = os.path.join(directory, filename)
+            if os.path.exists(filepath):
+                actual_path = filepath
+                break
+
+        if not actual_path:
+            return jsonify({'error': 'Resized file not found'}), 404
+
+        # Run the upload workflow
+        result = subprocess.run(
+            [
+                'python3', 'scripts/spoonflower_full_workflow.py',
+                design_url,
+                SPOONFLOWER_EMAIL,
+                SPOONFLOWER_PASSWORD,
+                actual_path
+            ],
+            capture_output=True,
+            text=True,
+            cwd=PROJECT_ROOT
+        )
+
+        if result.returncode != 0:
+            error_msg = result.stderr or result.stdout
+            return jsonify({'error': f'Upload failed: {error_msg}'}), 500
+
+        return jsonify({
+            'success': True,
+            'message': 'Image uploaded successfully to Spoonflower',
+            'designUrl': design_url
+        })
+
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+
+@app.route('/api/spoonflower', methods=['POST'])
+def process_spoonflower():
+    """Handle Spoonflower URL download and resize"""
+    try:
+        data = request.get_json(silent=True)
+        if data is None:
+            return jsonify({'error': 'Invalid or missing JSON body'}), 400
+        missing_env = missing_spoonflower_env()
+        if missing_env:
+            return jsonify({'error': f"Missing required environment variables: {', '.join(missing_env)}"}), 500
+
+        url = data.get('url')
+
+        if not url:
+            return jsonify({'error': 'No URL provided'}), 400
+
+        # Validate URL format
+        if 'spoonflower.com' not in url:
+            return jsonify({'error': 'Invalid Spoonflower URL'}), 400
+
+        # Download from Spoonflower
+        result = subprocess.run(
+            [
+                'python3', 'scripts/download_spoonflower.py',
+                url,
+                SPOONFLOWER_EMAIL,
+                SPOONFLOWER_PASSWORD
+            ],
+            capture_output=True,
+            text=True,
+            cwd=PROJECT_ROOT
+        )
+
+        if result.returncode != 0:
+            error_msg = result.stderr or result.stdout
+            if '404' in error_msg or 'wrong turn' in error_msg.lower():
+                return jsonify({
+                    'error': 'Design not found. Please verify the URL and that you own this design.'
+                }), 404
+            return jsonify({'error': f'Download failed: {error_msg}'}), 500
+
+        # Find downloaded file in /tmp/
+        import glob
+        downloaded_files = glob.glob('/tmp/*.png') + glob.glob('/tmp/*.jpg') + glob.glob('/tmp/*.jpeg')
+        if not downloaded_files:
+            return jsonify({'error': 'No file was downloaded'}), 500
+
+        # Get the most recent file
+        downloaded_file = max(downloaded_files, key=os.path.getctime)
+
+        # Get original image info
+        with Image.open(downloaded_file) as img:
+            original_width, original_height = img.size
+
+        # Resize
+        result = subprocess.run(
+            ['python3', 'scripts/resize_image.py', downloaded_file],
+            capture_output=True,
+            text=True,
+            cwd=PROJECT_ROOT
+        )
+
+        if result.returncode != 0:
+            return jsonify({'error': f'Resize failed: {result.stderr}'}), 500
+
+        # Find resized file
+        base_name = os.path.basename(downloaded_file).rsplit('.', 1)[0]
+        output_pattern = f'/tmp/{base_name}_24x*_150dpi.*'
+        output_files = glob.glob(output_pattern)
+
+        if not output_files:
+            return jsonify({'error': 'Could not find resized image'}), 500
+
+        output_path = output_files[0]
+
+        # Get resized image info
+        with Image.open(output_path) as img:
+            resized_width, resized_height = img.size
+
+        # Calculate stats
+        scale_factor = resized_width / original_width
+        height_inches = resized_height / 150
+
+        warning = None
+        if scale_factor > 1.2:
+            warning = "Image upscaled significantly - may have quality loss"
+        elif scale_factor < 0.5:
+            warning = "Image downscaled significantly - some detail will be lost"
+
+        return jsonify({
+            'success': True,
+            'original': {
+                'width': original_width,
+                'height': original_height
+            },
+            'resized': {
+                'width': resized_width,
+                'height': resized_height,
+                'heightInches': round(height_inches, 2)
+            },
+            'scaleFactor': round(scale_factor, 2),
+            'warning': warning,
+            'downloadUrl': f'/api/download/{os.path.basename(output_path)}',
+            'spoonflowerUrl': url,
+            'resizedFilePath': output_path
+        })
+
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+
+@app.route('/api/download/<filename>')
+def download_file(filename):
+    """Serve the resized file for download"""
+    # Check both /tmp and upload folder
+    for directory in ['/tmp', UPLOAD_FOLDER]:
+        filepath = os.path.join(directory, filename)
+        if os.path.exists(filepath):
+            return send_file(filepath, as_attachment=True)
+
+    return jsonify({'error': 'File not found'}), 404
+
+if __name__ == '__main__':
+    PORT = 9876
+    print('Starting Spoonflower Image Resizer server...')
+    print(f'Access the web interface at: http://127.0.0.1:{PORT}')
+    app.run(host='127.0.0.1', port=PORT, debug=False)
diff --git a/web-interface.html b/web-interface.html
new file mode 100644
index 0000000..7a6d977
--- /dev/null
+++ b/web-interface.html
@@ -0,0 +1,325 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Spoonflower Image Resizer</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gradient-to-br from-purple-600 to-indigo-800 min-h-screen flex items-center justify-center p-6">
+    <div class="bg-white rounded-2xl shadow-2xl p-8 max-w-2xl w-full">
+        <!-- Header -->
+        <div class="text-center mb-8">
+            <h1 class="text-4xl font-bold text-gray-800 mb-2">🎨 Spoonflower Resizer</h1>
+            <p class="text-gray-600">Resize designs to 24" width at 150 DPI</p>
+        </div>
+
+        <!-- Drop Zone -->
+        <div id="dropZone" class="border-4 border-dashed border-purple-300 rounded-xl p-12 text-center bg-purple-50 hover:bg-purple-100 transition-colors cursor-pointer mb-6">
+            <p class="text-purple-600 text-lg font-semibold mb-2">📁 Drop image file here</p>
+            <p class="text-gray-500 text-sm">or click to browse</p>
+            <input type="file" id="fileInput" accept="image/*" class="hidden">
+        </div>
+
+        <!-- OR Divider -->
+        <div class="flex items-center my-6">
+            <div class="flex-1 border-t border-gray-300"></div>
+            <span class="px-4 text-gray-500 text-sm">OR</span>
+            <div class="flex-1 border-t border-gray-300"></div>
+        </div>
+
+        <!-- Spoonflower URL Input -->
+        <div class="mb-6">
+            <label class="block text-sm font-medium text-gray-700 mb-2">Spoonflower Design URL</label>
+            <input
+                type="text"
+                id="urlInput"
+                placeholder="https://www.spoonflower.com/artists/designs/12345-design-name"
+                class="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:border-purple-500 transition-colors"
+            >
+        </div>
+
+        <!-- Action Buttons -->
+        <div class="grid grid-cols-2 gap-4 mb-6">
+            <button
+                id="resizeBtn"
+                class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-semibold py-3 px-6 rounded-lg hover:shadow-lg transform hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
+            >
+                🔧 Resize and Download
+            </button>
+            <button
+                id="goToSpoonBtn"
+                class="bg-gradient-to-r from-green-500 to-emerald-600 text-white font-semibold py-3 px-6 rounded-lg hover:shadow-lg transform hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
+                disabled
+            >
+                🌐 Go To Spoon Page
+            </button>
+        </div>
+
+        <!-- Spoonflower Link Field (appears after resize) -->
+        <div id="spoonLinkContainer" class="hidden mb-6 p-4 bg-green-50 border-2 border-green-300 rounded-lg">
+            <label class="block text-sm font-medium text-green-800 mb-2">✅ Design Link Ready</label>
+            <div class="flex gap-2">
+                <input
+                    type="text"
+                    id="spoonLink"
+                    readonly
+                    class="flex-1 px-4 py-2 bg-white border border-green-300 rounded-lg text-gray-700"
+                >
+                <button
+                    id="openNewTabBtn"
+                    class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-semibold"
+                >
+                    🔗 Open New Tab
+                </button>
+            </div>
+        </div>
+
+        <!-- Status Messages -->
+        <div id="status" class="hidden p-4 rounded-lg mb-6"></div>
+
+        <!-- Results Panel -->
+        <div id="resultsPanel" class="hidden mt-6 p-6 bg-gray-50 rounded-xl border border-gray-200">
+            <h3 class="text-lg font-bold text-gray-800 mb-4">📊 Resize Results</h3>
+            <div id="resultsContent"></div>
+        </div>
+
+        <!-- Preview -->
+        <div id="preview" class="mt-6"></div>
+    </div>
+
+    <script>
+        const dropZone = document.getElementById('dropZone');
+        const fileInput = document.getElementById('fileInput');
+        const urlInput = document.getElementById('urlInput');
+        const resizeBtn = document.getElementById('resizeBtn');
+        const goToSpoonBtn = document.getElementById('goToSpoonBtn');
+        const spoonLinkContainer = document.getElementById('spoonLinkContainer');
+        const spoonLink = document.getElementById('spoonLink');
+        const openNewTabBtn = document.getElementById('openNewTabBtn');
+        const status = document.getElementById('status');
+        const resultsPanel = document.getElementById('resultsPanel');
+        const resultsContent = document.getElementById('resultsContent');
+        const preview = document.getElementById('preview');
+
+        let selectedFile = null;
+        let currentSpoonflowerUrl = null;
+        let resizedImageUrl = null;
+
+        // Drop zone handlers
+        dropZone.addEventListener('click', () => fileInput.click());
+
+        dropZone.addEventListener('dragover', (e) => {
+            e.preventDefault();
+            dropZone.classList.add('border-purple-500', 'bg-purple-200');
+        });
+
+        dropZone.addEventListener('dragleave', () => {
+            dropZone.classList.remove('border-purple-500', 'bg-purple-200');
+        });
+
+        dropZone.addEventListener('drop', (e) => {
+            e.preventDefault();
+            dropZone.classList.remove('border-purple-500', 'bg-purple-200');
+
+            const files = e.dataTransfer.files;
+            if (files.length > 0) {
+                handleFile(files[0]);
+            }
+        });
+
+        fileInput.addEventListener('change', (e) => {
+            if (e.target.files.length > 0) {
+                handleFile(e.target.files[0]);
+            }
+        });
+
+        function handleFile(file) {
+            if (!file.type.startsWith('image/')) {
+                showStatus('error', 'Please select an image file');
+                return;
+            }
+
+            selectedFile = file;
+            dropZone.innerHTML = `
+                <p class="text-green-600 text-lg font-semibold mb-2">✅ ${file.name}</p>
+                <p class="text-gray-500 text-sm">Click to change</p>
+            `;
+            showStatus('success', `Selected: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
+
+            // Show preview
+            const reader = new FileReader();
+            reader.onload = (e) => {
+                preview.innerHTML = `<img src="${e.target.result}" alt="Preview" class="max-w-full rounded-lg shadow-lg mx-auto">`;
+            };
+            reader.readAsDataURL(file);
+        }
+
+        // Resize and Download button
+        resizeBtn.addEventListener('click', async () => {
+            const url = urlInput.value.trim();
+
+            if (!selectedFile && !url) {
+                showStatus('error', 'Please select a file or enter a Spoonflower URL');
+                return;
+            }
+
+            resizeBtn.disabled = true;
+            showStatus('info', '⏳ Processing...');
+
+            try {
+                let result;
+                if (selectedFile) {
+                    result = await processLocalFile(selectedFile);
+                } else if (url) {
+                    result = await processSpoonflowerURL(url);
+                }
+
+                // Show results
+                displayResults(result);
+
+                // If Spoonflower URL was provided, use it
+                if (url && url.includes('spoonflower.com')) {
+                    currentSpoonflowerUrl = url;
+                    goToSpoonBtn.disabled = false;
+                    spoonLink.value = url;
+                    spoonLinkContainer.classList.remove('hidden');
+
+                    showStatus('success', '✅ Image resized successfully! Opening Spoonflower page...');
+
+                    // Auto-open Spoonflower page after 1 second
+                    setTimeout(() => {
+                        window.open(url, '_blank');
+                    }, 1000);
+                } else {
+                    // No URL provided - ask for it
+                    showStatus('success', '✅ Image resized successfully!');
+
+                    // Prompt for Spoonflower URL
+                    setTimeout(() => {
+                        const spoonUrl = prompt('Enter the Spoonflower design URL to open:');
+                        if (spoonUrl && spoonUrl.includes('spoonflower.com')) {
+                            currentSpoonflowerUrl = spoonUrl;
+                            goToSpoonBtn.disabled = false;
+                            spoonLink.value = spoonUrl;
+                            spoonLinkContainer.classList.remove('hidden');
+                            urlInput.value = spoonUrl;
+
+                            showStatus('success', '✅ Opening Spoonflower design page...');
+                            window.open(spoonUrl, '_blank');
+                        }
+                    }, 500);
+                }
+
+            } catch (error) {
+                showStatus('error', error.message);
+            } finally {
+                resizeBtn.disabled = false;
+            }
+        });
+
+        // Go To Spoon Page button
+        goToSpoonBtn.addEventListener('click', () => {
+            if (currentSpoonflowerUrl) {
+                window.open(currentSpoonflowerUrl, '_blank');
+                showStatus('success', '🌐 Opened Spoonflower design page in new tab');
+            }
+        });
+
+        // Open New Tab button
+        openNewTabBtn.addEventListener('click', () => {
+            const url = spoonLink.value;
+            if (url) {
+                window.open(url, '_blank');
+                showStatus('success', '🌐 Opened Spoonflower design in new tab');
+            }
+        });
+
+        async function processLocalFile(file) {
+            const formData = new FormData();
+            formData.append('file', file);
+
+            const response = await fetch('/api/resize', {
+                method: 'POST',
+                body: formData
+            });
+
+            if (!response.ok) {
+                throw new Error('Failed to process image');
+            }
+
+            return await response.json();
+        }
+
+        async function processSpoonflowerURL(url) {
+            const response = await fetch('/api/spoonflower', {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json'
+                },
+                body: JSON.stringify({ url })
+            });
+
+            if (!response.ok) {
+                const error = await response.json();
+                throw new Error(error.message || 'Failed to process Spoonflower URL');
+            }
+
+            return await response.json();
+        }
+
+        function displayResults(result) {
+            resultsPanel.classList.remove('hidden');
+
+            const warningHtml = result.warning ? `
+                <div class="bg-yellow-50 border-l-4 border-yellow-400 p-3 mt-3">
+                    <p class="text-yellow-800 text-sm">⚠️ ${result.warning}</p>
+                </div>
+            ` : '';
+
+            resultsContent.innerHTML = `
+                <div class="grid grid-cols-2 gap-4 mb-4">
+                    <div>
+                        <p class="text-sm text-gray-600">Original Size</p>
+                        <p class="font-semibold text-gray-800">${result.original.width} × ${result.original.height} px</p>
+                    </div>
+                    <div>
+                        <p class="text-sm text-gray-600">Resized Size</p>
+                        <p class="font-semibold text-gray-800">${result.resized.width} × ${result.resized.height} px</p>
+                    </div>
+                    <div>
+                        <p class="text-sm text-gray-600">Physical Size</p>
+                        <p class="font-semibold text-gray-800">24" × ${result.resized.heightInches}" @ 150 DPI</p>
+                    </div>
+                    <div>
+                        <p class="text-sm text-gray-600">Scale Factor</p>
+                        <p class="font-semibold text-gray-800">${result.scaleFactor}x</p>
+                    </div>
+                </div>
+
+                ${warningHtml}
+
+                <a href="${result.downloadUrl}" download
+                   class="mt-4 block w-full bg-blue-600 text-white text-center font-semibold py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors">
+                    💾 Download Resized Image
+                </a>
+            `;
+
+            resizedImageUrl = result.downloadUrl;
+        }
+
+        function showStatus(type, message) {
+            status.classList.remove('hidden');
+
+            const colors = {
+                success: 'bg-green-100 border-green-400 text-green-800',
+                error: 'bg-red-100 border-red-400 text-red-800',
+                info: 'bg-blue-100 border-blue-400 text-blue-800'
+            };
+
+            status.className = `p-4 rounded-lg mb-6 border-l-4 ${colors[type]}`;
+            status.textContent = message;
+        }
+    </script>
+</body>
+</html>

(oldest)  ·  back to Resize It  ·  tighten .gitignore: add missing standing-rule patterns (tmp/ 11bfea0 →