diff --git a/README.md b/README.md index 6eb4925..82bfeb6 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@

ColorCraft creates a palette from a source image or from colors that you enter -manually. It detects geometric hue relationships, evaluates contrast, proposes -optional colors, and generates browser-side exports. +manually. It reviews geometric hue relationships and defined color-pair +contrast, proposes optional colors, and generates browser-side exports. ![ColorCraft Review workspace](./docs/assets/screenshots/review-dark.png) @@ -28,12 +28,18 @@ optional colors, and generates browser-side exports. 6. Use Export to generate **CSS custom properties**, **JSON**, **Tailwind theme colors**, or an **SVG swatch sheet**. 7. Select **Copy** or **Download**. -Relationship fit measures geometric agreement with documented hue structures, not aesthetic quality. Contrast checks measure one accessibility requirement; they do not prove complete WCAG conformance. +Relationship fit measures geometric agreement with documented hue structures, +not aesthetic quality. Text, non-text, and focus-indicator checks use the +threshold for the identified role pair. They do not prove complete WCAG +conformance. The API accepts a source image up to 10 MB and 40 million decoded pixels. Extraction returns 3–10 requested colors, or fewer for a limited processing sample. **Save palette** stores a versioned record in IndexedDB. The Library can open, search, rename, duplicate, and delete saved palettes. ColorCraft has no accounts or cloud synchronization. Source-image bytes and unsaved changes remain session-only. Browser history stores the active view and Review tab, not palette data. Export generation runs in the browser. +The shell reports **Loopback only** or **LAN enabled** from runtime metadata. +SVG exports select black or white swatch labels by measured contrast. + ![ColorCraft Palette Library](./docs/assets/screenshots/library-dark.png) ## Quick start diff --git a/backend/accessibility.py b/backend/accessibility.py index c28cf1e..fb91e6f 100644 --- a/backend/accessibility.py +++ b/backend/accessibility.py @@ -55,7 +55,7 @@ def wcag_rating(ratio): Get WCAG rating for a contrast ratio. Returns: - Dictionary with AA and AAA compliance for normal and large text + Dictionary with AA and AAA threshold results for normal and large text """ return { "ratio": round(ratio, 2), diff --git a/backend/color_suggestions.py b/backend/color_suggestions.py index 0790f70..cb98783 100644 --- a/backend/color_suggestions.py +++ b/backend/color_suggestions.py @@ -1,5 +1,5 @@ """ -Color suggestion engine for generating harmonious color recommendations. +Color suggestion engine for documented geometric color transformations. """ from color_extractor import hsl_to_rgb, rgb_to_hsl @@ -17,9 +17,6 @@ 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"] @@ -31,35 +28,34 @@ def generate_complementary(base_color): "saturation": s, "lightness": l, "name": "Direct Complement", - "description": "Exact opposite on the color wheel", + "description": "Hue shifted by 180°; saturation and lightness unchanged", }, { "hue": comp_hue, "saturation": min(100, s + 15), "lightness": max(20, l - 20), - "name": "Rich Complement", - "description": "More saturated and darker for depth", + "name": "Darker Complement", + "description": "Hue +180°, saturation +15, lightness -20", }, { "hue": comp_hue, "saturation": max(30, s - 20), "lightness": min(90, l + 20), - "name": "Soft Complement", - "description": "Lighter and less saturated for subtlety", + "name": "Lighter Complement", + "description": "Hue +180°, saturation -20, lightness +20", }, ] return { "type": "Complementary", "angle": "180°", - "description": "Complementary colors sit opposite each other on the color wheel, creating maximum contrast and visual energy.", + "description": "Moves the hue 180° around the color wheel. Color separation and contrast depend on saturation, lightness, and assigned roles.", "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", + "Comparing colors with opposite hues", + "Exploring emphasis after contrast review", + "Testing role assignments with strong hue separation", ], - "mood": "Energetic, bold, dynamic, attention-grabbing", + "common_associations": "Emphasis, opposition, and color separation", "examples": "Red & Green, Blue & Orange, Yellow & Purple", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -68,8 +64,6 @@ def generate_complementary(base_color): 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"] @@ -85,14 +79,14 @@ def generate_triadic(base_color): "saturation": s, "lightness": l, "name": f"Triadic Partner {offset}°", - "description": "Equal spacing for balance", + "description": f"Hue +{offset}°; saturation and lightness unchanged", }, { "hue": tri_hue, "saturation": min(100, s + 10), "lightness": l, - "name": f"Vibrant Triadic {offset}°", - "description": "Boosted saturation for impact", + "name": f"Saturated Triadic {offset}°", + "description": f"Hue +{offset}°, saturation +10, lightness unchanged", }, ] ) @@ -100,14 +94,13 @@ def generate_triadic(base_color): return { "type": "Triadic", "angle": "120°", - "description": "Triadic colors are evenly spaced around the color wheel, forming an equilateral triangle. This creates balanced, vibrant palettes.", + "description": "Moves the hue by 120° and 240° to form three evenly spaced hue positions. Suitability depends on context and assigned roles.", "use_cases": [ - "Playful, energetic designs", - "Children's products and educational materials", - "Brand identities that need to feel dynamic", - "Infographics and data visualizations", + "Exploring three-category color systems", + "Comparing evenly spaced hue options", + "Testing categorical data colors", ], - "mood": "Balanced, vibrant, playful, harmonious", + "common_associations": "Variety, category separation, and three-part systems", "examples": "Red-Yellow-Blue (primary colors), Orange-Green-Purple (secondary colors)", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -116,8 +109,6 @@ def generate_triadic(base_color): 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"] @@ -139,14 +130,13 @@ def generate_analogous(base_color): 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.", + "description": "Moves the hue by 30° or 60° in either direction while preserving saturation and lightness.", "use_cases": [ - "Backgrounds and gradients", - "Nature-inspired designs", - "Calming, serene interfaces", - "Photography and art portfolios", + "Exploring nearby hue variations", + "Testing gradients after interpolation review", + "Building related category colors", ], - "mood": "Harmonious, serene, cohesive, natural", + "common_associations": "Continuity, proximity, and related color groups", "examples": "Blue-Blue/Green-Green, Red-Orange-Yellow", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -181,8 +171,8 @@ def generate_split_complementary(base_color): "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", + "name": f"Lighter Split {'+30°' if offset > 0 else '-30°'}", + "description": f"Hue {150 if offset < 0 else 210:+d}°, saturation -15, lightness +15", }, ] ) @@ -190,14 +180,13 @@ def generate_split_complementary(base_color): 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.", + "description": "Moves the hue by 150° and 210° to place two options around the direct complement.", "use_cases": [ - "Sophisticated brand palettes", - "Web designs needing contrast without harshness", - "Editorial layouts and magazines", - "Product packaging with visual interest", + "Comparing two alternatives near a complementary hue", + "Exploring three-color role assignments", + "Testing categorical separation", ], - "mood": "Sophisticated, balanced, nuanced, refined", + "common_associations": "Contrast variation and a three-color structure", "examples": "Blue with Yellow-Orange and Red-Orange", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -206,8 +195,6 @@ def generate_split_complementary(base_color): 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"] @@ -229,14 +216,13 @@ def generate_tetradic(base_color): 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.", + "description": "Moves the hue by 90°, 180°, and 270° to form four evenly spaced hue positions.", "use_cases": [ - "Complex brand systems with multiple sub-brands", - "Data visualizations with many categories", - "Festive, celebratory designs", - "Gaming interfaces and entertainment", + "Exploring four-category color systems", + "Comparing quarter-turn hue offsets", + "Testing multi-role assignments", ], - "mood": "Rich, complex, diverse, energetic", + "common_associations": "Variety, four-part systems, and category separation", "examples": "Red-Yellow-Green-Blue, Orange-Chartreuse-Cyan-Violet", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -268,14 +254,13 @@ def generate_rectangular(base_color): 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.", + "description": "Moves the hue by 60°, 180°, and 240° to form two opposite hue pairs.", "use_cases": [ - "Editorial designs with multiple sections", - "Dashboard interfaces with distinct zones", - "Marketing materials with varied content", - "Presentation templates", + "Exploring two complementary pairs", + "Testing four-category color systems", + "Comparing primary and secondary role groups", ], - "mood": "Balanced, sophisticated, varied, professional", + "common_associations": "Paired opposites and multi-category systems", "examples": "Blue-Orange paired with Yellow-Violet", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -284,8 +269,6 @@ def generate_rectangular(base_color): 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"] @@ -295,56 +278,55 @@ def generate_monochromatic(base_color): "saturation": max(10, s - 30), "lightness": min(95, l + 30), "name": "Lighter Tint", - "description": "Pastel variation for backgrounds", + "description": "Same hue, saturation -30, lightness +30", }, { "hue": h, "saturation": max(5, s - 40), "lightness": min(98, l + 40), "name": "Very Light Tint", - "description": "Nearly white for subtle accents", + "description": "Same hue, saturation -40, lightness +40", }, { "hue": h, "saturation": min(100, s + 20), "lightness": max(15, l - 30), "name": "Darker Shade", - "description": "Rich, deep variation for text", + "description": "Same hue, saturation +20, lightness -30", }, { "hue": h, "saturation": min(100, s + 10), "lightness": max(10, l - 40), "name": "Very Dark Shade", - "description": "Nearly black for strong contrast", + "description": "Same hue, saturation +10, lightness -40", }, { "hue": h, "saturation": max(15, s - 25), "lightness": l, "name": "Desaturated Tone", - "description": "Muted variation for sophistication", + "description": "Same hue, saturation -25, lightness unchanged", }, { "hue": h, "saturation": min(100, s + 30), "lightness": l, - "name": "Vibrant Tone", - "description": "Boosted saturation for impact", + "name": "Saturated Tone", + "description": "Same hue, saturation +30, lightness unchanged", }, ] 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.", + "description": "Keeps the hue fixed and changes saturation and lightness. Contrast and role suitability require separate review.", "use_cases": [ - "Minimalist, elegant interfaces", - "Professional corporate designs", - "Photography portfolios", - "Luxury brand materials", + "Exploring states within one hue family", + "Testing light and dark role candidates", + "Building ordered surface levels", ], - "mood": "Cohesive, elegant, sophisticated, calm", + "common_associations": "Continuity, hierarchy, and one-hue systems", "examples": "Navy-Blue-Sky Blue-Powder Blue, Forest-Sage-Mint Green", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -398,14 +380,13 @@ def generate_double_complementary(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.", + "description": "Adds a hue 30° from the base and the 180° complements of both hues.", "use_cases": [ - "Bold, energetic brand identities", - "Sports team colors and jerseys", - "Festival and event materials", - "Attention-grabbing advertisements", + "Exploring two related base hues and their complements", + "Testing four-category color systems", + "Comparing two opposite hue pairs", ], - "mood": "Bold, energetic, dynamic, striking", + "common_associations": "Paired opposites and four-color systems", "examples": "Red-Green paired with Blue-Orange", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } @@ -414,8 +395,6 @@ def generate_double_complementary(base_color): 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"] @@ -448,14 +427,13 @@ def generate_shades_tints(base_color): 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.", + "description": "Keeps hue and saturation fixed while changing lightness by 15, 30, or 45 percentage points.", "use_cases": [ - "UI states (hover, active, disabled)", - "Text hierarchy (headings, body, captions)", - "Shadows and highlights", - "Depth and layering in designs", + "Exploring interface state candidates", + "Comparing surface levels", + "Testing text and background role candidates", ], - "mood": "Structured, hierarchical, organized, clear", + "common_associations": "Hierarchy, state variation, and tonal scales", "examples": "Light Blue → Blue → Navy, Pink → Red → Maroon", "suggestions": [convert_hsl_to_color(s) for s in suggestions], } diff --git a/backend/color_theory.py b/backend/color_theory.py index 80ee6b8..117f40f 100644 --- a/backend/color_theory.py +++ b/backend/color_theory.py @@ -16,6 +16,7 @@ TETRADIC_TOLERANCE = 10.0 SPLIT_COMPLEMENTARY_TOLERANCE = 12.0 MONOCHROMATIC_TOLERANCE = 10.0 +TEMPERATURE_DOMINANCE_THRESHOLD = 0.7 def normalize_hue(hue: float) -> float: @@ -273,6 +274,7 @@ def detect_monochromatic( def analyze_warm_cool_balance(colors: list[dict[str, object]]) -> dict[str, object]: warm_count = 0 + transitional_count = 0 cool_count = 0 for color in colors: hsl = color["hsl"] @@ -281,26 +283,43 @@ def analyze_warm_cool_balance(colors: list[dict[str, object]]) -> dict[str, obje hue = normalize_hue(float(hsl["h"])) if hue <= 60 or hue >= 300: warm_count += 1 - elif 120 <= hue <= 300: + elif hue < 120: + transitional_count += 1 + else: cool_count += 1 - categorized = warm_count + cool_count + categorized = warm_count + transitional_count + cool_count if categorized == 0: return { "balance": "neutral", "warm_count": 0, + "transitional_count": 0, "cool_count": 0, "warm_ratio": 0.0, + "transitional_ratio": 0.0, "cool_ratio": 0.0, } warm_ratio = warm_count / categorized + transitional_ratio = transitional_count / categorized cool_ratio = cool_count / categorized - balance = "warm" if warm_ratio > 0.7 else "cool" if cool_ratio > 0.7 else "balanced" + ratios = { + "warm": warm_ratio, + "transitional": transitional_ratio, + "cool": cool_ratio, + } + dominant_category, dominant_ratio = max(ratios.items(), key=lambda item: item[1]) + balance = ( + dominant_category + if dominant_ratio > TEMPERATURE_DOMINANCE_THRESHOLD + else "mixed" + ) return { "balance": balance, "warm_count": warm_count, + "transitional_count": transitional_count, "cool_count": cool_count, "warm_ratio": round(warm_ratio, 2), + "transitional_ratio": round(transitional_ratio, 2), "cool_ratio": round(cool_ratio, 2), } diff --git a/backend/config.py b/backend/config.py index da35995..48b23a0 100644 --- a/backend/config.py +++ b/backend/config.py @@ -118,6 +118,25 @@ def client_api_url(self) -> str: host = self.api_host if is_loopback_host(self.api_host) else "127.0.0.1" return origin_for(host, self.api_port) + @property + def network_mode(self) -> str: + """Return the exposure mode from the resolved hosts and browser origins.""" + configured_hosts = [self.web_host, self.api_host] + configured_hosts.extend( + parsed.hostname + for origin in self.allowed_origins + if (parsed := urlparse(origin)).hostname + ) + if self.vite_api_url: + parsed_api_url = urlparse(self.vite_api_url) + if parsed_api_url.hostname: + configured_hosts.append(parsed_api_url.hostname) + return ( + "loopback" + if all(is_loopback_host(host) for host in configured_hosts) + else "lan" + ) + @classmethod def from_env( cls, environment: Mapping[str, str] | None = None diff --git a/backend/main.py b/backend/main.py index a6f73ab..9e859d3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -167,6 +167,7 @@ async def metadata() -> ApplicationMetadata: api_url=runtime.client_api_url, health_url=f"{runtime.client_api_url}/health", readiness_url=f"{runtime.client_api_url}/ready", + network_mode=runtime.network_mode, capabilities=CAPABILITIES, ) diff --git a/backend/models.py b/backend/models.py index cc46373..b4de64e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -116,6 +116,7 @@ class ApplicationMetadata(ContractModel): api_url: str health_url: str readiness_url: str + network_mode: Literal["loopback", "lan"] capabilities: list[str] @@ -162,10 +163,12 @@ class HarmonyResults(ContractModel): class TemperatureResults(ContractModel): - balance: Literal["warm", "cool", "balanced", "neutral"] + balance: Literal["warm", "transitional", "cool", "mixed", "neutral"] warm_count: int = Field(ge=0) + transitional_count: int = Field(ge=0) cool_count: int = Field(ge=0) warm_ratio: float = Field(ge=0, le=1) + transitional_ratio: float = Field(ge=0, le=1) cool_ratio: float = Field(ge=0, le=1) @@ -238,7 +241,7 @@ class HarmonySuggestion(ContractModel): angle: str description: str use_cases: list[str] - mood: str + common_associations: str examples: str suggestions: list[SuggestionColor] diff --git a/docs/api-contracts.md b/docs/api-contracts.md index d2d53f4..795afd0 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -97,6 +97,15 @@ Each detected relationship contains: Relationship fit describes measured geometry. It does not describe aesthetic quality. +`temperatureBalance` contains: + +- `balance`: `warm`, `transitional`, `cool`, `mixed`, or `neutral` +- `warmCount`, `transitionalCount`, and `coolCount` +- `warmRatio`, `transitionalRatio`, and `coolRatio` + +The ratios use the total categorized meaningful-hue count. A category is +dominant only when its ratio is greater than 0.70. + ## Contrast analysis `accessibility` contains: @@ -116,9 +125,13 @@ Each item in `suggestions` contains: - `baseColor` - `harmonies` -Each harmony item contains `type`, `angle`, `description`, `useCases`, `mood`, -`examples`, and `suggestions`. Each suggested color contains `name`, -`description`, HEX, RGB, and HSL values. +Each harmony item contains `type`, `angle`, `description`, `useCases`, +`commonAssociations`, `examples`, and `suggestions`. Each suggested color +contains `name`, `description`, HEX, RGB, and HSL values. + +`commonAssociations` is conventional guidance. It is not a measured result. +This field replaces the removed `mood` field and is a breaking response-contract +change. ## Metadata and readiness @@ -133,8 +146,12 @@ Each harmony item contains `type`, `angle`, `description`, `useCases`, `mood`, - `apiUrl` - `healthUrl` - `readinessUrl` +- `networkMode`: `loopback` or `lan` - `capabilities` +`networkMode` comes from resolved hosts and browser-visible origins. It does +not come only from the LAN opt-in environment variable. + `GET /ready` returns HTTP 200 with `status: "ready"` after startup. Before startup completes, it can return HTTP 503 with `status: "not_ready"`. diff --git a/docs/architecture.md b/docs/architecture.md index b282c87..716a509 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,13 +85,17 @@ The frontend sends 2–10 normalized palette colors to 6. Returns a camelCase `analysis` contract. The frontend combines the API analysis with current role assignments. Contrast -shows role-specific checks and an advanced all-pairs contrast matrix. +uses an explicit `text`, `nonText`, or `focus` discriminator for each role +check. Text checks use AA and AAA text thresholds. Non-text and focus color +checks use 3:1 without text badges. The advanced matrix remains a text-contrast +exploration table. ## Suggestions flow The frontend sends 1–10 colors to `POST /api/suggest-colors`. The API generates -suggestion approaches for each base color. Suggestions do not change the -palette automatically. The user must select **Add**. +geometric suggestion approaches for each base color. `commonAssociations` and +`useCases` contain qualified conventional guidance. Suggestions do not change +the palette automatically. The user must select **Add**. The frontend fingerprints the current palette. A palette color change invalidates the displayed suggestion results. @@ -106,8 +110,9 @@ The frontend generates every export without an API request: - SVG swatch sheet CSS and Tailwind comments replace line breaks and the `*/` sequence in the -palette name. SVG output escapes `&`, `<`, `>`, `"`, and `'`. Download uses an -object URL and revokes the URL after the browser starts the download. +palette name. SVG output escapes `&`, `<`, `>`, `"`, and `'`. Each swatch label +uses black or white according to the higher measured contrast ratio. Download +uses an object URL and revokes the URL after the browser starts the download. Export does not create or update a saved palette record. @@ -130,6 +135,10 @@ The default services bind to `127.0.0.1`. CORS accepts the resolved web origin. Wildcard origins are rejected. Non-loopback hosts and origins require `COLORCRAFT_ALLOW_LAN_ACCESS=true`. +Runtime metadata reports `networkMode` from resolved hosts and browser origins. +The frontend uses this field for the shell status and does not infer exposure +from a display URL. + ColorCraft does not provide authentication. Trusted LAN access expands the network boundary. Do not expose the development API directly to an untrusted network. diff --git a/docs/assets/screenshots/contrast-dark.png b/docs/assets/screenshots/contrast-dark.png new file mode 100644 index 0000000..be08a4d Binary files /dev/null and b/docs/assets/screenshots/contrast-dark.png differ diff --git a/docs/assets/screenshots/review-dark.png b/docs/assets/screenshots/review-dark.png index 1dea84a..ade7cae 100644 Binary files a/docs/assets/screenshots/review-dark.png and b/docs/assets/screenshots/review-dark.png differ diff --git a/docs/assets/screenshots/suggestions-dark.png b/docs/assets/screenshots/suggestions-dark.png new file mode 100644 index 0000000..ad676f0 Binary files /dev/null and b/docs/assets/screenshots/suggestions-dark.png differ diff --git a/docs/brand-system.md b/docs/brand-system.md index 4c90fe6..46fcb0d 100644 --- a/docs/brand-system.md +++ b/docs/brand-system.md @@ -20,6 +20,11 @@ local utility rather than a dashboard and does not imply accounts or cloud sync. - Prefer concrete verbs such as extract, inspect, compare, and add. - Avoid generic AI language and inflated claims. - Keep helper text short and place detail beside the result it qualifies. +- Use **review** or **measure** for defined properties. Do not say that + ColorCraft validates aesthetic quality or complete accessibility. +- Use **Loopback only** and **LAN enabled** for resolved network status. Do not + substitute *Local only*, *Secure*, *Private*, or *Offline*. +- Present suggestion associations as conventional guidance, not measured fact. This document governs product voice, visual identity, and interface tone. [ColorCraft Technical English](./writing-style.md) governs procedural and @@ -85,8 +90,8 @@ menus use elevated shadow, and modal dialogs use dialog shadow. - Desktop uses a compact 256px sidebar with product identity, New palette, workflow navigation, and theme selection. -- Workspace headers describe the current palette source and always state that - work is local only. +- Workspace headers describe the current palette source and show the + metadata-derived network state as `Loopback only` or `LAN enabled`. - Create is always available. Review and Export reflect real palette prerequisites and explain disabled states. - At 1024px and below, the sidebar becomes a mobile top bar and bottom diff --git a/docs/color-analysis.md b/docs/color-analysis.md index 1cc8fcd..ec3fee4 100644 --- a/docs/color-analysis.md +++ b/docs/color-analysis.md @@ -132,8 +132,17 @@ accessibility score, or design recommendation. - Hue diversity is circular standard deviation in degrees, capped at 180. - Average saturation is the arithmetic mean of HSL saturation. - Lightness range is the maximum HSL lightness minus the minimum. -- Temperature ratios count meaningful hues in the current warm and cool - intervals. They do not evaluate aesthetic balance. +- Temperature evidence assigns every meaningful hue to one category: + - Warm: 300 through 360 degrees and 0 through 60 degrees + - Transitional: greater than 60 and less than 120 degrees + - Cool: 120 through less than 300 degrees +- A hue at 300 degrees is warm so that the categories do not overlap. +- Low-saturation colors are excluded from temperature evidence. +- `warmRatio`, `transitionalRatio`, and `coolRatio` use all categorized + meaningful hues as the denominator. +- A category is dominant when its ratio is greater than 70%. The result is + `mixed` when no category exceeds 70% and `neutral` when no meaningful hue + evidence exists. ## Contrast calculation @@ -150,7 +159,7 @@ Contrast ratio is: (lighter luminance + 0.05) / (darker luminance + 0.05) ``` -The API reports: +The API all-pairs result reports text thresholds: | Result | Minimum ratio | | --- | ---: | @@ -159,17 +168,30 @@ The API reports: | WCAG AAA normal text | 7:1 | | WCAG AAA large text | 4.5:1 | -The API evaluates every palette color pair. Review also evaluates assigned -color roles in meaningful interface combinations. +The API evaluates every palette color pair as text-contrast exploration data. +Review applies the ratio to typed role checks: + +- Text checks show the AA and AAA normal-text and large-text thresholds. +- Non-text component checks use a 3:1 threshold and do not show text badges. +- Focus-indicator color checks use a 3:1 threshold for each evaluated adjacent + color pair. + +A non-text result evaluates color contrast only. It does not evaluate component +size, shape, state, or other accessibility requirements. A focus-indicator +result does not evaluate size, area, thickness, visibility, or the +focused-versus-unfocused appearance. A passing ratio applies only to the evaluated contrast pair and text category. It does not prove complete interface accessibility or WCAG conformance. ## Suggestions -Suggestions are optional calculations from a selected base color. A suggestion -does not confirm that a detected relationship exists in the current palette. -The frontend invalidates suggestions when any palette color changes. +Suggestions are optional geometric transformations from a selected base color. +Descriptions lead with hue, saturation, or lightness changes. `useCases` and +`commonAssociations` contain conventional guidance, not measured suitability. +A suggestion does not confirm that a detected relationship exists in the +current palette. The frontend invalidates suggestions when any palette color +changes. ## Export accuracy and escaping @@ -177,8 +199,9 @@ JSON export uses `schemaVersion: 1`. JSON includes ordered colors, normalized color values, optional population, color-role metadata, and role assignments. CSS and Tailwind output sanitize palette names used in comments. SVG output -escapes XML-sensitive characters. Export generation does not alter the palette -and does not save it to IndexedDB. +escapes XML-sensitive characters. Each SVG swatch compares measured contrast +against black and white and uses the label color with the higher ratio. Export +generation does not alter the palette and does not save it to IndexedDB. ## Interpretation limits @@ -190,3 +213,4 @@ and does not save it to IndexedDB. - Contrast is independent of hue geometry. - Contrast evaluation covers one accessibility requirement. - Suggestions are recommendations, not measurements. +- Temperature categories describe hue intervals, not aesthetic balance. diff --git a/docs/dashboard-manifest.md b/docs/dashboard-manifest.md index 9452634..5459373 100644 --- a/docs/dashboard-manifest.md +++ b/docs/dashboard-manifest.md @@ -8,7 +8,8 @@ Static addresses are defaults only. Environment variables can change ports or ho 1. Discover the application from `app-manifest.json`. 2. Query the manifest's metadata path on the candidate API address. -3. Treat `/metadata` as authoritative for runtime-resolved URLs. +3. Treat `/metadata` as authoritative for runtime-resolved URLs and + `networkMode`. 4. Use `/ready` for dependency readiness and `/health` for liveness. Current capability slugs are: @@ -20,4 +21,7 @@ Current capability slugs are: - `palette-export` - `local-palette-library` -The manifest contract and public copy are tested for equality. Runtime tests verify that metadata respects configuration overrides. +The manifest contract and public copy are tested for equality. Runtime tests +verify that metadata respects configuration overrides. The static manifest +does not guess the network exposure mode. Runtime metadata reports `loopback` +or `lan` from the resolved configuration. diff --git a/docs/persistence-and-privacy.md b/docs/persistence-and-privacy.md index 0c02bdd..9a856ff 100644 --- a/docs/persistence-and-privacy.md +++ b/docs/persistence-and-privacy.md @@ -60,5 +60,10 @@ source-image data traverses the configured network path. ColorCraft does not provide authentication or transport encryption. Do not expose it directly to an untrusted network. +Runtime metadata reports `networkMode: "loopback"` when every resolved host and +browser origin is loopback. It reports `networkMode: "lan"` when any resolved +host or origin is non-loopback. The shell presents these values as **Loopback +only** and **LAN enabled**. + Local does not automatically mean private, secure, offline, anonymous, or persistent. diff --git a/docs/runtime-configuration.md b/docs/runtime-configuration.md index 8a3b384..2476ce5 100644 --- a/docs/runtime-configuration.md +++ b/docs/runtime-configuration.md @@ -82,6 +82,18 @@ termination when the network requires transport encryption. - `VITE_COLORCRAFT_API_URL` must be reachable from the browser. - Environment variables override `runtime-config.json`. +## Network status + +`GET /metadata` returns a machine-readable `networkMode`: + +- `loopback`: All resolved bind hosts and browser origins are loopback + addresses. +- `lan`: At least one resolved bind host or browser origin is not loopback. + +The application shell shows **Loopback only** or **LAN enabled** from this +field. Setting `COLORCRAFT_ALLOW_LAN_ACCESS=true` without a non-loopback host or +origin does not change the status to **LAN enabled**. + Static dashboard defaults are in [`app-manifest.json`](../app-manifest.json). Dashboard consumers must use `/metadata` when they need runtime-resolved URLs. See [Dashboard manifest](./dashboard-manifest.md). diff --git a/docs/screenshot-review.md b/docs/screenshot-review.md index 6be5f32..b0be00c 100644 --- a/docs/screenshot-review.md +++ b/docs/screenshot-review.md @@ -13,4 +13,4 @@ It creates 16 fixture-driven PNGs under `.tmp/ui-review`: empty Create in both t When a screenshot is representative and stable, copy only that file to `docs/assets/screenshots`, give it a semantic name, and reference it from documentation. Curated images are reviewable product documentation; the complete temporary set is disposable diagnostic output. -The current curated set shows light Create, dark Review, dark Library, and mobile Create. Regenerate it only when a deliberate interface change makes the existing image inaccurate. +The current curated set shows light Create, dark Review Overview, dark role-based Contrast, dark Suggestions, dark Library, and mobile Create. The Review images document the metadata-derived network status, three-way temperature evidence, typed contrast checks, and qualified suggestion language. Regenerate the set only when a deliberate interface change makes an existing image inaccurate. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 347a678..b511108 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -97,8 +97,9 @@ Check the API error code: - `image_decode_error`: Select a valid, decodable source image. - `no_visible_pixels`: Select an image that contains visible pixels. -The current Create UI states a 15 MB limit, but the API enforces 10 MB. Use -10 MB as the effective limit. +Create and the API both enforce a 10 MB limit. A file of exactly 10 MB is +accepted by the byte-limit check. The image must also pass type, decode, and +decoded-pixel validation. ## Color extraction returns fewer colors than requested diff --git a/docs/user-guide.md b/docs/user-guide.md index 979242c..fb0ead1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -101,6 +101,11 @@ Select **Refresh analysis** before you use a stale result. dominant color information, temperature, saturation, lightness, relationship fit, and a next action. +Temperature shows warm, transitional, and cool percentages. **Warm dominant**, +**Transitional dominant**, and **Cool dominant** require more than 70% evidence +in the named category. **Mixed temperature** means that no category exceeds +70%. Neutral colors do not contribute temperature evidence. + ### Harmony **Harmony** lists detected harmony relationships in descending relationship @@ -115,8 +120,8 @@ do not measure aesthetic quality. 1. Select **Contrast**. 2. Assign palette colors to the available color roles. 3. Review each available contrast-role check. -4. Open **Advanced: all-pairs contrast matrix** only when you need exploratory - comparisons. +4. Open **Advanced: all-pairs text contrast matrix** only when you need + exploratory text-threshold comparisons. The current color-role labels are: @@ -129,8 +134,14 @@ The current color-role labels are: - **Border** - **Focus indicator** -A passing contrast-role check does not prove that a complete interface is -accessible or conforms to WCAG. +Text checks show AA and AAA text thresholds. **Border against surface** is a +non-text component check with a 3:1 threshold. Focus-indicator checks use 3:1 +for the selected adjacent-color pair. + +A non-text result evaluates color contrast only. A focus-indicator result does +not evaluate size, area, thickness, visibility, or focused-versus-unfocused +appearance. A passing contrast-role check does not prove that a complete +interface is accessible or conforms to WCAG. ### Suggestions @@ -141,7 +152,9 @@ accessible or conforms to WCAG. 5. Select **Add** for an optional color. Select **Explore all relationships** for the complete set of suggestion -approaches. ColorCraft discards suggestion results when a palette color changes. +approaches. **Common associations** and **Common applications** are conventional +guidance. They are not measured suitability. ColorCraft discards suggestion +results when a palette color changes. ## Export a palette @@ -158,6 +171,9 @@ approaches. ColorCraft discards suggestion results when a palette color changes. exported file through the browser. Export does not create or update a saved palette record. +For SVG output, ColorCraft measures each swatch against black and white. It uses +the text color with the higher contrast ratio. + If clipboard permission is denied, select **Select preview**, and then copy the selected text manually. If download creation fails, copy the preview instead. diff --git a/docs/writing-style.md b/docs/writing-style.md index dd9ebd5..3789dd0 100644 --- a/docs/writing-style.md +++ b/docs/writing-style.md @@ -184,11 +184,16 @@ contents, clipboard contents, or unrelated palette data in user-facing errors. | meaningful hue | A hue with enough saturation to provide geometric evidence under the current algorithm. | The current minimum saturation is documented in `color-analysis.md`. | | contrast ratio | The relative-luminance contrast between two assigned colors. | A ratio is a measurement, not a complete accessibility result. | | contrast pair | Two colors evaluated together for contrast. | State the roles when the pair is role-based. | +| text contrast check | A contrast-role check that applies WCAG normal-text or large-text thresholds. | Do not apply text badges to borders or focus indicators. | +| non-text component contrast check | A contrast-role check that applies the 3:1 color threshold to a component boundary or state. | Do not claim complete component accessibility. | +| focus-indicator color contrast check | A 3:1 check for one focus-indicator and adjacent-color pair. | Size, area, thickness, visibility, and focused-versus-unfocused appearance remain outside the calculation. | | color role | A semantic interface role assigned to a palette color. | Current labels are Page background, Surface, Primary text, Secondary text, Primary action, Action text, Border, and Focus indicator. | | role assignment | The association between a palette color and a color role. | Do not use for an unassigned palette color. | -| contrast-role check | A contrast test between two meaningful assigned color roles. | Do not use as an exact synonym for the all-pairs contrast matrix. | -| all-pairs contrast matrix | The advanced matrix that compares every palette color pair. | Keep separate from contrast-role checks. | +| contrast-role check | A typed contrast test between two meaningful assigned color roles. | Identify whether the check is text, non-text component, or focus-indicator color contrast. | +| all-pairs text contrast matrix | The advanced matrix that applies text thresholds to every palette color pair. | Keep separate from typed contrast-role checks. | | suggestion approach | One method that proposes colors from a base color. | Current API approaches include complementary, triadic, analogous, split-complementary, tetradic, rectangular, monochromatic, double-complementary, and shades and tints. | +| common associations | Context-dependent conventional associations for a suggestion approach. | Preserve the API field `commonAssociations`. Do not present associations as measurements or guaranteed effects. | +| transitional hue | A meaningful hue greater than 60 degrees and less than 120 degrees in temperature evidence. | Keep separate from neutral colors, which do not provide meaningful hue evidence. | | Review | The application view that contains palette analysis. | Preserve capitalization when referring to the UI destination. | | Overview | The Review tab that summarizes the current analysis. | Preserve capitalization. | | Harmony | The Review tab that presents detected geometric relationships. | Preserve capitalization. | @@ -204,6 +209,8 @@ contents, clipboard contents, or unrelated palette data in user-facing errors. | saved palette | A versioned palette record in the browser's IndexedDB database. | Do not imply an account or cloud copy. | | Palette Library | The Library view that lists saved palettes in the current browser origin. | Use **Library** for the navigation label. | | local | The application runs on the current computer or configured local network. | Do not use as an automatic synonym for private, secure, offline, anonymous, or persistent. | +| Loopback only | The shell status for resolved loopback hosts and browser origins. | Preserve capitalization. Do not substitute *Local only*. | +| LAN enabled | The shell status when a resolved host or browser origin is non-loopback. | Do not imply authentication, privacy, or transport encryption. | | source-image preview | The in-memory browser preview of the current source image. | ColorCraft does not store source-image bytes in the Palette Library. | ## Required domain distinctions diff --git a/frontend/e2e/screenshot-review.spec.ts b/frontend/e2e/screenshot-review.spec.ts index 417bfc8..5461ce2 100644 --- a/frontend/e2e/screenshot-review.spec.ts +++ b/frontend/e2e/screenshot-review.spec.ts @@ -28,7 +28,7 @@ async function setPalette(page: Page) { for (let index = 0; index < 4; index += 1) { await page.getByRole('button', { name: 'Add color' }).click() } - const colors = ['#6A5BCF', '#F2763F', '#141D29', '#F5F0E8', '#41A37A'] + const colors = ['#6A5BCF', '#F2763F', '#141D29', '#F5F0E8', '#80FF00'] for (let index = 0; index < colors.length; index += 1) { const input = page.getByRole('textbox', { name: `Color ${index + 1}`, @@ -119,11 +119,19 @@ test('@screenshots fixture-driven UI review', async ({ page }) => { await capture(page, '09-review-harmony') await page.getByRole('tab', { name: 'Contrast' }).click() await page.getByLabel('Page background').selectOption('#F5F0E8') + await page.getByLabel('Surface').selectOption('#F5F0E8') await page.getByLabel('Primary text').selectOption('#141D29') + await page.getByLabel('Secondary text').selectOption('#725FD6') + await page.getByLabel('Primary action').selectOption('#725FD6') + await page.getByLabel('Action text').selectOption('#F5F0E8') + await page.getByLabel('Border').selectOption('#725FD6') + await page.getByLabel('Focus indicator').selectOption('#F2763F') await capture(page, '10-contrast-roles') await page.getByRole('tab', { name: 'Suggestions' }).click() await page.getByRole('button', { name: 'Generate suggestions' }).click() await expect(page.locator('.compact-suggestion-list')).toBeVisible() + await page.getByRole('button', { name: 'Explore all relationships' }).click() + await expect(page.getByText('Common associations').first()).toBeVisible() await capture(page, '11-suggestions') await page diff --git a/frontend/e2e/workflow.spec.ts b/frontend/e2e/workflow.spec.ts index a3cb67d..409f169 100644 --- a/frontend/e2e/workflow.spec.ts +++ b/frontend/e2e/workflow.spec.ts @@ -31,6 +31,7 @@ test('creation through save, review, export, reopen, and delete', async ({ await page.getByLabel('Page background').selectOption('#F5F0E8') await page.getByLabel('Primary text').selectOption('#6A5BCF') await expect(page.getByText('Primary text on page background')).toBeVisible() + await expect(page.getByText(/AA normal text/).first()).toBeVisible() await page .getByRole('navigation', { name: 'Primary' }) @@ -61,21 +62,53 @@ test('creation through save, review, export, reopen, and delete', async ({ await expect(page.getByText('No saved palettes')).toBeVisible() }) -test('representative Create, Review, and Library states have no serious axe violations', async ({ +test('trust-sensitive Create, Review, Suggestions, and Export states are explicit', async ({ page, }) => { + await page.goto('/?view=create') + await expect(page.getByText('JPG, PNG, or WebP · up to 10 MB')).toBeVisible() + await expect(page.getByText('Loopback only')).toHaveAttribute( + 'title', + /loopback traffic only/i, + ) + await createManualPalette(page) - for (const destination of ['create', 'review', 'library'] as const) { - if (destination === 'review') { - await page.getByRole('button', { name: 'Analyze palette' }).click() - await expect(page.getByText('Palette summary')).toBeVisible() - } else { + await page + .getByRole('textbox', { name: 'Color 1', exact: true }) + .fill('#141D29') + await page.getByRole('textbox', { name: 'Color 1', exact: true }).blur() + await page.getByRole('button', { name: 'Add color' }).click() + await page + .getByRole('textbox', { name: 'Color 3', exact: true }) + .fill('#80FF00') + await page.getByRole('textbox', { name: 'Color 3', exact: true }).blur() + await page.getByRole('button', { name: 'Analyze palette' }).click() + await expect(page.getByText(/transitional/)).toBeVisible() + + await page.getByRole('tab', { name: 'Contrast' }).click() + await page.getByLabel('Page background').selectOption('#F5F0E8') + await page.getByLabel('Surface').selectOption('#F5F0E8') + await page.getByLabel('Primary text').selectOption('#141D29') + await page.getByLabel('Border').selectOption('#141D29') + await page.getByLabel('Focus indicator').selectOption('#80FF00') + await expect(page.getByText(/AA normal text/).first()).toBeVisible() + await expect(page.getByText(/Non-text component contrast/)).toBeVisible() + await expect( + page.getByText(/Focus-indicator color contrast/).first(), + ).toBeVisible() + await expect(page.getByText(/Size, area, thickness/).first()).toBeVisible() + + for (const tab of ['Contrast', 'Suggestions'] as const) { + await page.getByRole('tab', { name: tab }).click() + if (tab === 'Suggestions') { + await expect( + page.getByText(/do not measure palette quality/i), + ).toBeVisible() + await page.getByRole('button', { name: 'Generate suggestions' }).click() await page - .getByRole('navigation', { name: 'Primary' }) - .getByRole('button', { - name: destination === 'create' ? 'Create' : 'Library', - }) + .getByRole('button', { name: 'Explore all relationships' }) .click() + await expect(page.getByText('Common associations').first()).toBeVisible() } const results = await new AxeBuilder({ page }).analyze() const serious = results.violations.filter((violation) => @@ -83,4 +116,32 @@ test('representative Create, Review, and Library states have no serious axe viol ) expect(serious).toEqual([]) } + + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('button', { name: 'Export' }) + .click() + await page.getByRole('tab', { name: 'SVG swatch sheet' }).click() + await expect( + page.getByLabel(/Generated SVG swatch sheet preview/), + ).toContainText('fill="#') +}) + +test('representative Create and Library states have no serious axe violations', async ({ + page, +}) => { + await createManualPalette(page) + for (const destination of ['create', 'library'] as const) { + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('button', { + name: destination === 'create' ? 'Create' : 'Library', + }) + .click() + const results = await new AxeBuilder({ page }).analyze() + const serious = results.violations.filter((violation) => + ['serious', 'critical'].includes(violation.impact ?? ''), + ) + expect(serious).toEqual([]) + } }) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4ec035c..ce0cf27 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -7,7 +7,12 @@ import { within, } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { analyzeColors, extractColors, suggestColors } from './api/client' +import { + analyzeColors, + extractColors, + getMetadata, + suggestColors, +} from './api/client' import { analysis, red } from './test/fixtures' import { savePalette } from './persistence' import App from './App' @@ -15,6 +20,7 @@ import App from './App' vi.mock('./api/client', () => ({ extractColors: vi.fn(), analyzeColors: vi.fn(), + getMetadata: vi.fn(), suggestColors: vi.fn(), })) @@ -45,14 +51,64 @@ describe('ColorCraft workspace', () => { }) vi.mocked(extractColors).mockReset() vi.mocked(analyzeColors).mockReset() + vi.mocked(getMetadata).mockReset() vi.mocked(suggestColors).mockReset() vi.mocked(analyzeColors).mockResolvedValue({ success: true, analysis }) + vi.mocked(getMetadata).mockResolvedValue({ + schemaVersion: 1, + id: 'colorcraft', + name: 'ColorCraft', + descriptor: 'Local color utility', + version: '1.0.0', + icon: 'http://127.0.0.1:5174/colorcraft-mark.svg', + webUrl: 'http://127.0.0.1:5174', + apiUrl: 'http://127.0.0.1:4100', + healthUrl: 'http://127.0.0.1:4100/health', + readinessUrl: 'http://127.0.0.1:4100/ready', + networkMode: 'loopback', + capabilities: [], + }) vi.mocked(suggestColors).mockResolvedValue({ success: true, suggestions: [], }) }) + it('renders trust-sensitive shell copy and metadata network mode', async () => { + render() + expect( + screen.getByText( + 'Extract, refine, and review color palettes from images.', + ), + ).toBeInTheDocument() + expect(await screen.findByText('Loopback only')).toHaveAttribute( + 'title', + expect.stringMatching(/loopback traffic only/i), + ) + }) + + it('renders LAN enabled from runtime metadata', async () => { + vi.mocked(getMetadata).mockResolvedValueOnce({ + schemaVersion: 1, + id: 'colorcraft', + name: 'ColorCraft', + descriptor: 'Local color utility', + version: '1.0.0', + icon: 'http://192.168.1.20:5174/colorcraft-mark.svg', + webUrl: 'http://192.168.1.20:5174', + apiUrl: 'http://192.168.1.20:4100', + healthUrl: 'http://192.168.1.20:4100/health', + readinessUrl: 'http://192.168.1.20:4100/ready', + networkMode: 'lan', + capabilities: [], + }) + render() + expect(await screen.findByText('LAN enabled')).toHaveAttribute( + 'title', + expect.stringMatching(/trusted LAN access/i), + ) + }) + it('enables views from real palette prerequisites and writes URL state', async () => { render() expect( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc132c6..8986b42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,7 @@ import { Upload, } from 'lucide-react' import { useEffect, useRef, useState } from 'react' -import { analyzeColors, extractColors } from './api/client' +import { analyzeColors, extractColors, getMetadata } from './api/client' import type { Analysis, Color } from './api/contracts' import { errorMessage } from './api/errors' import AppShell from './components/AppShell' @@ -76,6 +76,9 @@ function App() { const [analyzing, setAnalyzing] = useState(false) const [extracting, setExtracting] = useState(false) const [notice, setNotice] = useState(null) + const [networkMode, setNetworkMode] = useState<'loopback' | 'lan' | null>( + null, + ) const [confirmNewPalette, setConfirmNewPalette] = useState(false) const [pickerTarget, setPickerTarget] = useState(null) const [reviewTab, setReviewTab] = useState(() => @@ -165,6 +168,18 @@ function App() { ) }, []) + useEffect(() => { + const controller = new AbortController() + void getMetadata(controller.signal) + .then((metadata) => setNetworkMode(metadata.networkMode)) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + console.error('Could not read runtime metadata:', error) + } + }) + return () => controller.abort() + }, []) + useEffect(() => { const onPopState = () => { const target = viewFromLocation() @@ -559,7 +574,7 @@ function App() { ? `${savedPalettes.length} saved ${savedPalettes.length === 1 ? 'palette' : 'palettes'} · stored in this browser` : paletteActive ? `${colorCountLabel(colors.length)} · ${paletteSourceType === 'image' ? 'created from an image' : 'created manually'}` - : 'Extract, refine, and validate color palettes from images.' + : 'Extract, refine, and review color palettes from images.' const createView = !paletteActive ? ( ({ id, name }))} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index b741367..1cf69ef 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { analyzeColors, extractColors, suggestColors } from './client' +import { + analyzeColors, + extractColors, + getMetadata, + suggestColors, +} from './client' import { ColorCraftApiError } from './errors' import { analysis, blue, red } from '../test/fixtures' @@ -37,6 +42,32 @@ describe('ColorCraft API client', () => { expect(fetchMock).toHaveBeenCalledOnce() }) + it('parses the runtime network mode from metadata', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + schemaVersion: 1, + id: 'colorcraft', + name: 'ColorCraft', + descriptor: 'Local color utility', + version: '1.0.0', + icon: 'http://127.0.0.1:5174/colorcraft-mark.svg', + webUrl: 'http://127.0.0.1:5174', + apiUrl: 'http://127.0.0.1:4100', + healthUrl: 'http://127.0.0.1:4100/health', + readinessUrl: 'http://127.0.0.1:4100/ready', + networkMode: 'loopback', + capabilities: ['contrast-review'], + }), + ), + ) + + await expect(getMetadata()).resolves.toMatchObject({ + networkMode: 'loopback', + }) + }) + it('rejects failed HTTP responses', async () => { vi.stubGlobal( 'fetch', diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 98724ef..f4b0aa4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,9 +1,11 @@ import type { ZodType } from 'zod' import { analysisResponseSchema, + applicationMetadataSchema, extractionResponseSchema, suggestionResponseSchema, type AnalysisResponse, + type ApplicationMetadata, type Color, type ExtractionResponse, type SuggestionResponse, @@ -76,6 +78,15 @@ export function extractColors( ) } +export function getMetadata( + signal?: AbortSignal, +): Promise { + return request('/metadata', applicationMetadataSchema, { + method: 'GET', + signal, + }) +} + export function analyzeColors( colors: Color[], signal?: AbortSignal, diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 7575a0a..4708bb8 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -62,10 +62,12 @@ export const harmonyResultsSchema = z export const temperatureResultsSchema = z .object({ - balance: z.enum(['warm', 'cool', 'balanced', 'neutral']), + balance: z.enum(['warm', 'transitional', 'cool', 'mixed', 'neutral']), warmCount: z.number().int().nonnegative(), + transitionalCount: z.number().int().nonnegative(), coolCount: z.number().int().nonnegative(), warmRatio: z.number().min(0).max(1), + transitionalRatio: z.number().min(0).max(1), coolRatio: z.number().min(0).max(1), }) .strict() @@ -162,12 +164,29 @@ export const harmonySuggestionSchema = z angle: z.string(), description: z.string(), useCases: z.array(z.string()), - mood: z.string(), + commonAssociations: z.string(), examples: z.string(), suggestions: z.array(suggestionColorSchema), }) .strict() +export const applicationMetadataSchema = z + .object({ + schemaVersion: z.literal(1), + id: z.literal('colorcraft'), + name: z.literal('ColorCraft'), + descriptor: z.literal('Local color utility'), + version: z.string(), + icon: z.string(), + webUrl: z.string(), + apiUrl: z.string(), + healthUrl: z.string(), + readinessUrl: z.string(), + networkMode: z.enum(['loopback', 'lan']), + capabilities: z.array(z.string()), + }) + .strict() + export const suggestionResultSchema = z .object({ baseColor: colorSchema, @@ -221,5 +240,6 @@ export type SuggestionColor = z.infer export type HarmonySuggestion = z.infer export type SuggestionResult = z.infer export type SuggestionResponse = z.infer +export type ApplicationMetadata = z.infer export type ValidationIssue = z.infer export type ErrorResponse = z.infer diff --git a/frontend/src/components/AnalysisResults.tsx b/frontend/src/components/AnalysisResults.tsx index 72f04e2..b6c881a 100644 --- a/frontend/src/components/AnalysisResults.tsx +++ b/frontend/src/components/AnalysisResults.tsx @@ -65,12 +65,22 @@ export default function AnalysisResults({ analysis }: AnalysisResultsProps) {
-

Temperature balance

+

Temperature evidence

Balance {colorTheory.temperatureBalance.balance}
+
+ Transitional colors + + {colorTheory.temperatureBalance.transitionalCount} ( + {( + colorTheory.temperatureBalance.transitionalRatio * 100 + ).toFixed(0)} + %) + +
Warm colors diff --git a/frontend/src/components/AppShell.test.tsx b/frontend/src/components/AppShell.test.tsx index 3b38895..8ed1fcf 100644 --- a/frontend/src/components/AppShell.test.tsx +++ b/frontend/src/components/AppShell.test.tsx @@ -27,6 +27,7 @@ describe('AppShell navigation', () => { title="ColorCraft" sourceName="Local color utility" summary="Create a palette." + networkMode="loopback" onNavigate={vi.fn()} onNewPalette={vi.fn()} > @@ -60,6 +61,7 @@ describe('AppShell navigation', () => { title="Current palette" sourceName="Untitled palette" summary="Two colors · created manually" + networkMode="lan" onNavigate={onNavigate} onNewPalette={vi.fn()} > @@ -75,5 +77,9 @@ describe('AppShell navigation', () => { expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(themeControls[0]).toHaveValue('dark') expect(themeControls[1]).toHaveValue('dark') + expect(screen.getByText('LAN enabled')).toHaveAttribute( + 'title', + expect.stringMatching(/trusted LAN access/i), + ) }) }) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index ed4b06c..60c81a0 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -24,6 +24,7 @@ interface AppShellProps { title: string sourceName: string summary: string + networkMode: 'loopback' | 'lan' | null onNavigate: (view: WorkspaceView) => void onNewPalette: () => void recentPalettes?: Array<{ id: string; name: string }> @@ -45,6 +46,7 @@ export default function AppShell({ title, sourceName, summary, + networkMode, onNavigate, onNewPalette, recentPalettes = [], @@ -172,7 +174,22 @@ export default function AppShell({
{headerActions} - Local only + + {networkMode === 'loopback' + ? 'Loopback only' + : networkMode === 'lan' + ? 'LAN enabled' + : 'Network status unavailable'} +
{children}
diff --git a/frontend/src/components/ColorSuggestions.test.tsx b/frontend/src/components/ColorSuggestions.test.tsx index 724e4ca..61d6c96 100644 --- a/frontend/src/components/ColorSuggestions.test.tsx +++ b/frontend/src/components/ColorSuggestions.test.tsx @@ -40,7 +40,7 @@ function response( angle: '180°', description: 'Relationship details', useCases: ['Testing'], - mood: 'Measured', + commonAssociations: 'Color separation', examples: 'Example', suggestions: [suggested], }, @@ -166,4 +166,17 @@ describe('ColorSuggestions palette lifecycle', () => { screen.getByRole('button', { name: 'Already in palette #0000ff' }), ).toBeDisabled() }) + + it('qualifies suggestions and labels conventional guidance', async () => { + render() + await loadSuggestions([red]) + expect( + screen.getByText(/do not measure palette quality/i), + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: 'Explore all relationships' }), + ) + expect(screen.getByText('Common associations')).toBeInTheDocument() + expect(screen.queryByText('Mood')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/ColorSuggestions.tsx b/frontend/src/components/ColorSuggestions.tsx index 479e8c9..b7591f1 100644 --- a/frontend/src/components/ColorSuggestions.tsx +++ b/frontend/src/components/ColorSuggestions.tsx @@ -114,7 +114,7 @@ export default function ColorSuggestions({ setNotice(null)} /> )} + + Suggestions are generated transformations and optional design + directions. They do not measure palette quality. Review contrast and + assigned roles before use. +
@@ -223,6 +228,7 @@ function SuggestionResults({ {suggestion.hex} @@ -274,15 +280,15 @@ function SuggestionResults({

{harmony.description}

-
Mood
-
{harmony.mood}
+
Common associations
+
{harmony.commonAssociations}
Examples
{harmony.examples}
-
Useful for
+
Common applications
{harmony.useCases.join(', ')}
diff --git a/frontend/src/components/ImageUpload.test.tsx b/frontend/src/components/ImageUpload.test.tsx index f133d21..63fbdea 100644 --- a/frontend/src/components/ImageUpload.test.tsx +++ b/frontend/src/components/ImageUpload.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import ImageUpload, { MAX_IMAGE_BYTES } from './ImageUpload' +import { MAX_IMAGE_BYTES } from '../limits' +import ImageUpload, { validateImageFile } from './ImageUpload' function imageFile(name = 'source.png', type = 'image/png') { return new File(['image'], name, { type }) @@ -57,8 +58,29 @@ describe('ImageUpload', () => { Object.defineProperty(oversized, 'size', { value: MAX_IMAGE_BYTES + 1 }) fireEvent.change(input, { target: { files: [oversized] } }) expect(await screen.findByRole('alert')).toHaveTextContent( - 'Choose an image smaller than 15 MB.', + 'Choose an image that is 10 MB or smaller.', ) expect(onImageSelected).not.toHaveBeenCalled() }) + + it.each([ + ['immediately below', MAX_IMAGE_BYTES - 1, null], + ['exactly at', MAX_IMAGE_BYTES, null], + [ + 'immediately above', + MAX_IMAGE_BYTES + 1, + 'Choose an image that is 10 MB or smaller.', + ], + ])('validates a file %s the API limit', (_label, size, expected) => { + const file = imageFile() + Object.defineProperty(file, 'size', { value: size }) + expect(validateImageFile(file)).toBe(expected) + }) + + it('shows the effective API limit', () => { + render() + expect( + screen.getByText('JPG, PNG, or WebP · up to 10 MB'), + ).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/ImageUpload.tsx b/frontend/src/components/ImageUpload.tsx index 2f7a6dd..4d26014 100644 --- a/frontend/src/components/ImageUpload.tsx +++ b/frontend/src/components/ImageUpload.tsx @@ -1,9 +1,9 @@ import { useRef, useState, type DragEvent, type KeyboardEvent } from 'react' import { ImagePlus, Upload } from 'lucide-react' +import { MAX_IMAGE_BYTES, MAX_IMAGE_MEGABYTES } from '../limits' import Button from './ui/Button' import Notice from './ui/Notice' -export const MAX_IMAGE_BYTES = 15 * 1024 * 1024 const acceptedTypes = new Set(['image/jpeg', 'image/png', 'image/webp']) export function validateImageFile(file: File): string | null { @@ -15,7 +15,7 @@ export function validateImageFile(file: File): string | null { return 'Choose a JPG, PNG, or WebP image.' } if (file.size > MAX_IMAGE_BYTES) { - return 'Choose an image smaller than 15 MB.' + return `Choose an image that is ${MAX_IMAGE_MEGABYTES} MB or smaller.` } return null } @@ -103,7 +103,7 @@ export default function ImageUpload({ {dragReady ? 'Release to use this image' : 'Drop an image here'} - JPG, PNG, or WebP · up to 15 MB + JPG, PNG, or WebP · up to {MAX_IMAGE_MEGABYTES} MB
@@ -441,7 +445,7 @@ function Contrast({
{roleChecks.map((check) => { @@ -460,6 +464,7 @@ function Contrast({ ) } const ratio = contrastRatio(foreground, background) + const results = resultsForContrastCheck(check.kind, ratio) return (
@@ -478,17 +483,32 @@ function Contrast({ background={background.hex} />
- = 4.5} /> - = 3} /> - = 7} /> - = 4.5} /> + {results.map((result) => ( + + ))}
+

+ {check.kind === 'text' + ? 'Text thresholds apply only to this assigned foreground and background pair.' + : check.kind === 'nonText' + ? 'This checks non-text color contrast only. Component size, shape, state, and other accessibility requirements are not evaluated.' + : 'This checks one adjacent-color pair. Size, area, thickness, visibility, and focused-versus-unfocused appearance are not evaluated.'} +

) })}
- Advanced: all-pairs contrast matrix + Advanced: all-pairs text contrast matrix +

+ AA and AAA badges below apply text-contrast thresholds to every + palette color pair for exploration. They do not classify non-text + components or focus indicators. +

{analysis.accessibility.pairs.map((pair) => (
{pair.ratio.toFixed(2)}:1
- - - - + + + +
))} diff --git a/frontend/src/components/ui/StatusBadge.tsx b/frontend/src/components/ui/StatusBadge.tsx index 9a6e265..9aea53d 100644 --- a/frontend/src/components/ui/StatusBadge.tsx +++ b/frontend/src/components/ui/StatusBadge.tsx @@ -6,9 +6,15 @@ export type StatusVariant = export default function StatusBadge({ children, variant = 'neutral', + title, }: { children: ReactNode variant?: StatusVariant + title?: string }) { - return {children} + return ( + + {children} + + ) } diff --git a/frontend/src/contrast.test.ts b/frontend/src/contrast.test.ts index a1c4281..90f9eab 100644 --- a/frontend/src/contrast.test.ts +++ b/frontend/src/contrast.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { blue, red } from './test/fixtures' -import { contrastRatio, pruneRoleAssignments } from './contrast' +import { + contrastRatio, + pruneRoleAssignments, + resultsForContrastCheck, +} from './contrast' describe('role contrast state', () => { it('calculates WCAG contrast from RGB values', () => { @@ -10,8 +14,38 @@ describe('role contrast state', () => { { rgb: { r: 0, g: 0, b: 0 } }, ), ).toBe(21) + expect( + contrastRatio( + { rgb: { r: 118, g: 118, b: 118 } }, + { rgb: { r: 255, g: 255, b: 255 } }, + ), + ).toBeCloseTo(4.5422, 3) + expect( + contrastRatio( + { rgb: { r: 148, g: 148, b: 148 } }, + { rgb: { r: 255, g: 255, b: 255 } }, + ), + ).toBeCloseTo(3.0335, 3) }) + it('uses text thresholds only for text checks', () => { + expect(resultsForContrastCheck('text', 4.5)).toEqual([ + { label: 'AA normal text', threshold: 4.5, pass: true }, + { label: 'AA large text', threshold: 3, pass: true }, + { label: 'AAA normal text', threshold: 7, pass: false }, + { label: 'AAA large text', threshold: 4.5, pass: true }, + ]) + }) + + it.each(['nonText', 'focus'] as const)( + 'uses the 3:1 non-text threshold for %s checks', + (kind) => { + expect(resultsForContrastCheck(kind, 3)[0].pass).toBe(true) + expect(resultsForContrastCheck(kind, 2.99)[0].pass).toBe(false) + expect(resultsForContrastCheck(kind, 3)[0].label).not.toMatch(/AA|AAA/) + }, + ) + it('keeps only role assignments whose color still exists', () => { expect( pruneRoleAssignments( diff --git a/frontend/src/contrast.ts b/frontend/src/contrast.ts index c6accde..d1cda65 100644 --- a/frontend/src/contrast.ts +++ b/frontend/src/contrast.ts @@ -13,6 +13,21 @@ export const paletteRoles = [ export type PaletteRole = (typeof paletteRoles)[number] export type RoleAssignments = Partial> +export type ContrastCheckKind = 'text' | 'nonText' | 'focus' + +export const textContrastThresholds = { + aaNormal: 4.5, + aaLarge: 3, + aaaNormal: 7, + aaaLarge: 4.5, +} as const +export const nonTextContrastThreshold = 3 + +export interface ContrastResult { + label: string + threshold: number + pass: boolean +} export const roleLabels: Record = { pageBackground: 'Page background', @@ -30,6 +45,7 @@ export const roleChecks: Array<{ label: string foreground: PaletteRole background: PaletteRole + kind: ContrastCheckKind preview: 'page' | 'surface' | 'action' | 'border' | 'focus' }> = [ { @@ -37,6 +53,7 @@ export const roleChecks: Array<{ label: 'Primary text on page background', foreground: 'primaryText', background: 'pageBackground', + kind: 'text', preview: 'page', }, { @@ -44,6 +61,7 @@ export const roleChecks: Array<{ label: 'Secondary text on surface', foreground: 'secondaryText', background: 'surface', + kind: 'text', preview: 'surface', }, { @@ -51,6 +69,7 @@ export const roleChecks: Array<{ label: 'Action text on primary action', foreground: 'actionText', background: 'primaryAction', + kind: 'text', preview: 'action', }, { @@ -58,6 +77,7 @@ export const roleChecks: Array<{ label: 'Border against surface', foreground: 'border', background: 'surface', + kind: 'nonText', preview: 'border', }, { @@ -65,6 +85,7 @@ export const roleChecks: Array<{ label: 'Focus indicator against page background', foreground: 'focusIndicator', background: 'pageBackground', + kind: 'focus', preview: 'focus', }, { @@ -72,6 +93,7 @@ export const roleChecks: Array<{ label: 'Focus indicator against surface', foreground: 'focusIndicator', background: 'surface', + kind: 'focus', preview: 'focus', }, ] @@ -106,6 +128,46 @@ export function contrastRatio( return (lighter + 0.05) / (darker + 0.05) } +export function resultsForContrastCheck( + kind: ContrastCheckKind, + ratio: number, +): ContrastResult[] { + if (kind === 'text') { + return [ + { + label: 'AA normal text', + threshold: textContrastThresholds.aaNormal, + pass: ratio >= textContrastThresholds.aaNormal, + }, + { + label: 'AA large text', + threshold: textContrastThresholds.aaLarge, + pass: ratio >= textContrastThresholds.aaLarge, + }, + { + label: 'AAA normal text', + threshold: textContrastThresholds.aaaNormal, + pass: ratio >= textContrastThresholds.aaaNormal, + }, + { + label: 'AAA large text', + threshold: textContrastThresholds.aaaLarge, + pass: ratio >= textContrastThresholds.aaaLarge, + }, + ] + } + return [ + { + label: + kind === 'focus' + ? 'Focus-indicator color contrast' + : 'Non-text component contrast', + threshold: nonTextContrastThreshold, + pass: ratio >= nonTextContrastThreshold, + }, + ] +} + export function pruneRoleAssignments( assignments: RoleAssignments, colors: Color[], diff --git a/frontend/src/exporters.test.ts b/frontend/src/exporters.test.ts index d1ae1ab..e215851 100644 --- a/frontend/src/exporters.test.ts +++ b/frontend/src/exporters.test.ts @@ -53,6 +53,12 @@ describe('browser palette exporters', () => { expect(output).not.toContain('