Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ venv/
.venv*/
ENV/
*.egg-info/
.coverage
dist/
build/
*.log
Expand All @@ -24,6 +25,10 @@ pnpm-debug.log*
# Frontend build
frontend/dist/
frontend/.vite/
frontend/coverage/
frontend/playwright-report/
frontend/test-results/
.tmp/

# IDE
.vscode/
Expand Down
1,100 changes: 51 additions & 1,049 deletions README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions app-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
122 changes: 62 additions & 60 deletions backend/accessibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,126 +8,128 @@

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


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)
"""
if isinstance(color1, str):
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
21 changes: 8 additions & 13 deletions backend/color_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -162,19 +158,15 @@ 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)


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]


Expand Down Expand Up @@ -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
Loading
Loading