# Sandbox Mode: Creative Filter System

Client-side creative toolkit for MPS shops. When a shop owner enables sandbox
mode, all visitors see a floating filter toolbar. Users can apply real-time
CSS/SVG/canvas filters to images and video, export filtered artifacts, and
activate face detection overlays. All processing runs in the browser. The
server stores only the boolean toggle.

## Architecture

```
┌──────────────────────────────────────────────────────────┐
│                      Browser (client)                     │
│                                                           │
│  ┌─────────────┐   ┌──────────────┐   ┌──────────────┐  │
│  │ CSS Filters  │   │ Canvas Export │   │  MediaPipe   │  │
│  │ (live view)  │──>│ (artifacts)  │   │ Face Mesh    │  │
│  │              │   │              │   │ (on-demand)  │  │
│  │ 32 presets   │   │ ctx.filter   │   │ 468 landmarks│  │
│  │ 7 sliders    │   │ toBlob()     │   │ eye glow     │  │
│  │ SVG filters  │   │ MediaRecorder│   │ face mask    │  │
│  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘  │
│         │                  │                   │          │
│         │            ┌─────┴─────┐             │          │
│         │            │  Download  │             │          │
│         │            │  or Upload │             │          │
│         │            │  (presign) │             │          │
│         │            └───────────┘             │          │
│         │                                      │          │
│  ┌──────┴──────────────────────────────────────┴───────┐  │
│  │              sandbox.js (IIFE, ~700 lines)           │  │
│  │  localStorage persistence  │  window.sandboxReapply  │  │
│  └─────────────────────────────────────────────────────┘  │
│                                                           │
└────────────────────────────┬──────────────────────────────┘
                             │
                    ┌────────┴────────┐
                    │  MPS Server     │
                    │                 │
                    │ shop.sandbox    │
                    │ _mode = True    │
                    │                 │
                    │ (that's it.     │
                    │  server stores  │
                    │  one boolean.)  │
                    └─────────────────┘
```

## Filter Pipeline

```
User clicks preset          User adjusts slider
       │                           │
       ▼                           ▼
┌─────────────┐          ┌──────────────────┐
│ PRESETS[idx] │          │ sliderValues[id] │
│ .css string  │          │ → fn(val+unit)   │
└──────┬──────┘          └────────┬─────────┘
       │                          │
       └────────────┬─────────────┘
                    │
                    ▼
         ┌──────────────────┐
         │ getCurrentFilter  │     CSS filter string:
         │ CSS()             │──>  "sepia(40%) contrast(90%)
         └──────────────────┘      brightness(105%) saturate(80%)"
                    │
                    ▼
         ┌──────────────────┐
         │ element.style    │     Applied to: img, video
         │ .filter = css    │     (NOT <html> — stacks with
         └──────────────────┘      shop color_filter)
```

## Export Pipeline

### Image Export

```
img.product-main
       │
       ▼
  new Image()
  crossOrigin = "anonymous"    ← CORS required from CDN
       │
       ▼
  canvas.getContext("2d")
  ctx.filter = getCurrentFilterCSS()
  ctx.drawImage(img, 0, 0)
       │
       ▼
  canvas.toBlob("image/png")
       │
       ├──> Download (a.download = "sandbox-export.png")
       │
       └──> Upload (presigned POST to user's S3 bucket)
```

### Video Export

```
video element
       │
       ▼
  canvas.captureStream(30fps)
       │
       ▼
  MediaRecorder(stream, "video/webm")
       │
       ▼
  requestAnimationFrame loop:
    ctx.filter = getCurrentFilterCSS()
    ctx.drawImage(video, 0, 0)
       │
       ▼
  recorder.stop() → Blob
       │
       └──> Download ("sandbox-export.webm")
```

## Face Detection Pipeline (MediaPipe)

```
User clicks "Load Face Detection"
       │
       ▼
  Load face_mesh.js from CDN (~4MB WASM)
  Load camera_utils.js from CDN
       │
       ▼
  FaceMesh({ maxNumFaces: 4, refineLandmarks: true })
       │
       │
  ┌────┴────────────────────┐
  │                         │
  ▼                         ▼
Image path              Video path
  │                         │
  draw to canvas            Camera utility
  faceMesh.send()           onFrame → send()
  │                         │
  ▼                         ▼
  onResults(landmarks)      onResults(landmarks)
  │                         │
  └────────┬────────────────┘
           │
           ▼
    468 face landmarks per face
           │
    ┌──────┴──────┐
    │             │
    ▼             ▼
  Eye Glow     Face Mask
    │             │
    │  Iris       │  Face outline
    │  landmarks  │  (landmarks
    │  468-477    │  10,338,297...)
    │             │
    │  Radial     │  Fill face oval
    │  gradient   │  Draw eye shapes
    │  overlay    │  Draw eyebrows
    │             │  Draw mustache
    │             │  Draw goatee
    ▼             ▼
  Canvas overlay composited
  on top of media element
```

## Filter Preset Reference

### Basic (4)
| Name | CSS Filter Chain |
|------|-----------------|
| None | `none` |
| Grayscale | `grayscale(100%)` |
| Sepia | `sepia(100%)` |
| Invert | `invert(100%)` |

### Warm (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Warm | `sepia(30%) saturate(140%) brightness(105%)` |
| Sunset | `sepia(50%) hue-rotate(-15deg) saturate(150%)` |
| Golden | `sepia(60%) brightness(110%) contrast(90%)` |
| Amber | `sepia(40%) saturate(160%) hue-rotate(-10deg) brightness(108%)` |

### Cool (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Cool | `sepia(20%) hue-rotate(180deg) saturate(120%)` |
| Arctic | `sepia(30%) hue-rotate(200deg) brightness(110%)` |
| Moonlight | `sepia(40%) hue-rotate(220deg) saturate(80%) brightness(105%)` |
| Frost | `sepia(25%) hue-rotate(190deg) brightness(115%) contrast(95%)` |

### Dramatic (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Hi-Contrast | `contrast(180%) saturate(120%)` |
| Noir | `grayscale(100%) contrast(150%) brightness(90%)` |
| Faded | `contrast(80%) saturate(60%) brightness(110%)` |
| Vintage | `sepia(40%) contrast(90%) brightness(105%) saturate(80%)` |

### Color (5)
| Name | CSS Filter Chain |
|------|-----------------|
| Red Shift | `sepia(100%) hue-rotate(-30deg) saturate(300%)` |
| Green Shift | `sepia(100%) hue-rotate(85deg) saturate(300%)` |
| Blue Shift | `sepia(100%) hue-rotate(200deg) saturate(300%)` |
| Purple Haze | `sepia(100%) hue-rotate(265deg) saturate(200%)` |
| Cyan | `sepia(100%) hue-rotate(145deg) saturate(300%)` |

### Instagram-Style (7)
| Name | CSS Filter Chain |
|------|-----------------|
| Clarendon | `contrast(120%) saturate(125%)` |
| Juno | `sepia(10%) contrast(110%) saturate(150%) brightness(105%)` |
| Lark | `contrast(90%) brightness(115%) saturate(85%)` |
| Gingham | `brightness(105%) hue-rotate(350deg) saturate(80%)` |
| Nashville | `sepia(25%) contrast(120%) brightness(105%) saturate(120%) hue-rotate(-15deg)` |
| Valencia | `sepia(15%) contrast(110%) brightness(108%) saturate(120%)` |
| Walden | `brightness(110%) saturate(160%) sepia(30%) hue-rotate(350deg)` |

### SVG Filters (4)
| Name | Applied As |
|------|-----------|
| Duo Blue | `url(#sandbox-duotone-blue)` — SVG feColorMatrix |
| Duo Green | `url(#sandbox-duotone-green)` — SVG feColorMatrix |
| Film Grain | `url(#sandbox-grain)` — SVG feTurbulence + feBlend |
| Vignette | `url(#sandbox-vignette)` — SVG feFlood + feGaussianBlur |

## Adjustment Sliders

| Slider | Range | Default | CSS Function |
|--------|-------|---------|-------------|
| Brightness | 0-200% | 100% | `brightness()` |
| Contrast | 0-200% | 100% | `contrast()` |
| Saturation | 0-300% | 100% | `saturate()` |
| Hue | 0-360deg | 0deg | `hue-rotate()` |
| Blur | 0-20px | 0px | `blur()` |
| Sepia | 0-100% | 0% | `sepia()` |
| Grayscale | 0-100% | 0% | `grayscale()` |

## Shop Owner Configuration

Enable in **Shop Settings > Announcement Ribbon Settings** (the form section
that contains display toggles):

```
Settings > ribbon-settings form section > Sandbox Mode > Enable/Disable
```

The toggle adds `sandbox_mode = True` to the shop model. The base template
conditionally renders the toolbar HTML, SVG filter definitions, and loads
`sandbox.js` only when enabled.

## Watch Mode SPA Integration

When watch mode is enabled, SPA navigation replaces media elements without a
full page reload. The `updatePageContent()` function in `watch.js` calls
`window.sandboxReapply()` after swapping DOM elements. This re-applies the
active sandbox filter to newly loaded images and video with a 50ms delay to
let the DOM settle.

## CORS Requirements for Canvas Export

CSS filter preview works without CORS — filters are applied as rendering hints
on the element. Canvas export (toBlob) requires pixel access, which requires
CORS headers from the CDN.

DigitalOcean Spaces CORS configuration needed:

```json
{
    "CORSRules": [{
        "AllowedOrigins": ["*"],
        "AllowedMethods": ["GET"],
        "AllowedHeaders": ["*"],
        "MaxAgeSeconds": 3600
    }]
}
```

Without CORS, the export button shows a user-friendly error message. Filter
preview continues to work normally.

## localStorage Keys

| Key | Value | Purpose |
|-----|-------|---------|
| `mps_sandbox_preset` | Integer (0-31) | Active preset index |
| `mps_sandbox_sliders` | JSON object | Slider values |
| `mps_sandbox_panel` | "0" or "1" | Panel open/closed |
| `mps_sandbox_mode` | "preset" or "custom" | Filter mode |

## Artifact Storage (User S3 Bucket)

Users can configure their own S3-compatible bucket to upload sandbox artifacts.

```
User Settings (/u/settings)
  │
  │  Artifact Storage form:
  │  - S3 Endpoint URL
  │  - Region (optional)
  │  - Bucket Name
  │  - Access Key
  │  - Secret Key
  │
  ▼
POST /u/settings/storage
  │
  │  Saves credentials on User model
  │  (s3_endpoint, s3_region, s3_bucket,
  │   s3_access_key, s3_secret_key)
  │
  ▼
Sandbox toolbar shows "Upload to Bucket" button
  │  (only when data-has-bucket="1")
  │
  │  1. User exports image/video (download)
  │  2. User clicks "Upload to Bucket"
  │  3. JS POSTs to /u/sandbox/upload
  │  4. Server generates presigned POST with user's credentials
  │  5. JS uploads blob directly to user's S3 bucket
  │
  ▼
User's S3 Bucket
  sandbox/{user_id}/{timestamp}-{filename}
```

**Supported services:** DigitalOcean Spaces, AWS S3, MinIO, Backblaze B2,
any S3-compatible endpoint.

**Security:** Credentials stored as plain Unicode columns (same pattern as
Stripe/PayPal/Adyen keys on Shop model). Secret key field uses `type="password"`
in the form.

## Files

| File | Role |
|------|------|
| `models/shop.py` | `sandbox_mode` Boolean column |
| `models/user.py` | S3 credential columns + `has_s3_bucket` property |
| `views/shop.py` | Toggle handler in ribbon-settings |
| `views/user.py` | Storage settings POST handler + S3 fields in settings dict |
| `views/user_sandbox.py` | Presigned upload endpoint (`/u/sandbox/upload`) |
| `templates/shop_settings.j2` | Radio buttons for enable/disable |
| `templates/user_settings.j2` | Artifact Storage form |
| `templates/base.j2` | Conditional toolbar HTML + SVG defs + `data-has-bucket` |
| `static/js/sandbox.js` | Client-side filter engine + upload-to-bucket |
| `static/css/common.css` | Toolbar layout (CSS Grid) |
| `static/js/watch.js` | `sandboxReapply()` hook in SPA navigation |
| `routes.py` | `user_storage_settings`, `user_sandbox_upload` routes |
| `scripts/alembic/versions/a2c13d3117f2_*.py` | Shop sandbox_mode migration |
| `scripts/alembic/versions/f898ba460612_*.py` | User S3 credentials migration |

## Mobile Behavior

On screens `max-width: 800px`:
- Toggle button: bottom-right corner (10px offset)
- Panel: full-width (10px margin left + right)
- Max height: 50vh (scrollable)
- All controls always visible (no hover-only interactions)
- Touch targets adequate for finger tapping

## Stacking with Shop Color Filter

The shop-level `color_filter` (grayscale, red, green, blue, etc.) applies to
`<html>` via `data-color-filter` attribute. Sandbox filters apply to individual
`img` and `video` elements via `element.style.filter`. These stack: the shop
filter colors the entire page, and the sandbox filter adds per-element effects
on top. This is intentional — the two systems are independent and composable.
