From 1b1dcc91a814206c17a88bbd36963a0ebb989ee1 Mon Sep 17 00:00:00 2001
From: John Crafts <5889731+Artsen@users.noreply.github.com>
Date: Sat, 25 Jul 2026 03:21:06 -0400
Subject: [PATCH 1/2] Prepare ColorCraft for dashboard discovery
---
.github/workflows/ci.yml | 53 +
.gitignore | 5 +
README.md | 1100 +----------------
app-manifest.json | 25 +
backend/accessibility.py | 122 +-
backend/color_extractor.py | 21 +-
backend/color_suggestions.py | 619 +++++-----
backend/color_theory.py | 52 +-
backend/config.py | 14 +-
backend/main.py | 62 +-
backend/models.py | 20 +-
backend/requirements-dev.txt | 3 +
check.cmd | 17 +
check.py | 118 ++
dev.py | 18 +-
docs/README.md | 15 +
docs/api.md | 49 +
docs/architecture.md | 26 +
docs/assets/screenshots/create-light.png | Bin 0 -> 137039 bytes
docs/assets/screenshots/library-dark.png | Bin 0 -> 47157 bytes
docs/assets/screenshots/mobile-create.png | Bin 0 -> 55961 bytes
docs/assets/screenshots/review-dark.png | Bin 0 -> 157352 bytes
docs/brand-system.md | 8 +-
docs/dashboard-manifest.md | 23 +
docs/getting-started.md | 49 +
docs/persistence-and-privacy.md | 30 +
docs/runtime-configuration.md | 30 +
docs/screenshot-review.md | 16 +
docs/testing.md | 46 +
docs/troubleshooting.md | 46 +
docs/user-workflow.md | 36 +
frontend/.prettierignore | 6 +
frontend/.prettierrc.json | 5 +
frontend/e2e/capture-screenshots.mjs | 23 +
frontend/e2e/screenshot-review.spec.ts | 179 +++
frontend/e2e/workflow.spec.ts | 86 ++
frontend/eslint.config.js | 41 +
frontend/index.html | 6 +-
frontend/package.json | 18 +
frontend/playwright.config.ts | 49 +
frontend/pnpm-lock.yaml | 986 +++++++++++++++
frontend/pnpm-workspace.yaml | 2 +-
frontend/postcss.config.js | 1 -
frontend/public/app-manifest.json | 25 +
frontend/public/colorcraft-mark.svg | 7 +
frontend/src/App.test.tsx | 162 ++-
frontend/src/App.tsx | 450 ++++++-
frontend/src/api/client.test.ts | 13 +-
frontend/src/api/client.ts | 4 +-
frontend/src/api/errors.ts | 5 +-
.../src/components/AnalysisResults.test.tsx | 4 +-
frontend/src/components/AnalysisResults.tsx | 114 +-
frontend/src/components/AppShell.test.tsx | 34 +-
frontend/src/components/AppShell.tsx | 52 +-
.../src/components/ColorSuggestions.test.tsx | 35 +-
frontend/src/components/ColorSuggestions.tsx | 122 +-
frontend/src/components/ColorWheel.tsx | 14 +-
.../src/components/ExportWorkspace.test.tsx | 20 +-
frontend/src/components/ExportWorkspace.tsx | 45 +-
.../src/components/ImageColorPicker.test.tsx | 4 +-
frontend/src/components/ImageColorPicker.tsx | 45 +-
frontend/src/components/ImageUpload.test.tsx | 26 +-
frontend/src/components/ImageUpload.tsx | 21 +-
frontend/src/components/MiniColorWheel.tsx | 18 +-
frontend/src/components/PaletteItem.tsx | 40 +-
.../src/components/PaletteLibrary.test.tsx | 91 ++
frontend/src/components/PaletteLibrary.tsx | 199 +++
.../src/components/ReviewWorkspace.test.tsx | 95 +-
frontend/src/components/ReviewWorkspace.tsx | 314 ++++-
frontend/src/components/ui/Dialog.tsx | 6 +-
frontend/src/components/ui/Field.tsx | 10 +-
frontend/src/components/ui/IconButton.tsx | 9 +-
frontend/src/components/ui/Notice.tsx | 7 +-
frontend/src/components/ui/Panel.tsx | 5 +-
frontend/src/components/ui/StatusBadge.tsx | 3 +-
frontend/src/components/ui/Tabs.tsx | 4 +-
frontend/src/components/ui/ThemeControl.tsx | 11 +-
.../src/components/ui/primitives.test.tsx | 39 +-
frontend/src/contrast.test.ts | 23 +-
frontend/src/contrast.ts | 67 +-
frontend/src/exporters.test.ts | 2 +-
frontend/src/exporters.ts | 90 +-
frontend/src/main.tsx | 1 -
frontend/src/persistence.test.ts | 69 ++
frontend/src/persistence.ts | 205 +++
frontend/src/styles/components.css | 243 +++-
frontend/src/styles/features.css | 1022 ++++++++++++---
frontend/src/styles/foundation.test.ts | 10 +-
frontend/src/styles/foundations.css | 41 +-
frontend/src/styles/layout.css | 128 +-
frontend/src/styles/responsive.css | 246 +++-
frontend/src/styles/tokens.css | 67 +-
frontend/src/test/setup.ts | 9 +-
frontend/src/theme.test.tsx | 18 +-
frontend/src/theme.ts | 4 +-
frontend/src/workspace.test.ts | 9 +-
frontend/src/workspace.ts | 37 +-
frontend/tailwind.config.js | 6 +-
frontend/tsconfig.json | 1 -
frontend/tsconfig.node.json | 1 -
frontend/vite.config.ts | 15 +-
frontend/vitest.config.ts | 13 +
pyproject.toml | 19 +
tests/conftest.py | 3 +-
tests/test_accessibility.py | 1 -
tests/test_api.py | 62 +-
tests/test_check.py | 28 +
tests/test_color_extractor.py | 14 +-
tests/test_color_theory.py | 22 +-
tests/test_docs.py | 42 +
tests/test_manifest.py | 26 +
tests/test_runtime.py | 4 +-
112 files changed, 6299 insertions(+), 2327 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 app-manifest.json
create mode 100644 check.cmd
create mode 100644 check.py
create mode 100644 docs/README.md
create mode 100644 docs/api.md
create mode 100644 docs/architecture.md
create mode 100644 docs/assets/screenshots/create-light.png
create mode 100644 docs/assets/screenshots/library-dark.png
create mode 100644 docs/assets/screenshots/mobile-create.png
create mode 100644 docs/assets/screenshots/review-dark.png
create mode 100644 docs/dashboard-manifest.md
create mode 100644 docs/getting-started.md
create mode 100644 docs/persistence-and-privacy.md
create mode 100644 docs/runtime-configuration.md
create mode 100644 docs/screenshot-review.md
create mode 100644 docs/testing.md
create mode 100644 docs/troubleshooting.md
create mode 100644 docs/user-workflow.md
create mode 100644 frontend/.prettierignore
create mode 100644 frontend/.prettierrc.json
create mode 100644 frontend/e2e/capture-screenshots.mjs
create mode 100644 frontend/e2e/screenshot-review.spec.ts
create mode 100644 frontend/e2e/workflow.spec.ts
create mode 100644 frontend/eslint.config.js
create mode 100644 frontend/playwright.config.ts
create mode 100644 frontend/public/app-manifest.json
create mode 100644 frontend/public/colorcraft-mark.svg
create mode 100644 frontend/src/components/PaletteLibrary.test.tsx
create mode 100644 frontend/src/components/PaletteLibrary.tsx
create mode 100644 frontend/src/persistence.test.ts
create mode 100644 frontend/src/persistence.ts
create mode 100644 pyproject.toml
create mode 100644 tests/test_check.py
create mode 100644 tests/test_docs.py
create mode 100644 tests/test_manifest.py
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 0000000000000000000000000000000000000000..32c0321d6ebb5809525c4d944990d9d12e373a9b
GIT binary patch
literal 137039
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