diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f17874e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,53 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: backend/requirements-dev.txt
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Install backend dependencies
+ run: python -m pip install -r backend/requirements-dev.txt
+
+ - name: Install frontend dependencies
+ working-directory: frontend
+ run: corepack pnpm@9.15.9 install --frozen-lockfile
+
+ - name: Install Playwright browser
+ working-directory: frontend
+ run: corepack pnpm@9.15.9 exec playwright install --with-deps chromium
+
+ - name: Run complete validation
+ run: python check.py
+
+ - name: Upload browser diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-diagnostics
+ path: |
+ frontend/playwright-report
+ frontend/test-results
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index c7a33a9..6b7ed02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ venv/
.venv*/
ENV/
*.egg-info/
+.coverage
dist/
build/
*.log
@@ -24,6 +25,10 @@ pnpm-debug.log*
# Frontend build
frontend/dist/
frontend/.vite/
+frontend/coverage/
+frontend/playwright-report/
+frontend/test-results/
+.tmp/
# IDE
.vscode/
diff --git a/README.md b/README.md
index c222e5c..ac51c85 100644
--- a/README.md
+++ b/README.md
@@ -1,1085 +1,87 @@
# ColorCraft
-
+
+
+
-[](https://github.com/Artsen/ColorCraft)
+A local-first workspace for extracting, refining, reviewing, saving, and exporting color palettes.
-ColorCraft is an intelligent visual design companion that transforms images into actionable color insights. Upload a photo, extract dominant colors using perceptual clustering, and analyze geometric color relationships and WCAG contrast. Relationship fit describes angular structure; it is not an objective judgment of aesthetic quality.
+
+
+
+
+
-## Table of Contents
-- [Overview](#overview)
-- [What's New](#whats-new)
-- [Plain-English Tour](#plain-english-tour)
-- [Repository Layout](#repository-layout)
-- [Key Capabilities](#key-capabilities)
- - [Backend](#backend)
- - [Frontend](#frontend)
-- [Architecture](#architecture)
-- [Prerequisites](#prerequisites)
-- [Environment Setup](#environment-setup)
-- [Backend Setup](#backend-setup)
-- [Frontend Setup](#frontend-setup)
-- [Running the Stack](#running-the-stack)
-- [Using ColorCraft](#using-colorcraft)
- - [Workflow Options](#workflow-options)
- - [Controls Reference](#controls-reference)
- - [Exploration Tips](#exploration-tips)
-- [Color Extraction Algorithm](#color-extraction-algorithm)
-- [Color Theory Engine](#color-theory-engine)
-- [Accessibility Analysis](#accessibility-analysis)
-- [API Endpoints](#api-endpoints)
-- [Data Flow & Processing](#data-flow--processing)
-- [Troubleshooting](#troubleshooting)
-- [Roadmap & Next Steps](#roadmap--next-steps)
-- [Contributing](#contributing)
-- [License](#license)
-- [Mermaid Diagrams](#mermaid-diagrams)
+
-## Overview
+ColorCraft turns an image or a hand-built set of colors into practical design evidence. It extracts representative colors, detects geometric harmony relationships, checks WCAG contrast, suggests additions, and exports reusable values. Saved palettes stay in the current browser. Uploaded source images are processed for the active session and are not placed in persistent browser storage.
-ColorCraft bridges the gap between subjective color choices and objective color science. Instead of manually checking contrast ratios or guessing at harmony relationships, you upload an image or build a palette manually, and ColorCraft does the heavy lifting: perceptual color extraction in LAB space, multi-dimensional harmony detection (complementary, triadic, analogous, split-complementary, tetradic, monochromatic), WCAG 2.1 accessibility validation, and interactive D3.js visualization showing exactly how your colors relate on the color wheel.
+## Quick start
-## What's New
+Use Python 3.11 and Node.js 20 or newer. From the repository root:
-- **Windows compatibility fix**: Removed UMAP dependency to eliminate numba/coverage conflicts on Windows systems
-- **Manual-only workflow**: Skip image upload entirely and start with custom colors using the "Skip & Add Colors Manually" button
-- **IPv4 proxy fix**: Explicit 127.0.0.1 targeting for Windows Node.js compatibility
-- **Start Over functionality**: Reset and try different images or workflows without page reload
-- **LAB color space clustering**: Deterministic clustering with a seeded sample and processed-sample medoid representatives
-- **Default starter palette**: Beautiful gradient colors (#667eea, #764ba2, #f093fb) when skipping upload
-- **Enhanced UX flow**: Seamless transitions between upload and manual workflows
-
-## Plain-English Tour
-
-Think of ColorCraft as your color theory assistant. Here's the journey without jargon:
-
-1. **Choose your starting point.** Upload an image to extract its colors, or skip and add colors manually.
-2. **Extract dominant colors.** If you uploaded an image, select how many colors (3-10) you want to extract. ColorCraft analyzes the image in perceptual LAB color space and finds the most representative colors using KMeans clustering.
-3. **Refine your palette.** Add, remove, or edit colors. Click any color swatch to change it with the color picker, or type HEX values directly.
-4. **Analyze color relationships.** Click "Apply Color Theory" and ColorCraft examines your palette for:
- - **Harmony patterns**: Complementary pairs, triadic triangles, analogous neighbors, split-complementary schemes, tetradic squares, and monochromatic variations
- - **Temperature balance**: Warm vs. cool color distribution
- - **Accessibility**: WCAG AA/AAA contrast ratios for every color pair
- - **Palette metrics**: Circular hue diversity, average saturation, and lightness range
-5. **Explore the color wheel.** See every palette color and detected geometric relationship. The center displays relationship fit (0-100), not an aesthetic rating.
-6. **Review detailed insights.** Scroll down to see harmony tags, temperature analysis, accessibility warnings, and a complete contrast ratio table.
-
-If you remember only one thing: ColorCraft turns subjective color choices into objective, measurable insights.
-
-## Repository Layout
-
-```text
-.
-├── README.md # This guide
-├── LICENSE # MIT License
-├── CONTRIBUTING.md # Contribution guidelines
-├── backend/ # Python FastAPI backend
-│ ├── main.py # FastAPI application entry point
-│ ├── color_extractor.py # KMeans clustering in LAB color space
-│ ├── color_theory.py # Harmony detection algorithms
-│ ├── accessibility.py # WCAG 2.1 contrast calculations
-│ └── requirements.txt # Python dependencies
-├── frontend/ # React + TypeScript frontend
-│ ├── src/
-│ │ ├── components/ # React components
-│ │ │ ├── ImageUpload.tsx # File upload and extraction UI
-│ │ │ ├── ColorPalette.tsx # Color grid with editing
-│ │ │ ├── ColorWheel.tsx # D3.js visualization
-│ │ │ └── AnalysisResults.tsx # Harmony and accessibility display
-│ │ ├── App.tsx # Main application component
-│ │ ├── main.tsx # React entry point
-│ │ └── index.css # Global styles with Tailwind
-│ ├── package.json # Frontend dependencies
-│ ├── vite.config.ts # Vite configuration
-│ ├── tailwind.config.js # Tailwind CSS configuration
-│ └── tsconfig.json # TypeScript configuration
-└── .gitignore # Git ignore patterns
-```
-
-## Key Capabilities
-
-### Backend
-
-- **Perceptual color extraction**: Converts RGB to LAB color space for human-vision-aligned clustering
-- **KMeans clustering**: Seeded clustering and sampling with an effective cluster count based on usable unique pixels
-- **LAB ↔ RGB conversion**: High-precision color space transformations with D65 white point
-- **Color theory engine**: Hue angle mathematics for harmony detection with configurable tolerance
-- **Harmony detection**: Complementary (180°), analogous (30-60°), triadic (120°), tetradic (90°), split-complementary, and monochromatic patterns
-- **Temperature analysis**: Warm vs. cool color classification and ratio calculation
-- **WCAG 2.1 compliance**: Relative luminance calculation and contrast ratio formulas
-- **Accessibility validation**: AA/AAA compliance checking for normal and large text
-- **Relationship fit**: Explainable 0-100 fit based on measured angular deviation, confidence, and meaningful-hue coverage
-- **FastAPI framework**: RESTful API with automatic OpenAPI documentation
-- **CORS support**: Configured for local development and production deployment
-
-### Frontend
-
-- **Dual workflow support**: Image upload with extraction OR manual color creation
-- **Interactive color editing**: Click-to-edit swatches with native color picker
-- **HEX input validation**: Real-time validation and RGB/HSL conversion
-- **D3.js color wheel**: Interactive visualization with harmony connections and hover effects
-- **Harmony visualization**: Pattern-distinguished geometry for every detected relationship type, with an accessible text summary
-- **Relationship fit**: Compact geometric-fit display with contributing factors and an explicit non-aesthetic disclaimer
-- **Responsive design**: Tailwind CSS with mobile-first approach
-- **State management**: React hooks for efficient re-rendering
-- **Color format conversion**: Seamless HEX ↔ RGB ↔ HSL transformations
-- **Accessibility-first UI**: High contrast, semantic HTML, keyboard navigation
-- **TypeScript safety**: Full type coverage for color objects and API responses
-
-## Architecture
-
-ColorCraft follows a clean client-server architecture:
-
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Frontend (React) │
-│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
-│ │ Image │ │ Color │ │ Color Wheel │ │
-│ │ Upload │→ │ Palette │→ │ Visualization │ │
-│ └────────────┘ └────────────┘ └──────────────────────┘ │
-│ │ │ ↑ │
-│ └───────────────┴─────────────────────┘ │
-│ │ │
-│ API Calls (REST) │
-└─────────────────────────┼───────────────────────────────────┘
- │
- ↓
-┌─────────────────────────────────────────────────────────────┐
-│ Backend (FastAPI) │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
-│ │ Color │ │ Color │ │ Accessibility │ │
-│ │ Extractor │ │ Theory │ │ Engine │ │
-│ │ (LAB+KMeans)│ │ Engine │ │ (WCAG 2.1) │ │
-│ └──────────────┘ └──────────────┘ └──────────────────┘ │
-└─────────────────────────────────────────────────────────────┘
-```
-
-## Prerequisites
-
-- **Python 3.11+** (3.11 recommended for best compatibility)
-- **Node.js 18+** (LTS version recommended)
-- **Corepack** (included with supported Node.js releases)
-- **pip** (Python package installer)
-
-**Platform Support:**
-- ✅ Windows 10/11
-- ✅ macOS 11+
-- ✅ Linux (Ubuntu 20.04+, Debian 11+)
-
-## Environment Setup
-
-### Windows
-
-```powershell
-# Check Python version
-python --version # Should be 3.11+
-
-# Check Node version
-node --version # Should be 18+
-
-# Upgrade pip
-python -m pip install --upgrade pip
-```
-
-### macOS / Linux
-
-```bash
-# Check Python version
-python3 --version # Should be 3.11+
-
-# Check Node version
-node --version # Should be 18+
-
-# Upgrade pip
-python3 -m pip install --upgrade pip
-```
-
-## Backend Setup
-
-### Option A: Virtual Environment (Recommended)
-
-**Windows:**
```powershell
-cd backend
-py -3.11 -m venv .venv311
-.\.venv311\Scripts\python.exe -m pip install -r requirements-dev.txt
-```
-
-**macOS / Linux:**
-```bash
-cd backend
-python3.11 -m venv .venv
-.venv/bin/python -m pip install -r requirements-dev.txt
-```
-
-### Option B: Global Installation
-
-```bash
-cd backend
-pip install -r requirements.txt
-```
-
-### Dependencies Installed
-
-- **fastapi** (0.104.1): Modern web framework for building APIs
-- **uvicorn** (0.24.0): ASGI server for FastAPI
-- **python-multipart** (0.0.6): File upload support
-- **pillow** (10.1.0): Image processing library
-- **numpy** (1.26.2): Numerical computing
-- **scikit-learn** (1.3.2): KMeans clustering and metrics
-- **pydantic** (2.5.0): Data validation and settings management
-
-## Frontend Setup
-
-```bash
+py -3.11 -m venv backend\.venv311
+.\backend\.venv311\Scripts\python.exe -m pip install -r backend\requirements-dev.txt
cd frontend
corepack pnpm@9.15.9 install
+cd ..
+.\backend\.venv311\Scripts\python.exe dev.py
```
-### Dependencies Installed
-
-- **react** (18.3.1): UI component library
-- **react-dom** (18.3.1): React DOM rendering
-- **typescript** (5.9.3): Type-safe JavaScript
-- **vite** (5.4.20): Fast build tool and dev server
-- **tailwindcss** (3.4.18): Utility-first CSS framework
-- **d3** (7.9.0): Data visualization library
-- **zod** (3.25): Runtime validation for API contracts
-
-## Running the Stack
-
-### One terminal (recommended)
-
-**Windows:**
-```powershell
-cd D:\path\to\ColorCraft
-.\dev.cmd
-```
-
-**macOS / Linux:**
-```bash
-cd /path/to/ColorCraft
-backend/.venv/bin/python dev.py
-```
-
-The launcher finds the backend virtual environment, starts FastAPI and Vite,
-checks for obvious port conflicts, waits for API readiness, and keeps both
-services attached to the same terminal. If either service exits unexpectedly,
-the launcher stops the sibling process and returns a nonzero exit status.
-Press `Ctrl+C` to stop both.
-
-- App: http://127.0.0.1:5174
-- API: http://127.0.0.1:4100
-- Health: http://127.0.0.1:4100/health
-- Readiness: http://127.0.0.1:4100/ready
-
-These defaults do not conflict with Web Video Optimizer, which uses ports 5173
-and 4000.
-
-### Runtime configuration
-
-ColorCraft binds only to the local loopback interface by default. The launcher,
-API, and Vite read these environment variables:
-
-| Variable | Default | Purpose |
-| --- | --- | --- |
-| `COLORCRAFT_WEB_HOST` | `127.0.0.1` | Vite bind host |
-| `COLORCRAFT_WEB_PORT` | `5174` | Vite port |
-| `COLORCRAFT_API_HOST` | `127.0.0.1` | FastAPI bind host |
-| `COLORCRAFT_API_PORT` | `4100` | FastAPI port |
-| `COLORCRAFT_ALLOW_LAN_ACCESS` | `false` | Explicitly permits non-loopback binding and LAN CORS origins |
-| `COLORCRAFT_ALLOWED_ORIGINS` | Resolved frontend origin | Comma-separated exact CORS origins |
-| `VITE_COLORCRAFT_API_URL` | Resolved API origin | Optional direct frontend API origin |
-
-Vite uses `strictPort`, so startup fails instead of silently moving to another
-port. Non-loopback hosts and LAN origins are rejected unless
-`COLORCRAFT_ALLOW_LAN_ACCESS=true`.
-
-Trusted LAN example:
-
-```powershell
-$env:COLORCRAFT_ALLOW_LAN_ACCESS = "true"
-$env:COLORCRAFT_WEB_HOST = "0.0.0.0"
-$env:COLORCRAFT_API_HOST = "0.0.0.0"
-$env:COLORCRAFT_ALLOWED_ORIGINS = "http://192.168.1.20:5174"
-.\dev.cmd
-```
-
-Replace the example address with the host computer's LAN address. ColorCraft
-has no authentication and is intended only for trusted local or explicitly
-enabled trusted LAN use, not public hosting.
-
-### Two terminals (manual alternative)
-
-#### Terminal 1: Backend
-
-**Windows:**
-```powershell
-cd backend
-.\.venv311\Scripts\python.exe -m uvicorn main:app --host 127.0.0.1 --port 4100
-```
-
-**macOS / Linux:**
-```bash
-cd backend
-./.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 4100
-```
-
-**Expected output:**
-```
-INFO: Started server process [XXXX]
-INFO: Waiting for application startup.
-INFO: Application startup complete.
-INFO: Uvicorn running on http://127.0.0.1:4100 (Press CTRL+C to quit)
-```
-
-**Verify:** Visit http://127.0.0.1:4100/ready and confirm the status is
-`ready`.
-
-#### Terminal 2: Frontend
-
-```powershell
-cd frontend
-corepack pnpm@9.15.9 dev
-```
-
-**Expected output:**
-```
- VITE v5.4.20 ready in XXX ms
-
- ➜ Local: http://127.0.0.1:5174/
- ➜ Network: use --host to expose
- ➜ press h + enter to show help
-```
-
-**Access:** Open http://127.0.0.1:5174 in your browser
-
-## Using ColorCraft
-
-### Workflow Options
-
-#### Option 1: Image Upload & Extraction
-
-1. **Upload an image**: Click the upload area or drag and drop a JPG, PNG, or WebP file
-2. **Select color count**: Use the slider to choose 3-10 colors
-3. **Extract colors**: Click "Find Colors" button
-4. **Wait for processing**: Typically 1-2 seconds for standard images
-5. **Review extracted colors**: See swatches with HEX, RGB, and HSL values
-6. **Optional refinement**: Add, remove, or edit colors manually
-7. **Analyze**: Click "Apply Color Theory" to see harmony and accessibility analysis
-
-#### Option 2: Manual Color Creation
+On macOS or Linux, create `backend/.venv`, install the same requirements, then run `backend/.venv/bin/python dev.py`. One launcher starts both services and opens the app:
-1. **Skip upload**: Click "Skip & Add Colors Manually" button
-2. **Start with defaults**: Begin with 3 gradient colors
-3. **Edit colors**: Click any swatch to open color picker
-4. **Type HEX values**: Edit HEX codes directly in the input field
-5. **Add more colors**: Click the "+" button to add additional colors
-6. **Remove colors**: Click the "×" button on any color to remove it
-7. **Analyze**: Click "Apply Color Theory" when you have 2+ colors
+- Web: `http://127.0.0.1:5174`
+- API: `http://127.0.0.1:4100`
+- Runtime metadata: `http://127.0.0.1:4100/metadata`
-### Controls Reference
-
-| Control | Action | Location |
-|---------|--------|----------|
-| **Upload Area** | Click or drag-drop to select image | Top of page |
-| **Color Slider** | Adjust number of colors to extract (3-10) | Below upload area |
-| **Find Colors** | Extract colors from uploaded image | Below slider |
-| **Skip & Add Manually** | Bypass upload and start with default colors | Below upload area |
-| **Color Swatch** | Click to open color picker | Color palette grid |
-| **HEX Input** | Type HEX code directly | Below each swatch |
-| **+ Button** | Add new color to palette | End of color grid |
-| **× Button** | Remove color from palette | Top-right of each swatch |
-| **Apply Color Theory** | Analyze color relationships | Below color palette |
-| **Start Over** | Reset and return to upload screen | Top-right of palette |
-
-### Exploration Tips
-
-- **Minimum colors**: You need at least 2 colors for analysis
-- **Optimal range**: 3-7 colors typically produce the clearest harmony patterns
-- **Image selection**: Photos with distinct color regions work best for extraction
-- **Manual refinement**: Extracted colors can be edited after extraction
-- **Harmony connections**: Look for colored lines on the color wheel showing detected relationships
-- **Accessibility focus**: Red ✗ marks in the contrast table indicate potential issues
-- **Temperature balance**: Check if your palette skews warm, cool, or balanced
-- **Relationship fit**: Higher values mean detected hue geometry more closely matches the documented structures; this is not an aesthetic rating
-
-## Color Extraction Algorithm
-
-ColorCraft uses a sophisticated perceptual color extraction pipeline:
-
-### Step 1: Image Preprocessing
-```python
-# Resize to max 400px for performance
-# Ignore fully transparent pixels; composite partial alpha over white
-# Sample up to 10,000 pixels with a seeded generator
-```
-
-### Step 2: RGB → LAB Conversion
-```python
-# LAB color space is perceptually uniform
-# Distances in LAB space match human vision
-# Uses D65 white point (standard daylight)
-# Gamma correction for sRGB → linear RGB
-# XYZ intermediate transformation
-```
-
-### Step 3: KMeans Clustering
-```python
-# Effective clusters = min(requested colors, usable unique sampled pixels)
-# Seeded initialization for deterministic output
-# 300 max iterations for convergence
-# Clusters directly in LAB space
-# No dimensionality reduction needed
-```
-
-### Step 4: Representative Selection
-```python
-# Select the sampled RGB pixel nearest each LAB cluster center (a medoid)
-# Sort representatives by sampled population, largest first
-# Report population ratio and sampled pixel count
-```
+See [Getting started](./docs/getting-started.md) for setup and recovery details.
-### Step 5: Output Validation
+## Product workflow
-Representatives are processed-sample RGB values, so conversion cannot create
-NaN-derived or malformed HEX values. The API returns the actual number of
-colors found, which can be lower than the requested maximum.
-
-**Why LAB instead of RGB?**
-- RGB distances don't match perceived color differences
-- LAB is designed for human vision (CIE 1976)
-- Equal distances in LAB = equal perceptual differences
-- Better clustering results for color palettes
-
-## Color Theory Engine
-
-ColorCraft detects multiple harmony patterns using hue angle mathematics:
-
-### Harmony Detection Methods
-
-| Harmony Type | Hue Relationship | Tolerance | Visual Pattern |
-|--------------|------------------|-----------|----------------|
-| **Complementary** | 180° apart | ±12° | Opposite sides of wheel |
-| **Analogous** | 30° apart | ±15° | Adjacent neighbors |
-| **Triadic** | Three 120° gaps | ±12° per gap | Equilateral triangle |
-| **Tetradic** | Four 90° gaps | ±10° per gap | Square |
-| **Split-Complementary** | 150°, 150°, and 60° | ±12° | Y-shape pattern |
-| **Monochromatic** | Same meaningful hue, varied S/L | ±10° hue | Single color family |
-
-### Temperature Analysis
-
-**Warm Colors** (0-60°, 300-360°):
-- Reds, oranges, yellows
-- Associated with energy, passion, warmth
-
-**Cool Colors** (120-300°):
-- Greens, blues, purples
-- Associated with calm, professionalism, nature
-
-**Neutral** (60-120°):
-- Yellow-greens, chartreuse
-- Transitional colors
-
-### Geometric relationship fit
-
-Relationship fit combines the best measured confidence for each detected
-relationship type with the share of meaningful, chromatic hues involved.
-Every relationship reports expected angles, measured angles, deviation, and
-confidence. Colors below 10% saturation do not count as hue evidence, and
-duplicate hues cannot manufacture a relationship. This metric describes
-geometry only—not whether a palette is attractive or suitable for a design.
-
-## Accessibility Analysis
-
-ColorCraft implements full WCAG 2.1 contrast ratio calculations:
-
-### Contrast Ratio Formula
-
-```python
-# 1. Calculate relative luminance for each color
-L = 0.2126 * R + 0.7152 * G + 0.0722 * B
-# (where R, G, B are gamma-corrected)
-
-# 2. Compute contrast ratio
-ratio = (lighter_L + 0.05) / (darker_L + 0.05)
-# Range: 1:1 (no contrast) to 21:1 (black on white)
-```
-
-### WCAG Compliance Levels
-
-| Level | Normal Text | Large Text | Use Case |
-|-------|-------------|------------|----------|
-| **AA** | 4.5:1 | 3:1 | Minimum for body text |
-| **AAA** | 7:1 | 4.5:1 | Enhanced accessibility |
-
-**Large Text Definition:**
-- 18pt+ regular weight
-- 14pt+ bold weight
-
-### Accessibility Features
-
-- ✅ Pairwise contrast analysis for all color combinations
-- ✅ AA/AAA compliance badges for normal and large text
-- ✅ Automatic issue flagging with severity levels
-- ✅ Visual indicators (✓/✗) in contrast table
-- ✅ Summary statistics (total pairs, compliant count)
-- ✅ Specific warnings with color pairs and ratios
-
-## API Endpoints
-
-### Service Metadata
-```http
-GET /
-```
-**Response:**
-```json
-{
- "status": "ok",
- "service": "colorcraft-api",
- "version": "1.0.0"
-}
-```
-
-### Health and Readiness
-
-```http
-GET /health
-GET /ready
-```
-
-`/health` indicates that the API process is alive. `/ready` returns `ready`
-after color extraction, palette analysis, and color suggestions have
-initialized. The single-terminal launcher waits for this readiness response.
-
-Request and response validation details, including the canonical color-input
-strategy, are documented in [API Contracts](docs/api-contracts.md).
-
-### Extract Colors
-```http
-POST /api/extract-colors?n_colors=5
-Content-Type: multipart/form-data
-
-file:
-```
-**Parameters:**
-- `n_colors` (query): Maximum number of colors to extract (3-10)
-- `file` (form): Image file (JPG, PNG, WebP), up to 10 MB and 40 million decoded pixels
-
-**Response:**
-```json
-{
- "success": true,
- "colors": [
- {
- "hex": "#667eea",
- "rgb": {"r": 102, "g": 126, "b": 234},
- "hsl": {"h": 229, "s": 75, "l": 66},
- "population": 0.52,
- "pixelCount": 5200
- },
- {
- "hex": "#f093fb",
- "rgb": {"r": 240, "g": 147, "b": 251},
- "hsl": {"h": 294, "s": 92, "l": 78},
- "population": 0.31,
- "pixelCount": 3100
- },
- {
- "hex": "#764ba2",
- "rgb": {"r": 118, "g": 75, "b": 162},
- "hsl": {"h": 270, "s": 37, "l": 46},
- "population": 0.17,
- "pixelCount": 1700
- }
- ],
- "count": 3
-}
-```
-
-### Analyze Colors
-```http
-POST /api/analyze-colors
-Content-Type: application/json
-
-{
- "colors": [
- {
- "hex": "#667eea",
- "rgb": {"r": 102, "g": 126, "b": 234},
- "hsl": {"h": 229, "s": 75, "l": 66}
- },
- {
- "hex": "#f093fb",
- "rgb": {"r": 240, "g": 147, "b": 251},
- "hsl": {"h": 294, "s": 92, "l": 78}
- }
- ]
-}
-```
-
-**Response:**
-```json
-{
- "success": true,
- "analysis": {
- "colorTheory": {
- "harmonies": {
- "complementary": [{
- "type": "complementary",
- "colorIndexes": [0, 1],
- "expectedAngles": [180.0],
- "measuredAngles": [178.0],
- "deviation": 2.0,
- "confidence": 0.889
- }],
- "triadic": [],
- "analogous": [],
- "tetradic": [],
- "splitComplementary": [],
- "monochromatic": []
- },
- "temperatureBalance": {
- "balance": "cool",
- "warmCount": 1,
- "coolCount": 4,
- "warmRatio": 0.2,
- "coolRatio": 0.8
- },
- "relationshipFit": 75,
- "relationshipSummary": "Strong geometric relationship",
- "relationshipFactors": ["Best complementary pair deviation: 2.0 degrees."],
- "tags": [
- "Complementary Harmony Detected",
- "Triadic Harmony Detected",
- "Cool Color Palette",
- "Balanced Saturation"
- ],
- "metrics": {
- "hueDiversity": 45.2,
- "saturationAvg": 68.4,
- "lightnessRange": 42
- }
- },
- "accessibility": {
- "pairs": [
- {
- "color1": "#667eea",
- "color2": "#ffffff",
- "ratio": 4.52,
- "aaNormal": true,
- "aaLarge": true,
- "aaaNormal": false,
- "aaaLarge": true
- }
- ],
- "issues": [
- {
- "type": "low_contrast",
- "message": "Low contrast detected between #f1f1f1 and #aaaaaa (ratio: 2.1)",
- "severity": "warning",
- "color1": "#f1f1f1",
- "color2": "#aaaaaa",
- "ratio": 2.1
- }
- ],
- "summary": {
- "totalPairs": 10,
- "aaNormalPasses": 7,
- "aaLargePasses": 8,
- "aaaNormalPasses": 3,
- "aaaLargePasses": 7
- }
- }
- }
-}
-```
-
-## Data Flow & Processing
-
-### Image Upload Flow
```mermaid
-graph TD
- A[User Uploads Image] --> B[Frontend: File Validation]
- B --> C[API: POST /extract-colors]
- C --> D[Backend: Load Image with Pillow]
- D --> E[Resize to 400px max]
- E --> F[Sample up to 10k pixels]
- F --> G[Convert RGB → LAB]
- G --> H[KMeans Clustering 20 inits]
- H --> I[Select Median Colors]
- I --> J[Convert LAB → RGB/HEX/HSL]
- J --> K[Return Color Array]
- K --> L[Frontend: Display Color Palette]
+flowchart LR
+ Start[Upload image or start manually] --> Edit[Refine palette]
+ Edit --> Save[Save locally]
+ Save --> Review[Review harmony and contrast]
+ Review --> Export[Copy or download exports]
+ Save --> Library[Search, reopen, rename, duplicate, or delete]
```
-### Color Analysis Flow
-```mermaid
-graph TD
- A[User Clicks Apply Color Theory] --> B[Frontend: Validate 2+ Colors]
- B --> C[API: POST /analyze-colors]
- C --> D[Backend: Extract Hues]
- D --> E[Detect Complementary 180°]
- D --> F[Detect Triadic 120°]
- D --> G[Detect Analogous 30-60°]
- D --> H[Detect Tetradic 90°]
- D --> I[Detect Split-Complementary]
- D --> J[Detect Monochromatic]
- E --> K[Calculate Temperature Balance]
- F --> K
- G --> K
- H --> K
- I --> K
- J --> K
- K --> L[Compute Relationship Fit]
- L --> M[Calculate WCAG Ratios]
- M --> N[Generate Tags & Metrics]
- N --> O[Return Analysis Object]
- O --> P[Frontend: Render Visualizations]
-```
+The application makes state explicit: **Unsaved** means no local record exists, **Saved** means the open palette matches its record, and **Modified** means local edits need **Save changes**. Starting another palette or opening a saved palette prompts before meaningful unsaved work is discarded.
-### Color Wheel Rendering
-```mermaid
-graph LR
- A[Analysis Results] --> B[D3.js Initialize SVG]
- B --> C[Draw 360° Color Wheel]
- C --> D[Plot Color Points by Hue]
- D --> E[Draw Harmony Connections]
- E --> F[Add Hover Interactions]
- F --> G[Display Relationship Fit]
- G --> H[Render Complete Wheel]
-```
-
-## Troubleshooting
-
-### Backend Issues
+## Capabilities
-**Problem: `ModuleNotFoundError: No module named 'fastapi'`**
-```bash
-# Solution: Install dependencies
-cd backend
-pip install -r requirements.txt
-```
+- Deterministic LAB-space image color extraction
+- Manual palette creation and direct HEX editing
+- Harmony visualization and explainable relationship fit
+- WCAG contrast-role review and color suggestions
+- CSS, JSON, Tailwind, and token export
+- IndexedDB palette library with search and recent items
+- Light, dark, and system themes with responsive navigation
+- Dashboard discovery through a static manifest and runtime metadata endpoint
-**Problem: `Address already in use` (port 4100)**
-```bash
-# Windows: Find and kill process
-netstat -ano | findstr :4100
-taskkill /PID /F
+## Validation
-# macOS/Linux: Find and kill process
-lsof -ti:4100 | xargs kill -9
-```
+After installing the development dependencies and Playwright Chromium:
-**Problem: `ImportError: DLL load failed` (Windows)**
-```bash
-# Solution: Reinstall numpy with proper binaries
-pip uninstall numpy
-pip install numpy==1.26.2
-```
-
-### Frontend Issues
-
-**Problem: `ECONNREFUSED 127.0.0.1:4100`**
-```bash
-# Solution 1: Ensure backend is running
-cd backend
-python -m uvicorn main:app --host 127.0.0.1 --port 4100
-
-# Solution 2: Restart frontend after backend starts
-cd frontend
-# Press Ctrl+C to stop
-corepack pnpm@9.15.9 dev
-```
-
-**Problem: `pnpm: command not found`**
-```bash
-# Solution: Run pnpm through Corepack
-corepack pnpm@9.15.9 install
-```
-
-**Problem: Frontend won't start after `git pull`**
-```bash
-# Solution: Clean reinstall
+```powershell
cd frontend
-rm -rf node_modules pnpm-lock.yaml
-corepack pnpm@9.15.9 install
-corepack pnpm@9.15.9 dev
+corepack pnpm@9.15.9 exec playwright install chromium
+cd ..
+.\backend\.venv311\Scripts\python.exe check.py
```
-### Image Upload Issues
-
-**Problem: "Invalid file type" error**
-- **Solution**: Ensure file is JPG, PNG, or WebP format
-- Check file extension matches actual format
-
-**Problem: Colors extraction takes too long**
-- **Solution**: Image is likely very large
-- Try resizing image to under 2000px before upload
-- App auto-resizes to 400px but large images take time to load
+`check.py` runs formatting, linting, frontend and backend typing, unit and integration coverage, production build, Playwright workflow coverage, and automated accessibility checks. CI runs the same command. See [Testing](./docs/testing.md).
-**Problem: Extracted colors don't match image**
-- **Solution**: Image may have too many similar colors
-- Try increasing number of colors to extract (up to 10)
-- Or manually adjust colors after extraction
+## Documentation
-### Analysis Issues
+The [documentation index](./docs/README.md) links setup, workflow, runtime configuration, architecture, API, persistence and privacy, dashboard manifest, brand, testing, screenshot review, and troubleshooting guides.
-**Problem: "Please add at least 2 colors to analyze"**
-- **Solution**: Add more colors using the "+" button
-- Or extract colors from an image first
+## Privacy and security
-**Problem: Low relationship fit despite a good-looking palette**
-- **Explanation**: Relationship fit measures angular geometry, not aesthetics
-- Beautiful palettes may intentionally avoid strict geometric relationships
-- Treat the measured factors, accessibility, and intended use as separate evidence
-
-**Problem: All contrast ratios fail AA/AAA**
-- **Solution**: Your colors may be too similar in lightness
-- Add darker or lighter colors for better contrast
-- Use the contrast table to identify which pairs need adjustment
-
-## Roadmap & Next Steps
-
-### Planned Features (Post-MVP)
-
-- [ ] **Palette Export**: Save palettes as .ASE, .JSON, .SVG, .PNG
-- [ ] **AI Palette Naming**: Generate descriptive names like "Autumn Glow" or "Ocean Breeze"
-- [ ] **Batch Analysis**: Upload multiple images and compare color consistency
-- [ ] **Color Blindness Simulation**: Preview palette for deuteranopia, protanopia, tritanopia
-- [ ] **Gradient Generator**: Create smooth gradients between selected colors
-- [ ] **Palette History**: Save and revisit previous analyses
-- [ ] **Shareable Links**: Generate URLs to share palettes with collaborators
-- [ ] **Plugin Integration**: Figma, Photoshop, Canva plugins
-- [ ] **API Key System**: Rate limiting and usage tracking
-- [ ] **Advanced Clustering**: DBSCAN, hierarchical clustering options
-- [ ] **Color Mood Analysis**: Emotional associations and psychological impact
-- [ ] **Brand Palette Validation**: Upload brand guidelines and validate compliance
-
-### Technical Improvements
-
-- [ ] **Database Integration**: PostgreSQL for multi-user support
-- [ ] **Caching Layer**: Redis for frequently extracted color palettes
-- [ ] **WebSocket Support**: Real-time progress updates during extraction
-- [ ] **Docker Deployment**: Containerized stack for easy deployment
-- [ ] **CI/CD Pipeline**: Automated testing and deployment
-- [ ] **Performance Monitoring**: Sentry integration for error tracking
-- [ ] **API Documentation**: Interactive Swagger UI
-- [ ] **Unit Tests**: Comprehensive test coverage for backend and frontend
-- [ ] **E2E Tests**: Playwright or Cypress for user flow testing
-
-## Contributing
-
-Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
-
-### Quick Start for Contributors
-
-1. **Fork the repository**
-2. **Clone your fork**: `git clone https://github.com/YOUR_USERNAME/ColorCraft.git`
-3. **Create a branch**: `git checkout -b feature/your-feature-name`
-4. **Make changes** and test thoroughly
-5. **Commit**: `git commit -m "Add your feature"`
-6. **Push**: `git push origin feature/your-feature-name`
-7. **Create Pull Request** on GitHub
-
-### Development Guidelines
-
-- **Backend**: Follow PEP 8, use type hints, add docstrings
-- **Frontend**: Use TypeScript, follow React best practices, use functional components
-- **Commits**: Use conventional commits (feat:, fix:, docs:, etc.)
-- **Testing**: Add tests for new features
-- **Documentation**: Update README and inline comments
+ColorCraft binds to loopback addresses by default. Palette records are stored in the browser's IndexedDB database; theme preference is the only application preference stored in LocalStorage. Source images are sent to the local API for extraction but are not retained by ColorCraft after processing. Clearing site data removes saved palettes. Read [Persistence and privacy](./docs/persistence-and-privacy.md) before enabling LAN access.
## License
-MIT License - See [LICENSE](LICENSE) file for details.
-
-Copyright (c) 2025 ColorCraft
-
-## Mermaid Diagrams
-
-### System Architecture
-
-```mermaid
-graph TB
- subgraph Frontend["Frontend (React + TypeScript)"]
- UI[User Interface]
- Upload[ImageUpload Component]
- Palette[ColorPalette Component]
- Wheel[ColorWheel Component D3.js]
- Results[AnalysisResults Component]
- end
-
- subgraph Backend["Backend (Python FastAPI)"]
- API[FastAPI Router]
- Extractor[Color Extractor
LAB + KMeans]
- Theory[Color Theory Engine
Harmony Detection]
- Access[Accessibility Engine
WCAG 2.1]
- end
-
- subgraph Processing["Processing Pipeline"]
- LAB[RGB → LAB Conversion]
- KMeans[KMeans Clustering
20 Initializations]
- Median[Median Selection]
- RGB[LAB → RGB/HEX/HSL]
- end
-
- UI --> Upload
- UI --> Palette
- Upload -->|Image File| API
- Palette -->|Color Array| API
-
- API --> Extractor
- API --> Theory
- API --> Access
-
- Extractor --> LAB
- LAB --> KMeans
- KMeans --> Median
- Median --> RGB
- RGB -->|Colors| Palette
-
- Theory -->|Harmonies| Results
- Access -->|WCAG Ratios| Results
- Results --> Wheel
-```
-
-### Color Extraction Pipeline
-
-```mermaid
-sequenceDiagram
- autonumber
- actor User
- participant UI as ImageUpload UI
- participant API as FastAPI /extract-colors
- participant Img as Image Processor
- participant LAB as LAB Converter
- participant KM as KMeans Clusterer
- participant Conv as Color Converter
-
- User->>UI: Upload image (JPG/PNG/WebP)
- UI->>UI: Validate file type
- UI->>API: POST /extract-colors?n_colors=5
- API->>Img: Load image with Pillow
- Img->>Img: Convert to RGB if needed
- Img->>Img: Resize to max 400px
- Img->>Img: Extract pixel array
- Img->>Img: Sample 10k pixels if large
-
- Img->>LAB: Convert RGB pixels → LAB
- LAB->>LAB: Gamma correction
- LAB->>LAB: RGB → XYZ → LAB (D65)
- LAB-->>KM: LAB pixel array
-
- KM->>KM: KMeans fit (20 inits, 300 iters)
- KM->>KM: Assign cluster labels
- KM->>KM: Select nearest sampled RGB medoid
- KM-->>Conv: LAB cluster colors
-
- Conv->>Conv: LAB → XYZ → RGB
- Conv->>Conv: Gamma inverse correction
- Conv->>Conv: Clip to [0, 255]
- Conv->>Conv: RGB → HEX
- Conv->>Conv: RGB → HSL
- Conv-->>API: Color array (HEX, RGB, HSL)
-
- API-->>UI: JSON response with colors
- UI-->>User: Display color palette
-```
-
-### Color Analysis Pipeline
-
-```mermaid
-sequenceDiagram
- autonumber
- actor User
- participant UI as App UI
- participant API as FastAPI /analyze-colors
- participant Theory as Color Theory Engine
- participant Access as Accessibility Engine
- participant Wheel as D3.js Color Wheel
-
- User->>UI: Click "Apply Color Theory"
- UI->>UI: Validate 2+ colors
- UI->>API: POST /analyze-colors {colors}
-
- API->>Theory: Extract hue angles
- Theory->>Theory: Detect complementary (180°±30°)
- Theory->>Theory: Detect triadic (120°±30°)
- Theory->>Theory: Detect analogous (30-60°±30°)
- Theory->>Theory: Detect tetradic (90°±30°)
- Theory->>Theory: Detect split-complementary
- Theory->>Theory: Detect monochromatic (±15° hue)
- Theory->>Theory: Analyze temperature (warm/cool)
- Theory->>Theory: Calculate geometric relationship fit (0-100)
- Theory->>Theory: Generate harmony tags
- Theory-->>API: Color theory analysis
-
- API->>Access: Calculate pairwise contrasts
- Access->>Access: Compute relative luminance
- Access->>Access: Calculate contrast ratios
- Access->>Access: Check AA/AAA compliance
- Access->>Access: Flag accessibility issues
- Access-->>API: Accessibility analysis
-
- API-->>UI: Combined analysis JSON
- UI->>Wheel: Render color wheel
- Wheel->>Wheel: Draw 360° wheel background
- Wheel->>Wheel: Plot colors by hue angle
- Wheel->>Wheel: Draw harmony connections
- Wheel->>Wheel: Add compact relationship-fit annotation
- Wheel-->>UI: Interactive visualization
-
- UI-->>User: Display analysis results
-```
-
-### Data Model
-
-```mermaid
-erDiagram
- COLOR {
- string hex PK
- object rgb
- object hsl
- }
-
- HARMONY_ANALYSIS {
- uuid id PK
- array complementary_pairs
- array triadic_groups
- array analogous_groups
- array tetradic_groups
- array split_complementary
- boolean monochromatic
- object temperature_balance
- int relationship_fit
- array tags
- object metrics
- }
-
- ACCESSIBILITY_ANALYSIS {
- uuid id PK
- array contrast_pairs
- array issues
- object summary
- }
-
- CONTRAST_PAIR {
- string color1 FK
- string color2 FK
- float ratio
- boolean aa_normal
- boolean aa_large
- boolean aaa_normal
- boolean aaa_large
- }
-
- COLOR ||--o{ CONTRAST_PAIR : "participates in"
- HARMONY_ANALYSIS ||--o{ COLOR : "analyzes"
- ACCESSIBILITY_ANALYSIS ||--o{ CONTRAST_PAIR : "contains"
-```
-
----
-
-**Built with ❤️ for designers, developers, and artists who care about color.**
-
+[MIT](./LICENSE)
diff --git a/app-manifest.json b/app-manifest.json
new file mode 100644
index 0000000..092843c
--- /dev/null
+++ b/app-manifest.json
@@ -0,0 +1,25 @@
+{
+ "schemaVersion": 1,
+ "id": "colorcraft",
+ "name": "ColorCraft",
+ "descriptor": "Local color utility",
+ "version": "1.0.0",
+ "icon": "/colorcraft-mark.svg",
+ "defaults": {
+ "webAddress": "http://127.0.0.1:5174",
+ "apiAddress": "http://127.0.0.1:4100"
+ },
+ "endpoints": {
+ "metadata": "/metadata",
+ "health": "/health",
+ "readiness": "/ready"
+ },
+ "capabilities": [
+ "image-color-extraction",
+ "palette-editing",
+ "harmony-analysis",
+ "contrast-review",
+ "palette-export",
+ "local-palette-library"
+ ]
+}
diff --git a/backend/accessibility.py b/backend/accessibility.py
index 34f926d..c28cf1e 100644
--- a/backend/accessibility.py
+++ b/backend/accessibility.py
@@ -8,18 +8,19 @@
def hex_to_rgb(hex_color):
"""Convert hex color to RGB."""
- hex_color = hex_color.lstrip('#')
- return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
+ hex_color = hex_color.lstrip("#")
+ return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
def relative_luminance(rgb):
"""Calculate relative luminance using the current sRGB breakpoint."""
+
def adjust(channel):
channel = channel / 255.0
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
-
+
r, g, b = [adjust(c) for c in rgb]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
@@ -27,11 +28,11 @@ def adjust(channel):
def contrast_ratio(color1, color2):
"""
Calculate WCAG contrast ratio between two colors.
-
+
Args:
color1: RGB tuple or hex string
color2: RGB tuple or hex string
-
+
Returns:
Contrast ratio (1-21)
"""
@@ -39,95 +40,96 @@ def contrast_ratio(color1, color2):
color1 = hex_to_rgb(color1)
if isinstance(color2, str):
color2 = hex_to_rgb(color2)
-
+
l1 = relative_luminance(color1)
l2 = relative_luminance(color2)
-
+
lighter = max(l1, l2)
darker = min(l1, l2)
-
+
return (lighter + 0.05) / (darker + 0.05)
def wcag_rating(ratio):
"""
Get WCAG rating for a contrast ratio.
-
+
Returns:
Dictionary with AA and AAA compliance for normal and large text
"""
return {
- 'ratio': round(ratio, 2),
- 'aa_normal': ratio >= AA_NORMAL_MINIMUM,
- 'aa_large': ratio >= AA_LARGE_MINIMUM,
- 'aaa_normal': ratio >= AAA_NORMAL_MINIMUM,
- 'aaa_large': ratio >= AAA_LARGE_MINIMUM
+ "ratio": round(ratio, 2),
+ "aa_normal": ratio >= AA_NORMAL_MINIMUM,
+ "aa_large": ratio >= AA_LARGE_MINIMUM,
+ "aaa_normal": ratio >= AAA_NORMAL_MINIMUM,
+ "aaa_large": ratio >= AAA_LARGE_MINIMUM,
}
def analyze_accessibility(colors):
"""
Analyze accessibility for all color pairs.
-
+
Args:
colors: List of color dictionaries with 'hex' key
-
+
Returns:
Dictionary with accessibility analysis
"""
results = {
- 'pairs': [],
- 'issues': [],
- 'summary': {
- 'total_pairs': 0,
- 'aa_normal_passes': 0,
- 'aa_large_passes': 0,
- 'aaa_normal_passes': 0,
- 'aaa_large_passes': 0
- }
+ "pairs": [],
+ "issues": [],
+ "summary": {
+ "total_pairs": 0,
+ "aa_normal_passes": 0,
+ "aa_large_passes": 0,
+ "aaa_normal_passes": 0,
+ "aaa_large_passes": 0,
+ },
}
-
+
# Analyze all pairs
for i, color1 in enumerate(colors):
for j, color2 in enumerate(colors):
if i >= j:
continue
-
- ratio = contrast_ratio(color1['hex'], color2['hex'])
+
+ ratio = contrast_ratio(color1["hex"], color2["hex"])
rating = wcag_rating(ratio)
-
+
pair_result = {
- 'color1': color1['hex'],
- 'color2': color2['hex'],
- 'ratio': rating['ratio'],
- 'aa_normal': rating['aa_normal'],
- 'aa_large': rating['aa_large'],
- 'aaa_normal': rating['aaa_normal'],
- 'aaa_large': rating['aaa_large']
+ "color1": color1["hex"],
+ "color2": color2["hex"],
+ "ratio": rating["ratio"],
+ "aa_normal": rating["aa_normal"],
+ "aa_large": rating["aa_large"],
+ "aaa_normal": rating["aaa_normal"],
+ "aaa_large": rating["aaa_large"],
}
-
- results['pairs'].append(pair_result)
- results['summary']['total_pairs'] += 1
-
- if rating['aa_normal']:
- results['summary']['aa_normal_passes'] += 1
- if rating['aa_large']:
- results['summary']['aa_large_passes'] += 1
- if rating['aaa_normal']:
- results['summary']['aaa_normal_passes'] += 1
- if rating['aaa_large']:
- results['summary']['aaa_large_passes'] += 1
-
+
+ results["pairs"].append(pair_result)
+ results["summary"]["total_pairs"] += 1
+
+ if rating["aa_normal"]:
+ results["summary"]["aa_normal_passes"] += 1
+ if rating["aa_large"]:
+ results["summary"]["aa_large_passes"] += 1
+ if rating["aaa_normal"]:
+ results["summary"]["aaa_normal_passes"] += 1
+ if rating["aaa_large"]:
+ results["summary"]["aaa_large_passes"] += 1
+
# Flag potential issues
- if not rating['aa_large']:
- results['issues'].append({
- 'type': 'low_contrast',
- 'message': f"Low contrast detected between {color1['hex']} and {color2['hex']} (ratio: {rating['ratio']})",
- 'severity': 'warning',
- 'color1': color1['hex'],
- 'color2': color2['hex'],
- 'ratio': rating['ratio']
- })
-
- return results
+ if not rating["aa_large"]:
+ results["issues"].append(
+ {
+ "type": "low_contrast",
+ "message": f"Low contrast detected between {color1['hex']} and {color2['hex']} (ratio: {rating['ratio']})",
+ "severity": "warning",
+ "color1": color1["hex"],
+ "color2": color2["hex"],
+ "ratio": rating["ratio"],
+ }
+ )
+ return results
diff --git a/backend/color_extractor.py b/backend/color_extractor.py
index de41a5a..6633802 100644
--- a/backend/color_extractor.py
+++ b/backend/color_extractor.py
@@ -13,7 +13,6 @@
from PIL import Image, UnidentifiedImageError
from sklearn.cluster import KMeans
-
MAX_IMAGE_PIXELS = 40_000_000
MAX_SAMPLE_PIXELS = 10_000
SAMPLE_SEED = 42
@@ -47,11 +46,7 @@ def rgb_to_lab(rgb: list[int] | tuple[int, int, int] | np.ndarray) -> list[float
delta = 6 / 29
def transform(value: float) -> float:
- return (
- value ** (1 / 3)
- if value > delta**3
- else value / (3 * delta**2) + 4 / 29
- )
+ return value ** (1 / 3) if value > delta**3 else value / (3 * delta**2) + 4 / 29
fx = transform(x / 95.047)
fy = transform(y / 100)
@@ -133,6 +128,7 @@ def hsl_to_rgb(hsl: list[int] | tuple[int, int, int]) -> list[int]:
if saturation == 0:
red = green = blue = lightness
else:
+
def hue_to_rgb(p: float, q: float, value: float) -> float:
value %= 1
if value < 1 / 6:
@@ -162,9 +158,7 @@ def _visible_rgb_pixels(image: Image.Image) -> np.ndarray:
raise NoUsablePixelsError("The image contains no visible pixels.")
alpha = visible[:, 3:4].astype(np.float64) / 255.0
- composited = (
- visible[:, :3].astype(np.float64) * alpha + 255.0 * (1.0 - alpha)
- )
+ composited = visible[:, :3].astype(np.float64) * alpha + 255.0 * (1.0 - alpha)
return np.rint(composited).clip(0, 255).astype(np.uint8)
@@ -172,9 +166,7 @@ def _sample_pixels(pixels: np.ndarray) -> np.ndarray:
if len(pixels) <= MAX_SAMPLE_PIXELS:
return pixels
generator = np.random.default_rng(SAMPLE_SEED)
- indexes = generator.choice(
- len(pixels), size=MAX_SAMPLE_PIXELS, replace=False
- )
+ indexes = generator.choice(len(pixels), size=MAX_SAMPLE_PIXELS, replace=False)
return pixels[indexes]
@@ -260,6 +252,9 @@ def extract_colors(image_bytes: bytes, n_colors: int = 5) -> list[dict[str, obje
)
extracted.sort(
- key=lambda color: (-int(color["pixelCount"]), str(color["hex"]))
+ key=lambda color: (
+ -color["pixelCount"] if isinstance(color["pixelCount"], int) else 0,
+ str(color["hex"]),
+ )
)
return extracted
diff --git a/backend/color_suggestions.py b/backend/color_suggestions.py
index 2e0a2fe..0790f70 100644
--- a/backend/color_suggestions.py
+++ b/backend/color_suggestions.py
@@ -1,8 +1,8 @@
"""
Color suggestion engine for generating harmonious color recommendations.
"""
+
from color_extractor import hsl_to_rgb, rgb_to_hsl
-import math
def normalize_hue(hue):
@@ -17,461 +17,475 @@ def normalize_hue(hue):
def generate_complementary(base_color):
"""
Generate complementary color (180° opposite).
-
+
Creates maximum contrast and visual tension.
Perfect for call-to-action buttons and emphasis.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
comp_hue = normalize_hue(h + 180)
-
+
suggestions = [
{
- 'hue': comp_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': 'Direct Complement',
- 'description': 'Exact opposite on the color wheel'
+ "hue": comp_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": "Direct Complement",
+ "description": "Exact opposite on the color wheel",
},
{
- 'hue': comp_hue,
- 'saturation': min(100, s + 15),
- 'lightness': max(20, l - 20),
- 'name': 'Rich Complement',
- 'description': 'More saturated and darker for depth'
+ "hue": comp_hue,
+ "saturation": min(100, s + 15),
+ "lightness": max(20, l - 20),
+ "name": "Rich Complement",
+ "description": "More saturated and darker for depth",
},
{
- 'hue': comp_hue,
- 'saturation': max(30, s - 20),
- 'lightness': min(90, l + 20),
- 'name': 'Soft Complement',
- 'description': 'Lighter and less saturated for subtlety'
- }
+ "hue": comp_hue,
+ "saturation": max(30, s - 20),
+ "lightness": min(90, l + 20),
+ "name": "Soft Complement",
+ "description": "Lighter and less saturated for subtlety",
+ },
]
-
+
return {
- 'type': 'Complementary',
- 'angle': '180°',
- 'description': 'Complementary colors sit opposite each other on the color wheel, creating maximum contrast and visual energy.',
- 'use_cases': [
- 'Call-to-action buttons that need to stand out',
- 'Highlighting important UI elements',
- 'Creating vibrant, attention-grabbing designs',
- 'Logos that need strong visual impact'
+ "type": "Complementary",
+ "angle": "180°",
+ "description": "Complementary colors sit opposite each other on the color wheel, creating maximum contrast and visual energy.",
+ "use_cases": [
+ "Call-to-action buttons that need to stand out",
+ "Highlighting important UI elements",
+ "Creating vibrant, attention-grabbing designs",
+ "Logos that need strong visual impact",
],
- 'mood': 'Energetic, bold, dynamic, attention-grabbing',
- 'examples': 'Red & Green, Blue & Orange, Yellow & Purple',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Energetic, bold, dynamic, attention-grabbing",
+ "examples": "Red & Green, Blue & Orange, Yellow & Purple",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_triadic(base_color):
"""
Generate triadic colors (120° apart).
-
+
Creates balanced, vibrant palettes with strong visual interest.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Two triadic partners
for offset in [120, 240]:
tri_hue = normalize_hue(h + offset)
- suggestions.extend([
- {
- 'hue': tri_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': f'Triadic Partner {offset}°',
- 'description': 'Equal spacing for balance'
- },
- {
- 'hue': tri_hue,
- 'saturation': min(100, s + 10),
- 'lightness': l,
- 'name': f'Vibrant Triadic {offset}°',
- 'description': 'Boosted saturation for impact'
- }
- ])
-
+ suggestions.extend(
+ [
+ {
+ "hue": tri_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": f"Triadic Partner {offset}°",
+ "description": "Equal spacing for balance",
+ },
+ {
+ "hue": tri_hue,
+ "saturation": min(100, s + 10),
+ "lightness": l,
+ "name": f"Vibrant Triadic {offset}°",
+ "description": "Boosted saturation for impact",
+ },
+ ]
+ )
+
return {
- 'type': 'Triadic',
- 'angle': '120°',
- 'description': 'Triadic colors are evenly spaced around the color wheel, forming an equilateral triangle. This creates balanced, vibrant palettes.',
- 'use_cases': [
- 'Playful, energetic designs',
- 'Children\'s products and educational materials',
- 'Brand identities that need to feel dynamic',
- 'Infographics and data visualizations'
+ "type": "Triadic",
+ "angle": "120°",
+ "description": "Triadic colors are evenly spaced around the color wheel, forming an equilateral triangle. This creates balanced, vibrant palettes.",
+ "use_cases": [
+ "Playful, energetic designs",
+ "Children's products and educational materials",
+ "Brand identities that need to feel dynamic",
+ "Infographics and data visualizations",
],
- 'mood': 'Balanced, vibrant, playful, harmonious',
- 'examples': 'Red-Yellow-Blue (primary colors), Orange-Green-Purple (secondary colors)',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Balanced, vibrant, playful, harmonious",
+ "examples": "Red-Yellow-Blue (primary colors), Orange-Green-Purple (secondary colors)",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_analogous(base_color):
"""
Generate analogous colors (30-60° adjacent).
-
+
Creates harmonious, cohesive palettes that feel natural.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Neighbors on both sides
for offset in [-60, -30, 30, 60]:
ana_hue = normalize_hue(h + offset)
- suggestions.append({
- 'hue': ana_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': f'Analogous {abs(offset)}° {"Left" if offset < 0 else "Right"}',
- 'description': f'Adjacent color {abs(offset)}° away'
- })
-
+ suggestions.append(
+ {
+ "hue": ana_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": f"Analogous {abs(offset)}° {'Left' if offset < 0 else 'Right'}",
+ "description": f"Adjacent color {abs(offset)}° away",
+ }
+ )
+
return {
- 'type': 'Analogous',
- 'angle': '30-60°',
- 'description': 'Analogous colors sit next to each other on the color wheel, creating harmonious, cohesive palettes that feel natural and pleasing.',
- 'use_cases': [
- 'Backgrounds and gradients',
- 'Nature-inspired designs',
- 'Calming, serene interfaces',
- 'Photography and art portfolios'
+ "type": "Analogous",
+ "angle": "30-60°",
+ "description": "Analogous colors sit next to each other on the color wheel, creating harmonious, cohesive palettes that feel natural and pleasing.",
+ "use_cases": [
+ "Backgrounds and gradients",
+ "Nature-inspired designs",
+ "Calming, serene interfaces",
+ "Photography and art portfolios",
],
- 'mood': 'Harmonious, serene, cohesive, natural',
- 'examples': 'Blue-Blue/Green-Green, Red-Orange-Yellow',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Harmonious, serene, cohesive, natural",
+ "examples": "Blue-Blue/Green-Green, Red-Orange-Yellow",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_split_complementary(base_color):
"""
Generate split-complementary colors.
-
+
Similar to complementary but with two colors flanking the complement.
More nuanced than pure complementary.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
comp_hue = normalize_hue(h + 180)
-
+
suggestions = []
-
+
# Split complement: 150° and 210° (or 180° ± 30°)
for offset in [-30, 30]:
split_hue = normalize_hue(comp_hue + offset)
- suggestions.extend([
- {
- 'hue': split_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': f'Split Complement {"+30°" if offset > 0 else "-30°"}',
- 'description': f'Flanking the complement by {abs(offset)}°'
- },
- {
- 'hue': split_hue,
- 'saturation': max(40, s - 15),
- 'lightness': min(85, l + 15),
- 'name': f'Soft Split {"+30°" if offset > 0 else "-30°"}',
- 'description': 'Muted variation for subtlety'
- }
- ])
-
+ suggestions.extend(
+ [
+ {
+ "hue": split_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": f"Split Complement {'+30°' if offset > 0 else '-30°'}",
+ "description": f"Flanking the complement by {abs(offset)}°",
+ },
+ {
+ "hue": split_hue,
+ "saturation": max(40, s - 15),
+ "lightness": min(85, l + 15),
+ "name": f"Soft Split {'+30°' if offset > 0 else '-30°'}",
+ "description": "Muted variation for subtlety",
+ },
+ ]
+ )
+
return {
- 'type': 'Split-Complementary',
- 'angle': '150° & 210°',
- 'description': 'Split-complementary uses a base color and two colors adjacent to its complement, offering contrast with more nuance than pure complementary.',
- 'use_cases': [
- 'Sophisticated brand palettes',
- 'Web designs needing contrast without harshness',
- 'Editorial layouts and magazines',
- 'Product packaging with visual interest'
+ "type": "Split-Complementary",
+ "angle": "150° & 210°",
+ "description": "Split-complementary uses a base color and two colors adjacent to its complement, offering contrast with more nuance than pure complementary.",
+ "use_cases": [
+ "Sophisticated brand palettes",
+ "Web designs needing contrast without harshness",
+ "Editorial layouts and magazines",
+ "Product packaging with visual interest",
],
- 'mood': 'Sophisticated, balanced, nuanced, refined',
- 'examples': 'Blue with Yellow-Orange and Red-Orange',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Sophisticated, balanced, nuanced, refined",
+ "examples": "Blue with Yellow-Orange and Red-Orange",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_tetradic(base_color):
"""
Generate tetradic/square colors (90° apart).
-
+
Creates rich, complex palettes with four distinct colors.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Three partners at 90°, 180°, 270°
for offset in [90, 180, 270]:
tet_hue = normalize_hue(h + offset)
- suggestions.append({
- 'hue': tet_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': f'Tetradic {offset}°',
- 'description': f'Square harmony partner at {offset}°'
- })
-
+ suggestions.append(
+ {
+ "hue": tet_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": f"Tetradic {offset}°",
+ "description": f"Square harmony partner at {offset}°",
+ }
+ )
+
return {
- 'type': 'Tetradic (Square)',
- 'angle': '90°',
- 'description': 'Tetradic colors form a square on the color wheel, evenly spaced at 90° intervals. This creates rich, complex palettes with maximum variety.',
- 'use_cases': [
- 'Complex brand systems with multiple sub-brands',
- 'Data visualizations with many categories',
- 'Festive, celebratory designs',
- 'Gaming interfaces and entertainment'
+ "type": "Tetradic (Square)",
+ "angle": "90°",
+ "description": "Tetradic colors form a square on the color wheel, evenly spaced at 90° intervals. This creates rich, complex palettes with maximum variety.",
+ "use_cases": [
+ "Complex brand systems with multiple sub-brands",
+ "Data visualizations with many categories",
+ "Festive, celebratory designs",
+ "Gaming interfaces and entertainment",
],
- 'mood': 'Rich, complex, diverse, energetic',
- 'examples': 'Red-Yellow-Green-Blue, Orange-Chartreuse-Cyan-Violet',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Rich, complex, diverse, energetic",
+ "examples": "Red-Yellow-Green-Blue, Orange-Chartreuse-Cyan-Violet",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_rectangular(base_color):
"""
Generate rectangular/compound colors.
-
+
Two complementary pairs that form a rectangle on the wheel.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Rectangular: 60°, 180°, 240° (or other rectangle configurations)
for offset in [60, 180, 240]:
rect_hue = normalize_hue(h + offset)
- suggestions.append({
- 'hue': rect_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': f'Rectangular {offset}°',
- 'description': f'Rectangle harmony at {offset}°'
- })
-
+ suggestions.append(
+ {
+ "hue": rect_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": f"Rectangular {offset}°",
+ "description": f"Rectangle harmony at {offset}°",
+ }
+ )
+
return {
- 'type': 'Rectangular (Compound)',
- 'angle': '60° & 180°',
- 'description': 'Rectangular harmony uses two complementary pairs that form a rectangle on the color wheel, offering rich contrast with balance.',
- 'use_cases': [
- 'Editorial designs with multiple sections',
- 'Dashboard interfaces with distinct zones',
- 'Marketing materials with varied content',
- 'Presentation templates'
+ "type": "Rectangular (Compound)",
+ "angle": "60° & 180°",
+ "description": "Rectangular harmony uses two complementary pairs that form a rectangle on the color wheel, offering rich contrast with balance.",
+ "use_cases": [
+ "Editorial designs with multiple sections",
+ "Dashboard interfaces with distinct zones",
+ "Marketing materials with varied content",
+ "Presentation templates",
],
- 'mood': 'Balanced, sophisticated, varied, professional',
- 'examples': 'Blue-Orange paired with Yellow-Violet',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Balanced, sophisticated, varied, professional",
+ "examples": "Blue-Orange paired with Yellow-Violet",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_monochromatic(base_color):
"""
Generate monochromatic variations (same hue, different S/L).
-
+
Creates cohesive, elegant palettes with subtle variation.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = [
{
- 'hue': h,
- 'saturation': max(10, s - 30),
- 'lightness': min(95, l + 30),
- 'name': 'Lighter Tint',
- 'description': 'Pastel variation for backgrounds'
+ "hue": h,
+ "saturation": max(10, s - 30),
+ "lightness": min(95, l + 30),
+ "name": "Lighter Tint",
+ "description": "Pastel variation for backgrounds",
},
{
- 'hue': h,
- 'saturation': max(5, s - 40),
- 'lightness': min(98, l + 40),
- 'name': 'Very Light Tint',
- 'description': 'Nearly white for subtle accents'
+ "hue": h,
+ "saturation": max(5, s - 40),
+ "lightness": min(98, l + 40),
+ "name": "Very Light Tint",
+ "description": "Nearly white for subtle accents",
},
{
- 'hue': h,
- 'saturation': min(100, s + 20),
- 'lightness': max(15, l - 30),
- 'name': 'Darker Shade',
- 'description': 'Rich, deep variation for text'
+ "hue": h,
+ "saturation": min(100, s + 20),
+ "lightness": max(15, l - 30),
+ "name": "Darker Shade",
+ "description": "Rich, deep variation for text",
},
{
- 'hue': h,
- 'saturation': min(100, s + 10),
- 'lightness': max(10, l - 40),
- 'name': 'Very Dark Shade',
- 'description': 'Nearly black for strong contrast'
+ "hue": h,
+ "saturation": min(100, s + 10),
+ "lightness": max(10, l - 40),
+ "name": "Very Dark Shade",
+ "description": "Nearly black for strong contrast",
},
{
- 'hue': h,
- 'saturation': max(15, s - 25),
- 'lightness': l,
- 'name': 'Desaturated Tone',
- 'description': 'Muted variation for sophistication'
+ "hue": h,
+ "saturation": max(15, s - 25),
+ "lightness": l,
+ "name": "Desaturated Tone",
+ "description": "Muted variation for sophistication",
},
{
- 'hue': h,
- 'saturation': min(100, s + 30),
- 'lightness': l,
- 'name': 'Vibrant Tone',
- 'description': 'Boosted saturation for impact'
- }
+ "hue": h,
+ "saturation": min(100, s + 30),
+ "lightness": l,
+ "name": "Vibrant Tone",
+ "description": "Boosted saturation for impact",
+ },
]
-
+
return {
- 'type': 'Monochromatic',
- 'angle': '0° (same hue)',
- 'description': 'Monochromatic palettes use variations of a single hue with different saturation and lightness levels, creating cohesive, elegant designs.',
- 'use_cases': [
- 'Minimalist, elegant interfaces',
- 'Professional corporate designs',
- 'Photography portfolios',
- 'Luxury brand materials'
+ "type": "Monochromatic",
+ "angle": "0° (same hue)",
+ "description": "Monochromatic palettes use variations of a single hue with different saturation and lightness levels, creating cohesive, elegant designs.",
+ "use_cases": [
+ "Minimalist, elegant interfaces",
+ "Professional corporate designs",
+ "Photography portfolios",
+ "Luxury brand materials",
],
- 'mood': 'Cohesive, elegant, sophisticated, calm',
- 'examples': 'Navy-Blue-Sky Blue-Powder Blue, Forest-Sage-Mint Green',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Cohesive, elegant, sophisticated, calm",
+ "examples": "Navy-Blue-Sky Blue-Powder Blue, Forest-Sage-Mint Green",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_double_complementary(base_color):
"""
Generate double-complementary (two sets of complements).
-
+
Also known as tetradic rectangle.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Pick a second base 30° away, then both complements
second_base = normalize_hue(h + 30)
-
+
for base_hue in [second_base]:
comp_hue = normalize_hue(base_hue + 180)
- suggestions.extend([
- {
- 'hue': base_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': 'Second Base',
- 'description': '30° from original'
- },
- {
- 'hue': comp_hue,
- 'saturation': s,
- 'lightness': l,
- 'name': 'Second Complement',
- 'description': 'Complement of second base'
- }
- ])
-
+ suggestions.extend(
+ [
+ {
+ "hue": base_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": "Second Base",
+ "description": "30° from original",
+ },
+ {
+ "hue": comp_hue,
+ "saturation": s,
+ "lightness": l,
+ "name": "Second Complement",
+ "description": "Complement of second base",
+ },
+ ]
+ )
+
# Original complement
- suggestions.append({
- 'hue': normalize_hue(h + 180),
- 'saturation': s,
- 'lightness': l,
- 'name': 'Original Complement',
- 'description': 'Complement of base color'
- })
-
+ suggestions.append(
+ {
+ "hue": normalize_hue(h + 180),
+ "saturation": s,
+ "lightness": l,
+ "name": "Original Complement",
+ "description": "Complement of base color",
+ }
+ )
+
return {
- 'type': 'Double-Complementary',
- 'angle': 'Two 180° pairs',
- 'description': 'Double-complementary uses two pairs of complementary colors, creating rich, dynamic palettes with strong contrast.',
- 'use_cases': [
- 'Bold, energetic brand identities',
- 'Sports team colors and jerseys',
- 'Festival and event materials',
- 'Attention-grabbing advertisements'
+ "type": "Double-Complementary",
+ "angle": "Two 180° pairs",
+ "description": "Double-complementary uses two pairs of complementary colors, creating rich, dynamic palettes with strong contrast.",
+ "use_cases": [
+ "Bold, energetic brand identities",
+ "Sports team colors and jerseys",
+ "Festival and event materials",
+ "Attention-grabbing advertisements",
],
- 'mood': 'Bold, energetic, dynamic, striking',
- 'examples': 'Red-Green paired with Blue-Orange',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Bold, energetic, dynamic, striking",
+ "examples": "Red-Green paired with Blue-Orange",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def generate_shades_tints(base_color):
"""
Generate pure shades (darker) and tints (lighter).
-
+
Essential for creating depth and hierarchy.
"""
- h, s, l = base_color['hsl']['h'], base_color['hsl']['s'], base_color['hsl']['l']
-
+ h, s, l = base_color["hsl"]["h"], base_color["hsl"]["s"], base_color["hsl"]["l"]
+
suggestions = []
-
+
# Tints (lighter)
for i, lightness_offset in enumerate([15, 30, 45], 1):
- suggestions.append({
- 'hue': h,
- 'saturation': s,
- 'lightness': min(98, l + lightness_offset),
- 'name': f'Tint {i}',
- 'description': f'{lightness_offset}% lighter'
- })
-
+ suggestions.append(
+ {
+ "hue": h,
+ "saturation": s,
+ "lightness": min(98, l + lightness_offset),
+ "name": f"Tint {i}",
+ "description": f"{lightness_offset}% lighter",
+ }
+ )
+
# Shades (darker)
for i, lightness_offset in enumerate([15, 30, 45], 1):
- suggestions.append({
- 'hue': h,
- 'saturation': s,
- 'lightness': max(5, l - lightness_offset),
- 'name': f'Shade {i}',
- 'description': f'{lightness_offset}% darker'
- })
-
+ suggestions.append(
+ {
+ "hue": h,
+ "saturation": s,
+ "lightness": max(5, l - lightness_offset),
+ "name": f"Shade {i}",
+ "description": f"{lightness_offset}% darker",
+ }
+ )
+
return {
- 'type': 'Shades & Tints',
- 'angle': 'Same hue, varied lightness',
- 'description': 'Shades (darker) and tints (lighter) of the same color create depth, hierarchy, and visual interest while maintaining color identity.',
- 'use_cases': [
- 'UI states (hover, active, disabled)',
- 'Text hierarchy (headings, body, captions)',
- 'Shadows and highlights',
- 'Depth and layering in designs'
+ "type": "Shades & Tints",
+ "angle": "Same hue, varied lightness",
+ "description": "Shades (darker) and tints (lighter) of the same color create depth, hierarchy, and visual interest while maintaining color identity.",
+ "use_cases": [
+ "UI states (hover, active, disabled)",
+ "Text hierarchy (headings, body, captions)",
+ "Shadows and highlights",
+ "Depth and layering in designs",
],
- 'mood': 'Structured, hierarchical, organized, clear',
- 'examples': 'Light Blue → Blue → Navy, Pink → Red → Maroon',
- 'suggestions': [convert_hsl_to_color(s) for s in suggestions]
+ "mood": "Structured, hierarchical, organized, clear",
+ "examples": "Light Blue → Blue → Navy, Pink → Red → Maroon",
+ "suggestions": [convert_hsl_to_color(s) for s in suggestions],
}
def convert_hsl_to_color(hsl_obj):
"""Convert HSL suggestion object to full color object."""
- h, s, l = hsl_obj['hue'], hsl_obj['saturation'], hsl_obj['lightness']
+ h, s, l = hsl_obj["hue"], hsl_obj["saturation"], hsl_obj["lightness"]
rgb = hsl_to_rgb([h, s, l])
canonical_hsl = rgb_to_hsl(rgb)
- hex_color = '#{:02x}{:02x}{:02x}'.format(*rgb)
-
+ hex_color = "#{:02x}{:02x}{:02x}".format(*rgb)
+
return {
- 'hex': hex_color,
- 'rgb': {'r': rgb[0], 'g': rgb[1], 'b': rgb[2]},
- 'hsl': {
- 'h': canonical_hsl[0],
- 's': canonical_hsl[1],
- 'l': canonical_hsl[2]
- },
- 'name': hsl_obj['name'],
- 'description': hsl_obj['description']
+ "hex": hex_color,
+ "rgb": {"r": rgb[0], "g": rgb[1], "b": rgb[2]},
+ "hsl": {"h": canonical_hsl[0], "s": canonical_hsl[1], "l": canonical_hsl[2]},
+ "name": hsl_obj["name"],
+ "description": hsl_obj["description"],
}
def generate_all_suggestions(base_color):
"""
Generate all harmony-based suggestions for a base color.
-
+
Returns a comprehensive list of color suggestions organized by harmony type.
"""
return {
- 'base_color': base_color,
- 'harmonies': [
+ "base_color": base_color,
+ "harmonies": [
generate_complementary(base_color),
generate_analogous(base_color),
generate_triadic(base_color),
@@ -480,7 +494,6 @@ def generate_all_suggestions(base_color):
generate_rectangular(base_color),
generate_monochromatic(base_color),
generate_double_complementary(base_color),
- generate_shades_tints(base_color)
- ]
+ generate_shades_tints(base_color),
+ ],
}
-
diff --git a/backend/color_theory.py b/backend/color_theory.py
index ccbeeeb..80ee6b8 100644
--- a/backend/color_theory.py
+++ b/backend/color_theory.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-from itertools import combinations
import math
+from itertools import combinations
from typing import Iterable
import numpy as np
-
MIN_MEANINGFUL_SATURATION = 10
COMPLEMENTARY_TOLERANCE = 12.0
ANALOGOUS_EXPECTED_ANGLE = 30.0
@@ -73,8 +72,7 @@ def _relationship(
expected_angles = [round(value, 2) for value in expected]
measured_angles = [round(value, 2) for value in measured]
deviation = max(
- abs(actual - target)
- for actual, target in zip(measured_angles, expected_angles)
+ abs(actual - target) for actual, target in zip(measured_angles, expected_angles)
)
return {
"type": relationship_type,
@@ -203,11 +201,7 @@ def detect_split_complementary(
relationships = []
seen: set[tuple[int, int, int]] = set()
for base_index, base_hue in evidence:
- others = [
- (index, hue)
- for index, hue in evidence
- if index != base_index
- ]
+ others = [(index, hue) for index, hue in evidence if index != base_index]
for (first_index, first), (second_index, second) in combinations(others, 2):
measured = [
hue_distance(base_hue, first),
@@ -216,8 +210,7 @@ def detect_split_complementary(
]
expected = [150.0, 150.0, 60.0]
deviation = max(
- abs(actual - target)
- for actual, target in zip(measured, expected)
+ abs(actual - target) for actual, target in zip(measured, expected)
)
key = (base_index, *sorted((first_index, second_index)))
if deviation <= tolerance and key not in seen:
@@ -250,18 +243,14 @@ def detect_monochromatic(
if values[1] < MIN_MEANINGFUL_SATURATION or values in seen:
continue
seen.add(values)
- distinct.append(
- (index, {"h": values[0], "s": values[1], "l": values[2]})
- )
+ distinct.append((index, {"h": values[0], "s": values[1], "l": values[2]}))
if len(distinct) < 2:
return []
mean_hue = circular_mean(item["h"] for _, item in distinct)
if mean_hue is None:
return []
- measured = [
- hue_distance(item["h"], mean_hue) for _, item in distinct
- ]
+ measured = [hue_distance(item["h"], mean_hue) for _, item in distinct]
deviation = max(measured)
saturation_range = max(item["s"] for _, item in distinct) - min(
item["s"] for _, item in distinct
@@ -269,10 +258,7 @@ def detect_monochromatic(
lightness_range = max(item["l"] for _, item in distinct) - min(
item["l"] for _, item in distinct
)
- if (
- deviation > tolerance
- or (saturation_range <= 10 and lightness_range <= 10)
- ):
+ if deviation > tolerance or (saturation_range <= 10 and lightness_range <= 10):
return []
return [
_relationship(
@@ -309,13 +295,7 @@ def analyze_warm_cool_balance(colors: list[dict[str, object]]) -> dict[str, obje
}
warm_ratio = warm_count / categorized
cool_ratio = cool_count / categorized
- balance = (
- "warm"
- if warm_ratio > 0.7
- else "cool"
- if cool_ratio > 0.7
- else "balanced"
- )
+ balance = "warm" if warm_ratio > 0.7 else "cool" if cool_ratio > 0.7 else "balanced"
return {
"balance": balance,
"warm_count": warm_count,
@@ -346,11 +326,9 @@ def calculate_relationship_fit(
involved: set[int] = set()
for relationship in detected:
relationship_type = str(relationship["type"])
- if (
- relationship_type not in best_by_type
- or float(relationship["confidence"])
- > float(best_by_type[relationship_type]["confidence"])
- ):
+ if relationship_type not in best_by_type or float(
+ relationship["confidence"]
+ ) > float(best_by_type[relationship_type]["confidence"]):
best_by_type[relationship_type] = relationship
involved.update(int(index) for index in relationship["color_indexes"])
@@ -400,8 +378,8 @@ def analyze_color_theory(colors: list[dict[str, object]]) -> dict[str, object]:
"split_complementary": detect_split_complementary(evidence),
"monochromatic": detect_monochromatic(colors),
}
- relationship_fit, relationship_summary, factors = (
- calculate_relationship_fit(harmonies, len(evidence))
+ relationship_fit, relationship_summary, factors = calculate_relationship_fit(
+ harmonies, len(evidence)
)
temperature_balance = analyze_warm_cool_balance(colors)
@@ -413,9 +391,7 @@ def analyze_color_theory(colors: list[dict[str, object]]) -> dict[str, object]:
"split_complementary": "Split-Complementary Relationship Detected",
"monochromatic": "Monochromatic Relationship Detected",
}
- tags = [
- labels[name] for name, relationships in harmonies.items() if relationships
- ]
+ tags = [labels[name] for name, relationships in harmonies.items() if relationships]
balance = temperature_balance["balance"]
tags.append(
"Neutral Palette"
diff --git a/backend/config.py b/backend/config.py
index 842b94f..da35995 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -2,15 +2,14 @@
from __future__ import annotations
-from dataclasses import dataclass
import ipaddress
import json
import os
+from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from urllib.parse import urlparse
-
ROOT = Path(__file__).resolve().parent.parent
RUNTIME_CONFIG_PATH = ROOT / "runtime-config.json"
@@ -129,12 +128,8 @@ def from_env(
if not isinstance(defaults, dict):
raise ConfigurationError("runtime-config.json defaults must be an object.")
- web_host = env.get(
- "COLORCRAFT_WEB_HOST", str(defaults["webHost"])
- ).strip()
- api_host = env.get(
- "COLORCRAFT_API_HOST", str(defaults["apiHost"])
- ).strip()
+ web_host = env.get("COLORCRAFT_WEB_HOST", str(defaults["webHost"])).strip()
+ api_host = env.get("COLORCRAFT_API_HOST", str(defaults["apiHost"])).strip()
allow_lan_access = parse_boolean(
env.get("COLORCRAFT_ALLOW_LAN_ACCESS"),
name="COLORCRAFT_ALLOW_LAN_ACCESS",
@@ -149,8 +144,7 @@ def from_env(
):
if not is_loopback_host(host):
raise ConfigurationError(
- f"{name}={host} requires "
- "COLORCRAFT_ALLOW_LAN_ACCESS=true."
+ f"{name}={host} requires COLORCRAFT_ALLOW_LAN_ACCESS=true."
)
web_port = parse_port(
diff --git a/backend/main.py b/backend/main.py
index a51109d..a6f73ab 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -5,14 +5,7 @@
from contextlib import asynccontextmanager
from typing import Annotated
-from fastapi import FastAPI, File, Query, Request, UploadFile
-from fastapi.exceptions import RequestValidationError
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse
-from PIL import Image, UnidentifiedImageError
-from starlette.concurrency import run_in_threadpool
import uvicorn
-
from accessibility import analyze_accessibility
from color_extractor import (
ImageDimensionError,
@@ -22,10 +15,15 @@
from color_suggestions import generate_all_suggestions
from color_theory import analyze_color_theory
from config import RuntimeSettings
+from fastapi import FastAPI, File, Query, Request, UploadFile
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
from models import (
- APIError,
AnalysisResponse,
AnalysisResult,
+ APIError,
+ ApplicationMetadata,
ErrorResponse,
ExtractedColor,
ExtractionResponse,
@@ -37,14 +35,18 @@
SuggestionResponse,
ValidationIssue,
)
-
+from PIL import Image, UnidentifiedImageError
+from starlette.concurrency import run_in_threadpool
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
CAPABILITIES = [
- "color extraction",
- "palette analysis",
- "color suggestions",
+ "image-color-extraction",
+ "palette-editing",
+ "harmony-analysis",
+ "contrast-review",
+ "palette-export",
+ "local-palette-library",
]
ColorCount = Annotated[int, Query(ge=3, le=10)]
@@ -64,9 +66,7 @@ def error_response(
message: str,
details: list[ValidationIssue] | None = None,
) -> JSONResponse:
- payload = ErrorResponse(
- error=APIError(code=code, message=message, details=details)
- )
+ payload = ErrorResponse(error=APIError(code=code, message=message, details=details))
return JSONResponse(
status_code=status_code,
content=payload.model_dump(by_alias=True, exclude_none=True),
@@ -154,6 +154,22 @@ async def health() -> ServiceResponse:
version=runtime.application_version,
)
+ @application.get("/metadata", response_model=ApplicationMetadata)
+ async def metadata() -> ApplicationMetadata:
+ return ApplicationMetadata(
+ schema_version=1,
+ id="colorcraft",
+ name="ColorCraft",
+ descriptor="Local color utility",
+ version=runtime.application_version,
+ icon=f"{runtime.web_url}/colorcraft-mark.svg",
+ web_url=runtime.web_url,
+ api_url=runtime.client_api_url,
+ health_url=f"{runtime.client_api_url}/health",
+ readiness_url=f"{runtime.client_api_url}/ready",
+ capabilities=CAPABILITIES,
+ )
+
@application.get("/ready", response_model=ReadinessResponse)
async def ready(request: Request) -> ReadinessResponse | JSONResponse:
is_ready = bool(request.app.state.ready)
@@ -200,9 +216,7 @@ async def extract_uploaded_colors(
extracted = await run_in_threadpool(
extract_colors, image_bytes, color_count
)
- return [
- ExtractedColor.model_validate(color) for color in extracted
- ]
+ return [ExtractedColor.model_validate(color) for color in extracted]
except ImageDimensionError:
raise APIException(
413,
@@ -249,9 +263,7 @@ async def extract_colors_endpoint(
)
def analyze_palette(request: PaletteAnalysisRequest) -> AnalysisResult:
- colors = [
- color.model_dump(by_alias=False) for color in request.colors
- ]
+ colors = [color.model_dump(by_alias=False) for color in request.colors]
return AnalysisResult(
color_theory=analyze_color_theory(colors),
accessibility=analyze_accessibility(colors),
@@ -278,14 +290,10 @@ async def analyze_colors_endpoint(
async def suggest_colors_endpoint(
request: SuggestionRequest,
) -> SuggestionResponse:
- colors = [
- color.model_dump(by_alias=False) for color in request.colors
- ]
+ colors = [color.model_dump(by_alias=False) for color in request.colors]
return SuggestionResponse(
success=True,
- suggestions=[
- generate_all_suggestions(color) for color in colors
- ],
+ suggestions=[generate_all_suggestions(color) for color in colors],
)
@application.post(
diff --git a/backend/models.py b/backend/models.py
index 87611f6..cc46373 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -28,6 +28,8 @@ class ContractModel(BaseModel):
HexColor = Annotated[str, StringConstraints(pattern=r"^#[0-9A-Fa-f]{6}$")]
+
+
class RGB(ContractModel):
r: int = Field(ge=0, le=255)
g: int = Field(ge=0, le=255)
@@ -68,9 +70,7 @@ def representations_must_agree(self) -> "ColorInput":
**dict(
zip(
("h", "s", "l"),
- rgb_to_hsl(
- [expected_rgb.r, expected_rgb.g, expected_rgb.b]
- ),
+ rgb_to_hsl([expected_rgb.r, expected_rgb.g, expected_rgb.b]),
)
)
)
@@ -105,6 +105,20 @@ class ReadinessResponse(ContractModel):
capabilities: list[str]
+class ApplicationMetadata(ContractModel):
+ schema_version: Literal[1]
+ id: Literal["colorcraft"]
+ name: Literal["ColorCraft"]
+ descriptor: Literal["Local color utility"]
+ version: str
+ icon: str
+ web_url: str
+ api_url: str
+ health_url: str
+ readiness_url: str
+ capabilities: list[str]
+
+
class ExtractionResponse(ContractModel):
success: Literal[True]
colors: list[ExtractedColor] = Field(min_length=1, max_length=10)
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index 871467b..3b0f6b4 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -1,3 +1,6 @@
-r requirements.txt
httpx==0.25.2
pytest==8.3.5
+pytest-cov==6.2.1
+ruff==0.12.5
+mypy==1.17.0
diff --git a/check.cmd b/check.cmd
new file mode 100644
index 0000000..19b1325
--- /dev/null
+++ b/check.cmd
@@ -0,0 +1,17 @@
+@echo off
+setlocal
+set "COLORCRAFT_ROOT=%~dp0"
+
+if exist "%COLORCRAFT_ROOT%backend\.venv311\Scripts\python.exe" (
+ "%COLORCRAFT_ROOT%backend\.venv311\Scripts\python.exe" "%COLORCRAFT_ROOT%check.py" %*
+ exit /b %errorlevel%
+)
+
+if exist "%COLORCRAFT_ROOT%backend\.venv\Scripts\python.exe" (
+ "%COLORCRAFT_ROOT%backend\.venv\Scripts\python.exe" "%COLORCRAFT_ROOT%check.py" %*
+ exit /b %errorlevel%
+)
+
+echo ColorCraft could not find a backend development environment.
+echo Create one and install backend\requirements-dev.txt, then try again.
+exit /b 1
diff --git a/check.py b/check.py
new file mode 100644
index 0000000..838f37b
--- /dev/null
+++ b/check.py
@@ -0,0 +1,118 @@
+"""Run ColorCraft's complete repository validation workflow."""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+FRONTEND = ROOT / "frontend"
+BACKEND = ROOT / "backend"
+
+
+@dataclass(frozen=True)
+class CheckStep:
+ label: str
+ command: tuple[str, ...]
+ cwd: Path
+
+
+def validation_steps(
+ python: str,
+ corepack: str,
+ *,
+ include_e2e: bool = True,
+) -> tuple[CheckStep, ...]:
+ pnpm = (corepack, "pnpm@9.15.9")
+ steps = [
+ CheckStep("frontend format", (*pnpm, "format:check"), FRONTEND),
+ CheckStep("frontend lint", (*pnpm, "lint"), FRONTEND),
+ CheckStep("frontend typecheck", (*pnpm, "typecheck"), FRONTEND),
+ CheckStep("frontend coverage", (*pnpm, "test:coverage"), FRONTEND),
+ CheckStep("frontend build", (*pnpm, "build"), FRONTEND),
+ CheckStep(
+ "backend format",
+ (
+ python,
+ "-m",
+ "ruff",
+ "format",
+ "--check",
+ "backend",
+ "tests",
+ "dev.py",
+ "check.py",
+ ),
+ ROOT,
+ ),
+ CheckStep(
+ "backend lint",
+ (python, "-m", "ruff", "check", "backend", "tests", "dev.py", "check.py"),
+ ROOT,
+ ),
+ CheckStep(
+ "backend typecheck",
+ (
+ python,
+ "-m",
+ "mypy",
+ "backend/config.py",
+ "backend/models.py",
+ "check.py",
+ ),
+ ROOT,
+ ),
+ CheckStep(
+ "backend tests and coverage",
+ (
+ python,
+ "-m",
+ "pytest",
+ "tests",
+ "--cov=backend",
+ "--cov=dev",
+ "--cov-report=term-missing",
+ "--cov-report=xml:.tmp/backend-coverage.xml",
+ ),
+ ROOT,
+ ),
+ ]
+ if include_e2e:
+ steps.append(
+ CheckStep("browser E2E and accessibility", (*pnpm, "test:e2e"), FRONTEND)
+ )
+ return tuple(steps)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--skip-e2e",
+ action="store_true",
+ help="Run the fast validation layers without Playwright.",
+ )
+ arguments = parser.parse_args()
+ corepack = shutil.which("corepack.cmd") or shutil.which("corepack")
+ if not corepack:
+ print("ColorCraft validation requires Corepack.", file=sys.stderr)
+ return 1
+
+ for index, step in enumerate(
+ validation_steps(sys.executable, corepack, include_e2e=not arguments.skip_e2e),
+ start=1,
+ ):
+ print(f"[{index}] {step.label}")
+ result = subprocess.run(step.command, cwd=step.cwd, check=False)
+ if result.returncode:
+ print(f"Validation stopped: {step.label} failed.", file=sys.stderr)
+ return result.returncode
+ print("ColorCraft validation passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev.py b/dev.py
index 6b2eb94..b8c397b 100644
--- a/dev.py
+++ b/dev.py
@@ -2,21 +2,20 @@
from __future__ import annotations
-from dataclasses import dataclass
import json
import os
-from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import time
+from dataclasses import dataclass
+from pathlib import Path
from typing import Callable, Sequence
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
-
ROOT = Path(__file__).resolve().parent
BACKEND_DIR = ROOT / "backend"
FRONTEND_DIR = ROOT / "frontend"
@@ -228,6 +227,10 @@ def readiness_url(settings: RuntimeSettings) -> str:
return f"{settings.client_api_url}/ready"
+def metadata_url(settings: RuntimeSettings) -> str:
+ return f"{settings.client_api_url}/metadata"
+
+
def wait_for_readiness(
settings: RuntimeSettings,
processes: Sequence[tuple[ProcessStep, subprocess.Popen[bytes]]],
@@ -248,7 +251,13 @@ def wait_for_readiness(
if response.status == 200 and payload.get("status") == "ready":
return
last_error = f"readiness returned status {response.status}"
- except (HTTPError, URLError, OSError, TimeoutError, json.JSONDecodeError) as error:
+ except (
+ HTTPError,
+ URLError,
+ OSError,
+ TimeoutError,
+ json.JSONDecodeError,
+ ) as error:
last_error = str(error)
sleep(0.2)
@@ -295,6 +304,7 @@ def run_dev_launcher(
print(f" API: {runtime.client_api_url}")
print(f" Health: {runtime.client_api_url}/health")
print(f" Readiness: {readiness_url(runtime)}")
+ print(f" Metadata: {metadata_url(runtime)}")
print("Waiting for API readiness...")
for step in plan.steps:
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..e85fa55
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,15 @@
+# ColorCraft documentation
+
+Start with [Getting started](./getting-started.md), then use the guide that matches your task:
+
+- [User workflow](./user-workflow.md) — create, save, review, export, and manage palettes.
+- [Runtime configuration](./runtime-configuration.md) — ports, hosts, CORS, LAN opt-in, and launcher behavior.
+- [Architecture](./architecture.md) — application boundaries and data flow.
+- [API](./api.md) — endpoints, request shapes, metadata, readiness, and errors.
+- [Persistence and privacy](./persistence-and-privacy.md) — IndexedDB schema, migration, retention, and deletion.
+- [Dashboard manifest](./dashboard-manifest.md) — static discovery defaults and runtime resolution.
+- [Testing](./testing.md) — local and CI quality gates.
+- [Screenshot review](./screenshot-review.md) — deterministic fixtures, temporary output, and curation.
+- [Troubleshooting](./troubleshooting.md) — common Python, pnpm, port, and browser failures.
+- [Brand system](./brand-system.md) — identity, tokens, themes, and shell conventions.
+- [API contract rationale](./api-contracts.md) — compatibility and validation design notes.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..5977f71
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,49 @@
+# API
+
+The default base URL is `http://127.0.0.1:4100`. Interactive OpenAPI documentation is available at `/docs`.
+
+| Method | Path | Purpose |
+| --- | --- | --- |
+| `GET` | `/` | Service identity |
+| `GET` | `/health` | Liveness |
+| `GET` | `/ready` | Readiness and capability slugs |
+| `GET` | `/metadata` | Runtime-resolved dashboard metadata |
+| `POST` | `/api/extract-colors?n_colors=5` | Multipart image extraction |
+| `POST` | `/api/analyze-colors` | Analyze 2–10 normalized colors |
+| `POST` | `/api/suggest-colors` | Suggest colors for 1–10 normalized colors |
+| `POST` | `/api/full-analysis?n_colors=5` | Extract and analyze in one request |
+
+Analysis and suggestion requests wrap colors in an object:
+
+```json
+{
+ "colors": [
+ {
+ "hex": "#6A5BCF",
+ "rgb": { "r": 106, "g": 91, "b": 207 },
+ "hsl": { "h": 248, "s": 53, "l": 58 }
+ },
+ {
+ "hex": "#F5F0E8",
+ "rgb": { "r": 245, "g": 240, "b": 232 },
+ "hsl": { "h": 37, "s": 39, "l": 94 }
+ }
+ ]
+}
+```
+
+Passing a bare array produces `422 validation_error`. Error responses are stable envelopes:
+
+```json
+{
+ "error": {
+ "code": "validation_error",
+ "message": "Request validation failed.",
+ "details": []
+ }
+}
+```
+
+Extraction accepts JPEG, PNG, and WebP files up to 10 MB, limits decoded dimensions, and ignores fully transparent pixels. `n_colors` must be 3–10. Expected client errors use 413, 415, or 422 rather than an unstructured server exception.
+
+`/metadata` reports the resolved web, API, health, readiness, icon, version, and capabilities. `/ready` returns `503` with `not_ready` until application startup completes. See [Dashboard manifest](./dashboard-manifest.md).
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..bce7456
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,26 @@
+# Architecture
+
+```mermaid
+flowchart TD
+ Browser[React + TypeScript browser app]
+ IDB[(IndexedDB palette records)]
+ LS[(LocalStorage theme preference)]
+ API[FastAPI service]
+ Extract[LAB extraction]
+ Theory[Harmony and suggestions]
+ Contrast[WCAG contrast]
+ Browser <--> IDB
+ Browser <--> LS
+ Browser -->|JSON and multipart HTTP| API
+ API --> Extract
+ API --> Theory
+ API --> Contrast
+```
+
+The frontend owns navigation, transient image preview state, explicit save state, role assignments, exports, and the local palette repository. `persistence.ts` validates every stored record with Zod, migrates legacy version-0 records to schema version 1, and ignores malformed or unknown future records without crashing the Library.
+
+The backend is stateless. It validates contract models with Pydantic and delegates CPU-heavy extraction to a thread pool. Color extraction uses deterministic sampling, LAB conversion, KMeans clustering, and representative processed pixels. Theory, suggestions, and contrast operate on normalized color contracts.
+
+`dev.py` is a process supervisor rather than an application server. It resolves configuration once, starts the API and Vite with consistent values, checks `/ready`, optionally opens the browser, and forwards shutdown to both processes.
+
+Dashboard integration has two layers: the checked-in static manifest advertises stable defaults, while `/metadata` reports the runtime-resolved addresses. See [API](./api.md) and [Persistence and privacy](./persistence-and-privacy.md).
diff --git a/docs/assets/screenshots/create-light.png b/docs/assets/screenshots/create-light.png
new file mode 100644
index 0000000..32c0321
Binary files /dev/null and b/docs/assets/screenshots/create-light.png differ
diff --git a/docs/assets/screenshots/library-dark.png b/docs/assets/screenshots/library-dark.png
new file mode 100644
index 0000000..3aa4e1d
Binary files /dev/null and b/docs/assets/screenshots/library-dark.png differ
diff --git a/docs/assets/screenshots/mobile-create.png b/docs/assets/screenshots/mobile-create.png
new file mode 100644
index 0000000..307e052
Binary files /dev/null and b/docs/assets/screenshots/mobile-create.png differ
diff --git a/docs/assets/screenshots/review-dark.png b/docs/assets/screenshots/review-dark.png
new file mode 100644
index 0000000..1dea84a
Binary files /dev/null and b/docs/assets/screenshots/review-dark.png differ
diff --git a/docs/brand-system.md b/docs/brand-system.md
index 684bdfc..734cfb5 100644
--- a/docs/brand-system.md
+++ b/docs/brand-system.md
@@ -8,10 +8,10 @@ compact information density, restrained radii, semantic controls, and a narrow
iris-to-ember accent. ColorCraft remains its own product. It does not reuse the
Web Video Optimizer monogram, video metaphors, or video-specific navigation.
-The product uses the family application-shell grammar for three real workflow
-stages: Create, Review, and Export. It remains a focused local utility rather
-than a dashboard. Library, recent-palette, account, and persistence surfaces
-must not appear until the corresponding product capabilities exist.
+The product uses the family application-shell grammar for four real workflow
+destinations: Create, Review, Export, and Library. Library and recent-palette
+surfaces represent browser-local IndexedDB records. ColorCraft remains a focused
+local utility rather than a dashboard and does not imply accounts or cloud sync.
## Voice
diff --git a/docs/dashboard-manifest.md b/docs/dashboard-manifest.md
new file mode 100644
index 0000000..9452634
--- /dev/null
+++ b/docs/dashboard-manifest.md
@@ -0,0 +1,23 @@
+# Dashboard manifest
+
+[`app-manifest.json`](../app-manifest.json) is ColorCraft's static discovery contract. The identical file under `frontend/public` makes it available in the built web application.
+
+The manifest contains schema version 1, a stable `colorcraft` ID, product name and descriptor, version, icon path, default web/API addresses, health and metadata paths, and machine-readable capability slugs.
+
+Static addresses are defaults only. Environment variables can change ports or hosts at launch, so a dashboard should:
+
+1. Discover the application from `app-manifest.json`.
+2. Query the manifest's metadata path on the candidate API address.
+3. Treat `/metadata` as authoritative for runtime-resolved URLs.
+4. Use `/ready` for dependency readiness and `/health` for liveness.
+
+Current capability slugs are:
+
+- `image-color-extraction`
+- `palette-editing`
+- `harmony-analysis`
+- `contrast-review`
+- `palette-export`
+- `local-palette-library`
+
+The manifest contract and public copy are tested for equality. Runtime tests verify that metadata respects configuration overrides.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..b4b106d
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,49 @@
+# Getting started
+
+## Requirements
+
+- Python 3.11 (recommended; pinned Pillow wheels are not available for every newer Python release)
+- Node.js 20 or newer
+- Corepack
+- Git
+
+## Windows
+
+From the repository root:
+
+```powershell
+py -3.11 -m venv backend\.venv311
+.\backend\.venv311\Scripts\python.exe -m pip install --upgrade pip
+.\backend\.venv311\Scripts\python.exe -m pip install -r backend\requirements-dev.txt
+cd frontend
+corepack pnpm@9.15.9 install
+cd ..
+.\backend\.venv311\Scripts\python.exe dev.py
+```
+
+The launcher supervises the FastAPI and Vite processes, waits for readiness, prints every URL, and shuts both down when you press `Ctrl+C`.
+
+## macOS and Linux
+
+```bash
+python3.11 -m venv backend/.venv
+backend/.venv/bin/python -m pip install --upgrade pip
+backend/.venv/bin/python -m pip install -r backend/requirements-dev.txt
+cd frontend
+corepack pnpm@9.15.9 install
+cd ..
+backend/.venv/bin/python dev.py
+```
+
+If the browser does not open, visit `http://127.0.0.1:5174`. The API docs are at `http://127.0.0.1:4100/docs`.
+
+## Production build
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 build
+```
+
+The output is written to `frontend/dist`. This repository's launcher is a development workflow; choose a production web server and API process manager for deployment.
+
+For configuration overrides, see [Runtime configuration](./runtime-configuration.md). For setup errors, see [Troubleshooting](./troubleshooting.md).
diff --git a/docs/persistence-and-privacy.md b/docs/persistence-and-privacy.md
new file mode 100644
index 0000000..741a8c8
--- /dev/null
+++ b/docs/persistence-and-privacy.md
@@ -0,0 +1,30 @@
+# Persistence and privacy
+
+ColorCraft is local-first, not account-backed. There is no synchronization service or server-side palette database.
+
+## What is stored
+
+The `colorcraft` IndexedDB database contains `palettes` records with:
+
+- schema version, ID, name, and created/updated timestamps
+- manual or image source type and an optional source filename
+- 1–10 normalized colors with optional population and pixel-count metadata
+- semantic contrast-role assignments
+
+Schema version 1 is validated with Zod whenever records are read. Legacy records with no version or version 0 are migrated in memory and become version 1 when next saved. Corrupt records and unknown future versions are skipped so one bad record cannot prevent the Library from opening.
+
+LocalStorage contains only the theme preference. Uploaded image bytes, object URLs, analysis responses, and suggestions are not written to LocalStorage or IndexedDB.
+
+## Image processing
+
+An uploaded file is previewed in browser memory and sent to the configured FastAPI service. The service reads it for that request and does not write it to disk or a database. If LAN access is enabled, image traffic crosses that network path; use a trusted network or HTTPS termination.
+
+## Retention and deletion
+
+Palette records remain until the user deletes them or clears site data for the ColorCraft origin. Library deletion is confirmed and cannot be undone. Different browser profiles, origins, and port combinations have separate browser storage.
+
+To remove all ColorCraft browser data, use the browser's site-data controls for the web origin. Uninstalling the repository does not necessarily clear browser data.
+
+## Security posture
+
+Defaults bind both services to loopback. Non-loopback hosts and CORS origins require explicit `COLORCRAFT_ALLOW_LAN_ACCESS=true`; wildcard CORS is rejected. ColorCraft does not provide authentication, so do not expose it directly to an untrusted network.
diff --git a/docs/runtime-configuration.md b/docs/runtime-configuration.md
new file mode 100644
index 0000000..e1a4330
--- /dev/null
+++ b/docs/runtime-configuration.md
@@ -0,0 +1,30 @@
+# Runtime configuration
+
+ColorCraft defaults to loopback-only services:
+
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `COLORCRAFT_WEB_HOST` | `127.0.0.1` | Vite bind host |
+| `COLORCRAFT_WEB_PORT` | `5174` | Web port |
+| `COLORCRAFT_API_HOST` | `127.0.0.1` | FastAPI bind host |
+| `COLORCRAFT_API_PORT` | `4100` | API port |
+| `COLORCRAFT_READINESS_TIMEOUT_SECONDS` | `30` | Launcher startup deadline |
+| `COLORCRAFT_ALLOWED_ORIGINS` | resolved web origin | Comma-separated exact CORS origins |
+| `VITE_API_URL` | resolved API origin | Browser-visible API base URL |
+| `COLORCRAFT_ALLOW_LAN_ACCESS` | `false` | Required opt-in for non-loopback hosts/origins |
+| `COLORCRAFT_NO_BROWSER` | `false` | Prevent automatic browser launch |
+
+Example PowerShell override:
+
+```powershell
+$env:COLORCRAFT_WEB_PORT = "5184"
+$env:COLORCRAFT_API_PORT = "4110"
+$env:COLORCRAFT_NO_BROWSER = "true"
+.\backend\.venv311\Scripts\python.exe dev.py
+```
+
+Ports must be valid and distinct. The launcher checks availability before starting either child process and reports a focused error if a port is occupied. Wildcard CORS origins are rejected.
+
+LAN binding expands the trust boundary. Set `COLORCRAFT_ALLOW_LAN_ACCESS=true`, configure explicit hosts and origins, and use a firewall appropriate to the network. Uploaded images traverse the configured network path to the API.
+
+Static dashboard defaults stay in [`app-manifest.json`](../app-manifest.json). Consumers that need overrides must read `/metadata`; see [Dashboard manifest](./dashboard-manifest.md).
diff --git a/docs/screenshot-review.md b/docs/screenshot-review.md
new file mode 100644
index 0000000..6be5f32
--- /dev/null
+++ b/docs/screenshot-review.md
@@ -0,0 +1,16 @@
+# Screenshot review
+
+Run the deterministic review workflow:
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 review:screenshots
+```
+
+It creates 16 fixture-driven PNGs under `.tmp/ui-review`: empty Create in both themes, extraction progress, image and manual palettes, selected and modified states, four Review surfaces, Export, Library, two mobile surfaces, and an API error. Network-dependent extraction is mocked; analysis uses fixed palette data.
+
+`.tmp` is intentionally ignored. Review those files locally for clipping, hierarchy, contrast, focus, error placement, and responsive behavior. Temporary test artifacts must not be linked from documentation.
+
+When a screenshot is representative and stable, copy only that file to `docs/assets/screenshots`, give it a semantic name, and reference it from documentation. Curated images are reviewable product documentation; the complete temporary set is disposable diagnostic output.
+
+The current curated set shows light Create, dark Review, dark Library, and mobile Create. Regenerate it only when a deliberate interface change makes the existing image inaccurate.
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 0000000..66567b9
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,46 @@
+# Testing
+
+## Complete validation
+
+Install the browser once:
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 exec playwright install chromium
+cd ..
+```
+
+Then run the same gate used by CI:
+
+```powershell
+.\backend\.venv311\Scripts\python.exe check.py
+```
+
+On Unix, use the Python executable from `backend/.venv`. `check.cmd` is a Windows convenience wrapper.
+
+The gate runs:
+
+1. Prettier format verification
+2. ESLint
+3. TypeScript checking
+4. Vitest unit/integration tests with V8 coverage
+5. Vite production build
+6. Ruff format and lint checks
+7. mypy on stable backend contracts and the validation runner
+8. pytest with backend and launcher coverage
+9. Playwright Chromium workflow and axe accessibility checks
+
+Use `python check.py --skip-e2e` for a fast local loop. CI always runs the complete gate.
+
+## Focused commands
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 test
+corepack pnpm@9.15.9 test:e2e
+corepack pnpm@9.15.9 review:screenshots
+cd ..
+.\backend\.venv311\Scripts\python.exe -m pytest tests
+```
+
+Persistence tests use an in-memory IndexedDB implementation and cover migration, malformed data, identity-preserving save updates, sorting, rename, duplicate, and delete. Browser tests cover create → save → analyze → contrast → export → reopen → delete plus representative accessibility states. Backend tests cover contracts, upload defenses, analysis, suggestions, metadata, readiness, runtime parsing, and launcher supervision.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..2b990e4
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,46 @@
+# Troubleshooting
+
+## Pillow fails to install on Python 3.13
+
+The pinned Pillow version may not provide a compatible wheel. Install Python 3.11 and create the environment explicitly:
+
+```powershell
+py -3.11 -m venv backend\.venv311
+.\backend\.venv311\Scripts\python.exe -m pip install -r backend\requirements-dev.txt
+```
+
+If virtual-environment creation reports that `venvlauncher.exe` cannot be copied, close processes using that environment, choose a new directory name, and verify the Python installation outside the Microsoft Store alias.
+
+## pnpm reports “packages field missing or empty”
+
+Run pnpm inside `frontend`, not the repository root:
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 install
+```
+
+The repository pins pnpm 9 for reproducibility. If pnpm blocks an `esbuild` lifecycle script, use the pinned version and the checked-in lockfile rather than approving an unrelated global pnpm policy.
+
+## A port is already in use
+
+ColorCraft defaults to 5174/4100. Stop the conflicting process or set `COLORCRAFT_WEB_PORT` and `COLORCRAFT_API_PORT`; see [Runtime configuration](./runtime-configuration.md). The launcher prints which address is occupied.
+
+## The frontend cannot reach the API
+
+Check `/ready`, confirm the printed API URL, and ensure `VITE_API_URL` matches the browser-reachable address. Custom web origins must appear exactly in `COLORCRAFT_ALLOWED_ORIGINS`.
+
+## Saved palettes disappeared
+
+Palettes are scoped to the exact browser profile and origin. Changing the web port, using private browsing, or clearing site data creates an empty Library. ColorCraft has no cloud recovery.
+
+## Playwright cannot find Chromium
+
+```powershell
+cd frontend
+corepack pnpm@9.15.9 exec playwright install chromium
+```
+
+## A 422 response appears for analysis or suggestions
+
+Send `{ "colors": [...] }`, not a bare array, and include consistent HEX, RGB, and HSL values. Read the structured `error.details` entries or use `/docs`.
diff --git a/docs/user-workflow.md b/docs/user-workflow.md
new file mode 100644
index 0000000..54a197e
--- /dev/null
+++ b/docs/user-workflow.md
@@ -0,0 +1,36 @@
+# User workflow
+
+## Create and refine
+
+Choose **Upload image** for PNG, JPEG, or WebP files up to 10 MB, or choose **Start manually**. Extraction returns 3–10 representative colors. Select a palette row, type a six-digit HEX value, use the native picker, duplicate a color, or remove it.
+
+Uploaded image previews exist only for the active page session. Saving an image-derived palette records its filename, colors, population data, and pixel counts—not the image bytes.
+
+## Save states
+
+- **Unsaved**: the open palette has no IndexedDB record. **Save palette** creates one.
+- **Saved**: the current palette matches its record. The save action is disabled.
+- **Modified**: colors, roles, name, or source metadata differ. **Save changes** updates the same record.
+
+ColorCraft confirms before opening another record or starting over when doing so would discard meaningful unsaved or modified work.
+
+## Review
+
+Choose **Analyze palette** to enter Review:
+
+- **Overview** summarizes temperature, saturation, lightness, geometric relationships, and next actions.
+- **Harmony** explains detected relationships on the color wheel.
+- **Contrast** assigns semantic interface roles and reports WCAG ratios.
+- **Suggestions** proposes complementary, triadic, analogous, split-complementary, tetradic, monochromatic, or accessibility-oriented colors.
+
+Relationship fit measures geometric hue structure; it is not a judgment of aesthetic quality.
+
+## Export
+
+Export the current palette as CSS custom properties, JSON, Tailwind configuration, or design tokens. Copy uses the Clipboard API; download creates a local file. Export does not upload the palette.
+
+## Library
+
+The Library lists browser-local records most recently updated first. Search by palette name or source filename. Open, inline rename, duplicate, or delete a record. Deletion always asks for confirmation; deleting the active record returns to the empty Library state and disables Review and Export until another palette opens.
+
+The sidebar shows recent palettes only when records exist. Clearing browser site data permanently removes the Library.
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000..1ece1a9
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,6 @@
+coverage
+dist
+node_modules
+playwright-report
+test-results
+pnpm-lock.yaml
diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json
new file mode 100644
index 0000000..e3b414c
--- /dev/null
+++ b/frontend/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/frontend/e2e/capture-screenshots.mjs b/frontend/e2e/capture-screenshots.mjs
new file mode 100644
index 0000000..6b6b742
--- /dev/null
+++ b/frontend/e2e/capture-screenshots.mjs
@@ -0,0 +1,23 @@
+import { rm, mkdir } from 'node:fs/promises'
+import path from 'node:path'
+import { spawn } from 'node:child_process'
+
+const frontendRoot = path.resolve(import.meta.dirname, '..')
+const outputDirectory = path.resolve(frontendRoot, '..', '.tmp', 'ui-review')
+await rm(outputDirectory, { recursive: true, force: true })
+await mkdir(outputDirectory, { recursive: true })
+
+const isWindows = process.platform === 'win32'
+const executable = isWindows
+ ? 'corepack pnpm@9.15.9 exec playwright test --grep @screenshots'
+ : 'corepack'
+const arguments_ = isWindows
+ ? []
+ : ['pnpm@9.15.9', 'exec', 'playwright', 'test', '--grep', '@screenshots']
+const child = spawn(executable, arguments_, {
+ cwd: frontendRoot,
+ stdio: 'inherit',
+ env: { ...process.env, COLORCRAFT_SCREENSHOTS: '1' },
+ shell: isWindows,
+})
+child.on('exit', (code) => process.exit(code ?? 1))
diff --git a/frontend/e2e/screenshot-review.spec.ts b/frontend/e2e/screenshot-review.spec.ts
new file mode 100644
index 0000000..417bfc8
--- /dev/null
+++ b/frontend/e2e/screenshot-review.spec.ts
@@ -0,0 +1,179 @@
+import { expect, test, type Page } from '@playwright/test'
+import path from 'node:path'
+
+test.describe.configure({ mode: 'serial' })
+
+const outputDirectory = path.resolve(
+ import.meta.dirname,
+ '..',
+ '..',
+ '.tmp',
+ 'ui-review',
+)
+const png = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAARElEQVR4nGP8z8AARLJgFIyCUUBPAAA2QQE/7iZ8GAAAAABJRU5ErkJggg==',
+ 'base64',
+)
+
+async function capture(page: Page, name: string) {
+ await page.screenshot({
+ path: path.join(outputDirectory, `${name}.png`),
+ fullPage: true,
+ animations: 'disabled',
+ })
+}
+
+async function setPalette(page: Page) {
+ await page.getByRole('button', { name: 'Start manually' }).click()
+ for (let index = 0; index < 4; index += 1) {
+ await page.getByRole('button', { name: 'Add color' }).click()
+ }
+ const colors = ['#6A5BCF', '#F2763F', '#141D29', '#F5F0E8', '#41A37A']
+ for (let index = 0; index < colors.length; index += 1) {
+ const input = page.getByRole('textbox', {
+ name: `Color ${index + 1}`,
+ exact: true,
+ })
+ await input.fill(colors[index])
+ await input.blur()
+ }
+}
+
+test('@screenshots fixture-driven UI review', async ({ page }) => {
+ test.skip(
+ !process.env.COLORCRAFT_SCREENSHOTS,
+ 'Run through pnpm review:screenshots.',
+ )
+ await page.goto('/?view=create')
+ await page.locator('.sidebar-theme select').selectOption('dark')
+ await capture(page, '01-create-empty-dark')
+ await page.locator('.sidebar-theme select').selectOption('light')
+ await capture(page, '02-create-empty-light')
+
+ await page.route('**/api/extract-colors?*', async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 700))
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ count: 3,
+ colors: [
+ {
+ hex: '#6A5BCF',
+ rgb: { r: 106, g: 91, b: 207 },
+ hsl: { h: 248, s: 53, l: 58 },
+ population: 0.5,
+ pixelCount: 50,
+ },
+ {
+ hex: '#F2763F',
+ rgb: { r: 242, g: 118, b: 63 },
+ hsl: { h: 18, s: 87, l: 60 },
+ population: 0.3,
+ pixelCount: 30,
+ },
+ {
+ hex: '#141D29',
+ rgb: { r: 20, g: 29, b: 41 },
+ hsl: { h: 214, s: 34, l: 12 },
+ population: 0.2,
+ pixelCount: 20,
+ },
+ ],
+ }),
+ })
+ })
+ await page.locator('#source-image').setInputFiles({
+ name: 'fixture-palette.png',
+ mimeType: 'image/png',
+ buffer: png,
+ })
+ await expect(page.getByRole('button', { name: /^Extracting/ })).toBeVisible()
+ await capture(page, '03-extraction-progress')
+ await expect(page.getByText(/3 distinct colors were/)).toBeVisible()
+ await capture(page, '04-image-palette-workspace')
+ await page.locator('[aria-label="Palette color 2"]').click()
+ await capture(page, '05-selected-palette-color')
+
+ await page.getByRole('button', { name: 'New palette' }).click()
+ await page.getByRole('button', { name: 'Discard and start new' }).click()
+ await setPalette(page)
+ await capture(page, '06-manual-palette-workspace')
+ await page.getByRole('button', { name: 'Save palette' }).click()
+ await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+ const firstColor = page.getByRole('textbox', {
+ name: 'Color 1',
+ exact: true,
+ })
+ await firstColor.fill('#725FD6')
+ await firstColor.blur()
+ await capture(page, '07-modified-state')
+ await page.getByRole('button', { name: 'Save changes' }).click()
+
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await expect(page.getByText('Palette summary')).toBeVisible()
+ await page.locator('.sidebar-theme select').selectOption('dark')
+ await capture(page, '08-review-overview')
+ await page.getByRole('tab', { name: 'Harmony' }).click()
+ await capture(page, '09-review-harmony')
+ await page.getByRole('tab', { name: 'Contrast' }).click()
+ await page.getByLabel('Page background').selectOption('#F5F0E8')
+ await page.getByLabel('Primary text').selectOption('#141D29')
+ await capture(page, '10-contrast-roles')
+ await page.getByRole('tab', { name: 'Suggestions' }).click()
+ await page.getByRole('button', { name: 'Generate suggestions' }).click()
+ await expect(page.locator('.compact-suggestion-list')).toBeVisible()
+ await capture(page, '11-suggestions')
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Export' })
+ .click()
+ await capture(page, '12-export')
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Library' })
+ .click()
+ await capture(page, '13-library')
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page
+ .getByRole('navigation', { name: 'Mobile primary' })
+ .getByRole('button', { name: 'Create' })
+ .click()
+ await capture(page, '14-mobile-create')
+ await page
+ .getByRole('navigation', { name: 'Mobile primary' })
+ .getByRole('button', { name: 'Review' })
+ .click()
+ await capture(page, '15-mobile-review')
+
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.getByRole('button', { name: 'New palette' }).click()
+ const discardButton = page.getByRole('button', {
+ name: 'Discard and start new',
+ })
+ if (await discardButton.isVisible()) {
+ await discardButton.click()
+ }
+ await page.unroute('**/api/extract-colors?*')
+ await page.route('**/api/extract-colors?*', (route) =>
+ route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ error: { code: 'fixture_error', message: 'Fixture extraction failed.' },
+ }),
+ }),
+ )
+ await page.locator('#source-image').setInputFiles({
+ name: 'fixture-error.png',
+ mimeType: 'image/png',
+ buffer: png,
+ })
+ await expect(page.getByRole('alert')).toContainText(
+ 'Fixture extraction failed.',
+ )
+ await capture(page, '16-error-notice')
+})
diff --git a/frontend/e2e/workflow.spec.ts b/frontend/e2e/workflow.spec.ts
new file mode 100644
index 0000000..a3cb67d
--- /dev/null
+++ b/frontend/e2e/workflow.spec.ts
@@ -0,0 +1,86 @@
+import AxeBuilder from '@axe-core/playwright'
+import { expect, test, type Page } from '@playwright/test'
+
+async function createManualPalette(page: Page) {
+ await page.goto('/?view=create')
+ await page.getByRole('button', { name: 'Start manually' }).click()
+ await page.getByRole('button', { name: 'Add color' }).click()
+ await page
+ .getByRole('textbox', { name: 'Color 1', exact: true })
+ .fill('#6A5BCF')
+ await page.getByRole('textbox', { name: 'Color 1', exact: true }).blur()
+ await page
+ .getByRole('textbox', { name: 'Color 2', exact: true })
+ .fill('#F5F0E8')
+ await page.getByRole('textbox', { name: 'Color 2', exact: true }).blur()
+}
+
+test('creation through save, review, export, reopen, and delete', async ({
+ page,
+ context,
+}) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+ await createManualPalette(page)
+ await expect(page.getByText('Unsaved')).toBeVisible()
+ await page.getByRole('button', { name: 'Save palette' }).click()
+ await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await expect(page.getByText('Palette summary')).toBeVisible()
+ await page.getByRole('tab', { name: 'Contrast' }).click()
+ await page.getByLabel('Page background').selectOption('#F5F0E8')
+ await page.getByLabel('Primary text').selectOption('#6A5BCF')
+ await expect(page.getByText('Primary text on page background')).toBeVisible()
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Export' })
+ .click()
+ await expect(
+ page.getByLabel(/Generated CSS custom properties preview/),
+ ).toContainText('--color-palette-1')
+ await page.getByRole('button', { name: 'Copy' }).click()
+ await expect(page.getByText(/copied to the clipboard/)).toBeVisible()
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Library' })
+ .click()
+ await expect(
+ page.getByRole('button', { name: 'Open Untitled palette' }),
+ ).toBeVisible()
+ await page.getByRole('button', { name: 'Open Untitled palette' }).click()
+ await expect(page.locator('input[value="#6A5BCF"]').first()).toBeVisible()
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Library' })
+ .click()
+ await page.getByRole('button', { name: 'Delete Untitled palette' }).click()
+ await page.getByRole('button', { name: 'Delete palette' }).click()
+ await expect(page.getByText('No saved palettes')).toBeVisible()
+})
+
+test('representative Create, Review, and Library states have no serious axe violations', async ({
+ page,
+}) => {
+ await createManualPalette(page)
+ for (const destination of ['create', 'review', 'library'] as const) {
+ if (destination === 'review') {
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await expect(page.getByText('Palette summary')).toBeVisible()
+ } else {
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', {
+ name: destination === 'create' ? 'Create' : 'Library',
+ })
+ .click()
+ }
+ const results = await new AxeBuilder({ page }).analyze()
+ const serious = results.violations.filter((violation) =>
+ ['serious', 'critical'].includes(violation.impact ?? ''),
+ )
+ expect(serious).toEqual([])
+ }
+})
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..416c06c
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,41 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ {
+ ignores: [
+ 'coverage/**',
+ 'dist/**',
+ 'node_modules/**',
+ 'playwright-report/**',
+ 'test-results/**',
+ ],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ['*.js', 'e2e/**/*.mjs'],
+ languageOptions: { globals: globals.node },
+ },
+ {
+ files: ['src/**/*.{ts,tsx}', 'e2e/**/*.ts'],
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-refresh/only-export-components': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+ },
+)
diff --git a/frontend/index.html b/frontend/index.html
index 3e3655f..e333467 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,9 +5,10 @@