From ee42c979fa4199f6e39589b2bfcca404757844f2 Mon Sep 17 00:00:00 2001 From: John Crafts <5889731+Artsen@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:54:02 -0400 Subject: [PATCH] fix: align trust-sensitive analysis behavior --- README.md | 12 +- backend/accessibility.py | 2 +- backend/color_suggestions.py | 148 ++++++++---------- backend/color_theory.py | 25 ++- backend/config.py | 19 +++ backend/main.py | 1 + backend/models.py | 7 +- docs/api-contracts.md | 23 ++- docs/architecture.md | 19 ++- docs/assets/screenshots/contrast-dark.png | Bin 0 -> 142773 bytes docs/assets/screenshots/review-dark.png | Bin 157352 -> 165478 bytes docs/assets/screenshots/suggestions-dark.png | Bin 0 -> 324269 bytes docs/brand-system.md | 9 +- docs/color-analysis.md | 44 ++++-- docs/dashboard-manifest.md | 8 +- docs/persistence-and-privacy.md | 5 + docs/runtime-configuration.md | 12 ++ docs/screenshot-review.md | 2 +- docs/troubleshooting.md | 5 +- docs/user-guide.md | 26 ++- docs/writing-style.md | 11 +- frontend/e2e/screenshot-review.spec.ts | 10 +- frontend/e2e/workflow.spec.ts | 81 ++++++++-- frontend/src/App.test.tsx | 58 ++++++- frontend/src/App.tsx | 20 ++- frontend/src/api/client.test.ts | 33 +++- frontend/src/api/client.ts | 11 ++ frontend/src/api/contracts.ts | 24 ++- frontend/src/components/AnalysisResults.tsx | 12 +- frontend/src/components/AppShell.test.tsx | 6 + frontend/src/components/AppShell.tsx | 19 ++- .../src/components/ColorSuggestions.test.tsx | 15 +- frontend/src/components/ColorSuggestions.tsx | 14 +- frontend/src/components/ImageUpload.test.tsx | 26 ++- frontend/src/components/ImageUpload.tsx | 6 +- .../src/components/ReviewWorkspace.test.tsx | 58 ++++++- frontend/src/components/ReviewWorkspace.tsx | 52 ++++-- frontend/src/components/ui/StatusBadge.tsx | 8 +- frontend/src/contrast.test.ts | 36 ++++- frontend/src/contrast.ts | 62 ++++++++ frontend/src/exporters.test.ts | 6 + frontend/src/exporters.ts | 15 +- frontend/src/limits.ts | 2 + frontend/src/test/fixtures.ts | 4 +- frontend/vite.config.ts | 4 + tests/test_api.py | 41 ++++- tests/test_color_theory.py | 50 ++++++ tests/test_docs.py | 2 + tests/test_runtime.py | 17 ++ 49 files changed, 886 insertions(+), 184 deletions(-) create mode 100644 docs/assets/screenshots/contrast-dark.png create mode 100644 docs/assets/screenshots/suggestions-dark.png create mode 100644 frontend/src/limits.ts 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.  @@ -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. +  ## 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 0000000000000000000000000000000000000000..be08a4d11586e45075fa27f0160298c2e100b661 GIT binary patch literal 142773 zcmdq|RZv{b7d{G;kU$bVxVyVM1PE@yoe9C+-2;K(?!i4+a2PDOyXypZcbH+$kT<{o zcW%C$b9L(Mnwsj`d#1a0_v$6jdREvcC25p*1n=PB;80{`KB~gOz0QGyL!fx`5_U&Y zb!8F`4joSRqnNtKkE7MM`dHgg#IsQ2W+Mz8bySs3EcF34ctxR0ImO9DEXek^|Irg%Hokzn>iFSJqr`Ko;-}^!St6b?3)gum^_te}2!%Te7SvEV)T{vp=`xxO`rrRqwg5wg2D;M2_@&cDHJ64?Lmdj5{UIl)W zWL;QJU#`vquj(J-F6DUWKI9YT4~m_SOoBeTLVmggM=0eRoPOUWRXDg`O!qq)!4VFL zJ>DSd(p8QN#`e?iVG#j;bc%^-r8?AIyPni_Gl-Duw=E0|tKPc$J?ST^2@_V?@i>zR z4}*g7& 1&vj@NU z#2@ jOq_?4iy??cE!puTiSEJ^Ao1RauT<-%jr7gE!b4aadoyG}Z;C hl~!cRqOa|MBmFQ&||ve@*>yhL33_fHa*rn~cdza>;<2{HJ7-X&C` zk&teDx-aI#c9x04Av&A4ZSK%u)FP2(Pve*=C)>I&A}+OOI_9ZlPWW?MrUP$=--2vu zYi0muq9adYbLOP@f@`mOGhj$*YoMx6c5f>=&)PvFp-V?KSx8QHd$X-o@IBv_=E+|y za4H$AdESR4J2tyoZU$6#-NLf7nVjyna_w-dsER2m`3(pJzJC2v$f^a$LDgx=0b+bF$a3(eM?N9lE6-q^toz*)gdhhQpJw`m036?FM?rN|> zWl89kbtDW=5?(;y&>76)%2ozB{>dhMGG9co5WZNr*b?2^Bu=u$t34=h5iS4B(R?ed zzNFuB;v(KKaq>o%W1QSTb_T6e&OL3vOQM5f>Vp*wKnnYqK wd|)asFaL0p>F%}m z7IOF}w_Eh7=~6=#yqMwq*m`g3Z9RU0)UFWLihXWirgSVkxDPwdAoItrg4M+idTov` z(V4{{+SBjLHNua!gXjKop!kL94Aj*MfyOJr$A(N8aYsi_rt)rB25#ag|l4+D&Z| zUH%NVaYmPcRk-|PJ7k#+UpVa>{fz8soF5tvC4lvXi|%H-=x)r!I4q~$yP8Xa2Ct@= z_xbHSL0eO8&j1wMgisp4Bu$8b96Zr@!i9UL_vDa1E4=}gqW+F2V};%x#Dj*MS oV#1_uqnt3)jPtjqEDb2pb!wp2&EoF= GN^(hut+sYnxU;03&B+mUH^Aem0?j8SzC zu8HL`VUYzcNLs}_YrYnC%plhZ@{RnRs_(6iN_{8t%O6j?(l$*V >y%MV-b zF(im^m3b_;Kk{bGyb}CgWc4B%*!7WDSD!=}EbRy%bw9$SELc`PV?Oj=Z}En5 H z!`tf%a$}T-XgP?3)uFr4sGtkS!Gh%BNw-1A>cfWWKuSIFD%tKF%i>4CBlJ+nR?7VR zXe>c=pXhR$=G@1zl}yAVgKu;x`y} &i>v-8i@Dod&|= z&cW|EKSanVQW7XI!;UAEtZ|)?R80a?XC|6nayly^h+Yd^@*Mtt(djSVMp7$qe7EM< zMCl397g^qdw5YxRZj`MPojqP-(OXKEp6?^MT}UcQ@6*d}=5)y}<4dpTDRSZ=L*i(4 zeSd4ulD5>vmie^O5Ya2_GN;5t;Yi=_o KyTsh<+%1FnM7*yl`xt;;h2|YEow7(K9x)~XOc~AfMM|jSa+GF-o&H5LqRtf3 z*eOG1)vB>5z+=wkg%eu3M!S&)@K!oeqY(vOpaZCOK3_8YiqGnSEyt=Xvr{xnID(kV zV eDA$q zNJkpu-bQ10MDTZs+;h)A7B9#MI8$We1h0*$l#=%?Lg(rZ+bK$J`7AZz$A8Af Dq%fuMGog9Ag|@9ntmA$q<(V}AUMR-)=3*U0|Uv$ zZI9)?Xhf^A=x0)7dHHz#q3EbTj$sz{HDbCHIXtmy&>A?=DYG3fPox?K-j5>I{OK~@ z@OtRdFk{&1v2fJFU|=5Hs72D!8`%Es^7%g8tmTKvIjQ{89=O@F^|B9XBkky?`|wV} zYZt<36dhr|>hi^2*+N21Y67XW;mE7%<_)T)C4pW^lpDNNUBXo^VFSJ!iPK4AkvpfS zT~tk_=T<)xU|a ^XV$^JR{gBt)1= iTXQhu~6)W#s+E__1b>-Ro}?iD@9I)$gAK9;6OF*zcoA6AZ^n z@!mik>^qnS7okg;5M@XH^o7T7@oBY-rTV9fw?pQ=3eX&Jk?jTT1M@Acoh{r1f=$sO zl4D!Qy{fbTq+RQ<{Zwa2goEAK%Ks|A;W$bOqU&9;PbdQ&7i@m>8n-f>38BHUk(Ph5 z>j58B{avM`%04zbTkE8o6c %(Dk|KDOICYjv*2L`Iuqf zU}Gs3_A#%ZU~6HaNv&9^!{<7DCTd2@@)(pod7s^;!1*o%O(-pd(B&XodMm-P8ya_w zUTv}ZwLrQiNYlkudO%{kS=j5&rGSZ7%E!qoG%mqg |D!Kb>I0!&e3xpR!|a zVEln?Rlp8;aW0qg|F8f7mg1^1PbV)iqt}Yal^|n;U#%{;u40a|vW|nZXIz25a=z|` znElza;@2ibkS u5-aOudBX6DKU7ZbL z1H^|clWFWvF%gKG?3!ATae5`MP$sczO2Q}oJt*cOwqzC?ne}I3p{a$ TJRm-K@XqPfG0N;iDmV_{Ls=fglpK{H$!G$^Xf zaA<$Qp@HR5Sb35)$2pHm`9oo_Ynr zA<|px5B7Js+XxTAJmHi&837>==BGvV@+O 6*e j+9L7RIb08=S(?B4r#idl>P zVpJ5`obew4Po3}+)5>t7J&D!ZligP_U)iS6iyF3&8UT&_i46sp8dk&&XqZ<0*Q@+Z zm%HtzN-Gjt(79dGYPM$pljDW_RQ-~m2iI#0K@Inz+X4?kYKR)0wEexkLKHU4FmvH@ z{l*|l8{?ikUR $H-9O=t-$M{X zLJbJnSO%#q`2_iU-)9$))UCd+vk{lx*TqI;o6UASm{GM{)dv6o9LsiBF=Bes?o0_w z0oJpwBSj$Z7(@0E>#BG>!qJ1S^fk>3BtF!$)%PYeN5ZGds@7M~+sn-Q?jz)_`RQtC zxj~2fb{nsmvZZ-;K!+c+DSxBOCBe(vzIE?r4!V|ECGJof0UGQc_ST~}nRNU;vg&_L zdRDz_UO)Fxw5AEOY&q tI|A3c%gf8~ z-k1^UsJAe>6H0zr(v_5-6S&^X^1CtbRZs)AXtE^8)@ +0o{e&IV~oANc9Ps(+B zxJonXJ##mb_pb<0pV#6`Nahhu8{0isDdI|lt!3f8+r&I0Bu0b@>bTtC#ue;nHJw*= z8!WTQOd?vI)Nm)$-tKT$0H7irAbI*S;d<5gb{M;C1p#5JlNA&34m#L=+`)Z^Yu_Mu zy2sO*yizn79J~5N{&B@x;yn_Kj4`TtNzb5P`E)6d)K( fsp6+(UAlna99WPnakTWvsO^%%6$_1gK`K8e;vl2j>uWRofs}DpOf8Uh& zYY5iwmMwetWQI_^h(>-oQWh;63ui# CECR*dk9Kv +$?- z&Kdyv#0MB*kL!(Cd--{KyB`FZ^d5Y(vx Uho+Whm^T_L_k2YcHmP&u_tt3MyG_bC*0NegUS`6^&_R(y9tQ+^~9qQxh{$*3o zDtv*W#np8MgWhOl aTD+FEr!_`Fe^;hyhl(CZt2yPMD;R^xn8L62gABL)ql zb@)X9f+10w8#;o% HUQNGbRH)y@>y1?Mdd- z{Oq{|Gc&P3kcH9Aq%N5qj#{2%UATNWNe>tV<4R3br3aY7>}igXeuVPP30-9nVH3)m zUl0O##+mS`YQ4(^8X8C=iH=~O3eVUcrU TVi-@z6B{NZT(T zg2lVjV}$JNNq(FyYw&Fqp$EBTrUVYuzfb&Ig1w5MAr^* zg)nla1zEAykBdXzva+U|(c}7~ttX!<4hb{OXzTeh+(kxSgm3*&;Ea=Z@ZFimI-cP9 zHee6x)vPl}V80?j*@)PX57Q(-mR{N_ zjDAn#^71R?Y!RsgxweWousOixSay-+!=X?c-Orn6ZON|T)e!^ts|0Zy#`dTIrzcaV z{_?kg7XK5({YdMYb%A9srXQA!@(ccleMYvj%*)REfme&fYqYmeWp=WOZGXWaRvYU2 zAZzZxlEL4{wF%C^#CDiZ)vPPvWU&?$u|Y0beuYxN_{WV20tDXqiWV`dD2jh-GT~w^ zdr`((eq4N3y}CDBTzPV<0kpstG}XM>Bq;woQMie(@HylL*{6{D* WKhxK{*@}7DRyVI&5QZQ@aTQLV)L0x<{q`~A8VsB9x zfi0PJdLTHgBUsqrZfiXV@m&1N=h c^9*;Kn2l?L;b2B|-S8(Dw0tD$iQPsJd<0(%d~ z(C}b}bnyFREiwMh&cS(FbiSB~-MCdacsS3}8^0VH;YL>j{+^T+U3ot=rQ_;yBm>y^ zc{-14?Vmbzw>>Q)*gMW>khvVdp5GsdzobXB`gtoMy{YZd&5{s@(G8$ZLU%Y=m_xfU z XAU>f *S&vv_$X zFY0q+Xz#B|*|_UHo`0S>)p{8`$bhEH06xmUZ>c=rZV%f+EXF}O Mo^rzaLG9v>aAD?jZK;j%w&57{IZd!=AI;a%gtk%9a YX@~s$xDh*048SE 5hm0(Nl$ zToLeX?N_s{zl7$%oGe|gjhzoMvj0gW+-U033)C{4u~niza^Q=o?>PHAXSv^W5dZf$ z3HBIq`L<`%4vvdLS9dA%Nc#PM{aRGc_=U^Kb0WT+7)V6_V)sS*zxz4Os)D}*Ct< us-$F0{*9Uz+(CLW4*QI~7+1t; PkRjsS6(#qqeP^}qpmhF3)%ilm zNkR1&zik7<5%pTI>)%cf;+B~C;Bw}Ae&8M7QoaaN<~78N2ah6V1pV7i;LpocZMc%= zlZJ}_?!e8wuY;-L6(L@tyPw4ck5T2lq2Q77ma=}<_F@~ADqF2$8N}CqFi 3S3WF+-`GNiV!5}Kq!MQOTetKK??()K3(?PxF3hb&A~#s{g3{lNUt>G`D$%Z;=;5pdFFko`g$JQ~fleyIui zJF_0dA_f*E&WDT^&HO66t0dn8zCyiMoY`|DzU~!A=LcjNM=n?@*qASWz0+e{43Qpk zN}k)2Z&a(v$Zn@x{WWV;Fs^^zcbdiRbr;sJuopO%BFQ_@Idv>r`&$k8n6C#)Jo4)i zoJ#%ro<>+Yo_PbZ(YRL)=9-2|wo !29SkQ;FF{HCKmm8Xu@;U-HHoNO zT^xsM%8vSs5dW&5N=EXxGbE2DKz33 &y*q@f^T_*H&f;?j>s#-14&j@B?DNF7hz?AFL91Md zT8^oI`X#4OT3{)t^e{ B >{kw`Xk!M zE8@^{M1QdCUt!NNn~*G=DZf`p+I@8OsZUNPIxA}7_QW0mv *t^BF(>* zF(BM%vaFGnI_t1@$DA;!OFr@P;!?X8)iD#Pc!=Mc_D@zkvN08p$J{cPqBf5*{WPMO_n_~h0uy1;A@Y}D$Zq?*+@ 6nNk2@VO=}JNKQ!c%1PPJuEH%~@ wQJ6 zb|cd*@0Qs9B&q_8*k<8055=?WjZc%?l6YFr-W*&`ANn7 NBG$Gl)H8nXnIT3QU11tk%At$vSI0Y)~ zzu4yEq<-(X5t!g!dgCTq0#xzfR*L=xZ<{?Oi?U-J&EC!O` zFI(w;@3Mbf5o(|4H290iQ#cVDCK@-0$=+fXUH5NJYoMCSxhH5Z-l*|(jGW9L8F4Ax z!scH>FrAxKXgb(o3lg9Doc~C+i{s?$_On57gB=nm6zamg4iZCRdS1tXr2shTiUBl~ zQBJ3thZ5z*#boAU1eaxuI~reGUGqaT%Z--pQQ{Z2X?)+kLs0_l?X)eFXh8-`w*5D# zc22{Js2 C)LKQxbPJiAlTts{s7Gie!Rk*}*jUu|pR#6(y5me`q(=ACA3yt1=Fc`i*u zen@jZx^^_@OFRu~Bc{C`FE|f^S7Ot$2t^LXh9X u0PL z?^TqBZQ)lH&2&ZhrtCXcvP>xjYjPQ}+|_`Y8yEuKK3f_<08PQeRyXxX)u-*38v~Q* ziw?y#p)&*0iNLBfQ1L<3!#OGn?P#G<+?&~M#^=?7`xIujDp^RaY`@_mNKDLpV@pT~ z|8Tl1Po4Cmt1BLG+uwonU44+j_%`AVt!_uPJ1~thvzXVdJ&F $Ls{I|J(G)+BL=erb#LD&fSF#fTl7#TS3YNywY+lO-V8TfKIT<7GWk0ZzP4_ zDUm7=x}T>i;&{=x`*x;LQ`mG 4C}$Jl}LQHbF{Fg{I( zR~e*8$ZUOoW&CL&HYvYQ*wk%Y*Vfyt5NszG0tSD+PUpNT%MkA|_^foPrP%^_yV`Xu zf$AgKQMkQ9J9Z(ckv4mxgV #vl z-cB0&mx33tlxu3yr)C{kN?D1{JjFEu*DpPN LQ2>k9u<`^0k z5Q*?_Qw~L`Rp4N08` HqUbswEkl!iB0 z5pnP0uDAUn`h^@OW|WpV>JQTLx%W8C%mAeLHy?sNhk#|6MWz1pFVvN5?K;JRaF==e z2G*2rt)&?U9~=7P51P{)dTXbfUbN1?F1+M;CSS; O9H$Ro?n~_dNW~Nk8e8WzaOal=;-4hHR!|G{gvfKZ%CYMT@ejz|F z**`ZJaLKW&a6jTf(@mSIU00tfFfkxQCD)`7$+uLH!zPw To%~Zpc0{jX=e`E%ci4Gi9-eB=Qn8UvetJog^yQy>S#Y=rPFb6%z0wd zeu~#{i;MFPtv_78`Nb3|@No2syu)C0h;3Ktv3=UYdD6OfX7CYy+7Kh5Q$EwZywR1D zLFThrJ8BI5pM}fhBPX8}lhw#E#?LsXLOPyb;7UmS?MOr&U{s^;c7LZeQ)woQ5c<{9 zc{wRDk!S%!nm~8IiR6`f2>ZW!$@J>KKj4(c{O38s0{B;aa;0}4wf}Yi<4W70|FMWf zy@z^#|92 iT$O#XAIxy-E)`BcBLQ&Ydnsm_(A z`4<@M$Q{G=A0n$$=3-2B#r-#ri65oJe>~p%mo_y8z;V%h`_` ju;=;ah!Y{%j-aW%FA2^=@vLJf$kK?~#9~pW12spTs7%9oL-*nP3`Sx@}!#LY! z>!;6R$ffrTZz40>5=$6YMze&%!Oh@BS?>|1?>Svv)_kMxm3)pebM8A~FfEFN);JtY zPn8&4RlS2Oi$9B6dZCwXXKq=UwXLx{tszwpF82D rp)0NQHwrKQC zlpR%@m+R)ZXf!DbL!TrbmnhIHWRDh2V{+X=!O|s&i=RuI((IGE&8JVXvg2=GS)oCe zyC!+xZ<{?wu>odM!xNFx2l36G5%&PU?F+Np{z)_#_xuhF-BXb6=Uv;|+FB{JKB_I& z>p;qZz7Oy+|F4Lf>lJ(g4v M+Zx;U3 rvi_vGWv J`@CQX>Y=x>k7z_- zH|5(^QCnpgmnL6UZSDF9YM0&a;`e1EVno5nh evSQeM_ 7e;p%EY Z88n5C;7ea{ z@6~PAP&Kr C_WEkqWC0#c*Bc@%kIn21ghmF#@oQNb$?u9;UlPq_-5z`d zH8Trib7}8W{DW%XxWo)xRjRA2XJ@xRf*-4r8*W-(KN tQEVa6w2vFR %BT=^M!u?ue zggfP@@}#ipERs>F_5BDr+-7;$D9_gi_!*6W&F$fjDt>;CsKj=wt&Zniiv gHxyZ?w*P2q(y@b|AN58b#BoLTN(ii-SX%B1ruo^Ks4Jf&s2C7paapKAs@C5xOF ztC5z}{DS>~sk5okL<`qLBBHJ!maZ3F^45nx^5z2SG%xv9Od)t=_IfMDrGX=!B3q z`c_%Z;rhQJxS76M2iesM`tJ=KAKgHty!JBnz!g66q0k<4? J;t!JPwHit)!yjz)dLOSM^$6(!Cba)ELW-Y4l~wrNPp)tQEGI`R*D5pc z_xJM26(=WiK}sg(=Auoc=bLk zpO&ja#swW*NH7XTP@lqLf8~=y+AqL2spbduXIeecitgJ!cfpN9MWiA#o{L)-5pY}r zfGRyur2%Z?Xa&8O8*FcT(P)1nNrWJ0{(($F2EH@vwNpk7_Q)oz`L*meg6zEFK0P2) zJ&aEHr!X6cr)S5VqUpDrM2Cf8JSLN3@(x+!SRyP)s4K@v6ieoP%@^x1Z1)M{LR|$m z-`(F+BfT2EOnnwy3v}HOGa*%Ta7wwu!IbMHZZ@@iONsVH`3=z u*#Y!h17Z**#{o|@PtqO*amUuJP& zWZtaG?9sG~?&u)*6DjoT$(4sIr$?*Bq620Y31b$3@BZT nD7 z-QC@>sZDIU&W%34zw>@Lf54e@~C2thv^;)?hP2q0sc6uD*us!Hh>SZMRz% zlMz|yL*S5a>>={+ZBkHcGMr}1ovps3Bm07Xw!W3ofR(w0MTtd>VWX@G;hR_4dZ-@z zdwb@F7K0a;IIO0T+7X1OG8LO{a5{Czc(;D#IO7nL(abh`ok~KHSKp*|yF>BW%x8wv z9LADNk29V0$PsDG$Zak*86@D{9JQwV4Tg%YS?O%Smnk{RM1r-vw6rFNHv~JLjWjau zpGU+!WfvQ)Jny#m{el={N)~>Zk9jv<{;p{4AOWYUl_sLb8p@!W(W1?1?Y15)D4574 zGn<9dX&$#;Lsw{PD4IxaTiTRFM9zucb6Cd~9NDJ=!J1_cGZIx`ca(tK}fG_~is z>2Xt&BE#XxpVm9vS8uZcSE>_1z;j_bTE-tkVR^TWM@dOpLoOip_552&Dfmw8L6$AF zlorj#AzXf9e(9@Fu!WfPSr+Y ;Z_&%k~suo8xhgF*V9|j*jvQ3S}X?-8_6ca&n=MVeTqp1v6!^%y_HZu`Qm9 z4vGFOrexmtlbxM~3XHXycJXp)^;A4OCfm>x`)#T+O;+Bh*jQnce({st^}Ft%iG&q* zH?lW5O-mi*G|3~zk}qfvlq%K84zz5m?9AR7jYZ3u{SdyXF|jIZ2()}_V;@0iORHXT zkZ&6!i2Efofa< Ce?sItk!;rLCZ**$bE8J %+lWz*A|juSdh;mWVyqmH <*>LvtQA-PWfUaN+400efgn)x`Eb27F zVAWtB|BdU7b~&G> +9SA~K?K#4Cv7 zW~igBbRC;ib8#h5Sz0r_!pzufg&d!hrKmLhIX-hx5F=nIBh(^+C99(3m*bS1 j(O48>COO6a zVFhXV=xRsB-pKo~O&>Xn^F(oPNuVx+O-Tt|oTuSLzACWJD?hQMVl&2Y|Ee_-K_fAf z6t3Lr7H9pG%}VtBz1%~oyH#v18O0f3c6!1ID{b!E$8wfa`1fg*TI1<;+Sw~L8Xa}E zUL1{+#!!8Q6N|m?KP93D%1fx5eETLBvT(cxfh;#m#8Kh^Yw=46S=dr 73J$Zs2t_Ca31u?UkjMm8;c&HP6^1} z$j%s11F)jE!U$-BzeGxdwt|xb@VVTbAMO}UUkmGZVmX|t2QN`yd|n2vCKz9pKS2&& z=7nbfUXr dE^V%= zR{cr<1s=sUffZJhhu|irZzRFtqifpf{A_RFp|Du)VYFdL?_>Xg7jtFAECr^ty0nNV zUhB30LqFgfkz*DwD~jeWSszJ=$1>hNE%1*|(eLCn(~oaUN&9LgGMyqC>+AYmA?P+8 z&w9cM%p7E<`Fw|?!V;u9M~ounXfrPZ%-nOGAWnKM?wT#js*3y;DqT71WrMGcKNDzG z?CgS@PsDc`il@}2S_ox0l%^H)Tun nYU8LC;c#s zYnr6dN_5Vz7ZoV0?3Te`*Q
%?6>kr8Gi;q`^^U8Ld*rK?Xw*w%sbyBYxM>s><0D&t;WfZ!!UI1B z`zy5EY!^icB(0hI`7U6?h%~5j%g-Nat`@q(9^K84feh1-UDoo2yUV@WnQ5V|bACP< z?%Ys2j}=!bEJw1Xu;^XRpR)^q!HmAHB*rv)p^pITy)}@~4^KMlc?%sbFQG(wqy6ib z1(*BP?ZFwvT=xZLsrNJr{2Sa7R(3@xetT-%+s_GK7F^$&q5H$NA(Jw@^kduvW0JB7 zUIre46Z9AiTOFec1-piyLy@vn_qA&uiuU8_a+-|CuG$N#D8{gPcCM)VcC+;87obBk zNI4`771}?gcTjP$z!L-CHg5b_nJbS{m`gvKrc=u6r87D34t=1d-GP)#novq`3vhAy z4I9F5ul96SMCfr{E=&PtAcw^aKR28ZUB B}0|*2tVLBilewi#Z7+zEj}$^+_z8chDl~E7ea1}cefvK zpNEZKq4eP4;ZS=_kHA_%8jiI8Oq;_q0B-Y|@8r+IT6%?xSJUTjvH0e@V7cp%dCd#d z+j6wIIy`7?%HuWfA6zjRPDds4S{NJC^w(f0ftX)XuFL2Q8@jOo@yFuPKa7ZE;wNTA z<`@%4CHWBC%&h;@Z~)8*P$-x%`IPu2$twMY@v#5 `)EldF0<8@f5 ztzD&*2#`rXG2O-znkk%Ak65bR1UBSY?EM}6mR?ccQL!SB6%!8MF5RUdeA=hs7eQR= z!><0Px8Jo?RX%>yi2Q>yCgZ47z~BlhDt>1eqlCdk+IeK|Jp9TtKnN8}E>t!u2f}WD zYX`Qs-yKulB22R1VE_4 0snVA`?*Q>x+EZG?u!^1KYBw7vJ z{`fjC##J`A3y{WhayCFnE>I7BMP%Unl|H2=qzFk%{{`rBfZzz}RG!sm^X}^PU7S5I z9(dOpL9jWKgHr68i$u{f{{xo*V~k3In9RV8H`%zLsh7w~Z!93`qNamEVbpeUKs2NH zs#PJu@$rsGGCV9lt0l>3Xy^t9O%oSZM=b!R`W`q%dqER%0!>*teZnnXbRzJy(&7BI zy*(-pte2LuipIytjIe3_r;d143qpd^TPmt`)IB_^4oVK%+uH%O;Q7baT}27fudFS` z7Uufq#z_eYnqmAqZ)U~(J}3&8K38PWhr{2>WPc9BrlO=o(m&8QfV%!4tT<)XrvJtK zM_yMSP;3L{O|b6h#K!1sXCh(DkL%=_(7 };f))TpA10Zl`UO55T>c`~bP~=K(G^Ul zg;%2W&YOQv=z?LU=xj;WrJ^K?i2f!4;yz}H^fVt>to}jc^-7H|16o>a6!lS`naT#g zLV`kH{}&lxRX49)UERgO!oouH>dq}M5A^wmYqR7#A|VqH=s*>|cl#5@G&H#WQ7~X| zwAI?yvx$wZm)k|irMLf)XJEX#g$`i?bV8Q>Z$9;~zxWq=5ZuUg#Kz AakD#<)=Z#D0j6*pCG-bmZ>(WhaH!g$aw?F=~38P`S;%B zr~8+j<|E!`s1S$9mOkf;9j6RWJ37TKqZMr@)4GQws}|mL(Z7AbWd0~D0ICo$ke*IH ze|%H?z?l3C!eOGXr;5G5Ha*eRSM6l-uU|PyK7O&h6!d!zCMGF~1WmKrpJ|W>0;wW; z0r2`3-llEHnn)^lO3g=yALUldcK3g+2;f^yC@Ii$%F2cs%MyV|uPoa`a_j1_+c+S@ zsr+q|orx+bp2hkiH{mN+EyYQ@bN6qJ<;ye$^f>V_ZK!G14 noZo}ZGZVP0MrlQtmy=NJq zUu;2 z4h=i=VlsG=Q28b<3?bEt?jDU!l&JfP$n0bkwjl8DY=HN?jT)1l@%Rq&WCNRlLUbjU zkBi_saP}8y#5G#^cCfcGh~m)gl3-;>{!#4COjY8;Uy2-zm%k#Bfpv*qqoB6LtwXZ) zFeQIw5*RGZ$2ZKek3F2)?5iTPzGbC$>3Fr}b2+2HeQd+eLdk8tJ1ci8#jtp;xjGMm z;>$GN_1+g_ZeTFM>+JNR^y=SpdbWRD;A+;6GU>T6p1&V&u_EG&!H!UP{HGL|ip6JG z_!k%Kgpj5mIi>d}hGkq;9)@LoG2NzL&CYEhmR~1=Rc2ObJsVT>$L+?0ogxDG1}Noo zo^M~8ig7pBo__qzy?wUA$S=?KRp4RiLK8J1ypPfOK+T=s=3TSX-okY58C9#^B||0) z`bc-{sMEXUj?KCI2~YsJkw# |KF`798&|>I_UW$6+&u?pF?yh{6atwAp##1 J?Xz=UO;Zf FHxwAPLNS35Au95& e>y`eUnEvkX9+QdKH!@HVM=&lpiEK@lAApUq)lIQP0rOGk z#$Q}aY&Z3<-IbHJv9SSyJd;P+?F#%v7o3qWf^T-gc-uQBK%@|8UKF{7g`)tQll3jI zXJKFkBaD&2gaIDJRqy0G?gj_`yElKH6TF|;vX*?I$n%@He`I=UiWnvgk&gl#_1|#+ z5J00c5=u*f-I0}no4WzpOZMkQn7?sr3<~hRT_Be04?%-0U_q?yj*`aw{any4&(SL< z^F1RUTN`kAgJDG$@9u^s=KilhIec9DO?Dk8P~S-d>5wy1XpA(HFrZ7!rTy3cM87rI zh_;T`ukW$w4+jTJ#YhwtiG5&zv<-S408Z1<;p1z5t^Y+!n)k0q;I& NHQ8Mk)diLZRp2*6cg?za5VbzW@DfRJ6{~X&*cmf|20s zzt})z6TzCu4taebN_g+G^YVwPl{0cJ#-}=h|4d!1AT}n &)3h z>m_icZQ7r+>V(&|&(DfN{ow?$Siap4r*DPx;P9TzpM$@AxLXR3DQF=(UkjUCP LXTL?OTlnAeDy~}UZ((KUr>2&wua!tE}t?BLmGf0^0?4ZQG?0T!acf) zIGwL%5sAjYgRj@B1w27f$Dg`*vElqYPxJYSgLd+p1RxAQ{C#6(^8;j!^Svf1gy4pz zSV_qh{eD?0*D|IqRT%-;GX){p)bX&t)g62_ jJH}EzkY3MHg7D4P$;wg*00Kiq*!*EO6+Amc zKOb_0N*VWthRJ5f@3RfX Leg3T~$D}m;s%*~jv z6AQP|UW=$@XRuQ%?|YrDiN@B972e_e133IT+ z3p^habjEVr#+(!^(?-Q>^ySgnQ(LKrt`(1ix(*K5(K+CyYQj8Ouxl9IYAe%{8+K|< zjlZN?DCZEVR+L;d8nMepGm~6h^69y%of`aE9;^;eECFgj({s8*33m8`3X6AgkA96a zBJfo1{Elj*W)&Rus4Mw$fK_gC{wsa@#=D2Zv(cWETY05Y=Oqi~FHNAdia~q^+8I<+ zHs5UJ(k+oXf6OlZR#Q(o*~^UU%e4M(lPYNt4c+bI>JtBBSJgWq{;^ue#}$*W;0u8; zqq8jLT?vH@@=+ZZr*jAo#rdJ&mm^()O9_qV751Kw0~s83eYTbND<&S_Iz8;5;PnyM z^`3)mq{J?RbTnpbNj=K@&;osR&ql% $gfIj5Q;q8aC zrjeWD@>HeJhgtD948C@-*{c8%$)qc{`fdKE*zS<8i689EQ8#uSaqDby+Pi}z=8GMo z`^s7RJtTlG?d+kc6+!z si9KXtUg*Q$SJw!>r jT;J`u3RIuGttHG-h zJQRJVE?|GeWsbw8eGd7f)1w$!FX6qqX{d>{v<}E-7Tr60fpnU%g0V29ossS?{+Qe> zMAOQmF#NH(=;>gQ%T$A99nQDX#WOby9_keyOoT|x;MQrM%e%_+kSKXyQGuH>? I1 zfPIo6EKn0Iz28h*OG_jp5WlAZlL_bG<*pCq@YuQ~aW)VyO?HkRTG;zE@IfMapdj89 zU84jWyfsk} d!M8-ll=Doq(b&2mHhs}L30CAaDz4YPP68)+waT-2qpH9 zCnhISv^Xm|=(6 Ub8)67MtTi>spsS$3M67E=TFptj|Z9W>W%<_Zw z2=iD5CiCFW61#D)vuEOdYR6`fq&3lcc9sO6vOpv$l|@NxdWEQEShizg-CF&~dq;+w zh{Sr`09vYa1~#dgLp!vx9<%{wsp1f@H)(Y!h?ngpIje5p&`BNYbi0R6$L-2qUn64j zI&y4?&89J@Bxew3oQhFQ*!7IN(zwuZ)S@>=C#KBg^oxd~K_ZXQRqq-8D487E6P!!K zcJ5FxoCY|aKrl#OaqXI=Kz3+wd_u_xaaY1#e(%1Qrb>&mJf_8YGRSwFrk=mdr#ea5 z>M`Aru+-}D{IK);3U}B^3ddPT8$rn}gGSYb_oZzf%T}c-sg`1Z67y-&u0;1#*BJwq zJFB8!S1bMi^oNV1dtHJ6I<@VB%ehD&B@W59p2vg}LOp4%pbFeQn%w!B!4CaZd||+n zibqvC>ojSai9x@wq7oX4WIIzy&!F@m^elrRUA<)?WPAkxRj{9akC%8cgn`D&-ga+t zE(u03tm~L4;q7?Gl8=YBiV79cQs(lBb8T)e*>+zJ4RfXD_@!LFNoUwgUC5h+X0;E^ zDuE7{h?c%kT&7fTF`9?@!oeDkND{@1Y2+7i!*nqxQJ{>mSB**gZ^7@9zF{FljK%ln zPTm v5 zA(5ho|14wfjb$5?TR0Km3|n0QY$~>xq^Of)Ps7V2zG%JXV3+J!__mX )AnjeeZB@lbr!*7J|UrG|t)X`I? 9CQJ z^QNr!YL50M{@RI8f^ONR%dMvl889et@Q-*n K#kZZoGba-m&oym1-q0YvG*E( z4g;Be#Sko@cKICm#}8N-bCKTClpsbl|JQhHr`y_<&m_Q|DOTt2c _+Faq?H_ivLNcA2j3vpmvd&oY+h1$IC@LE}lUhUL<( zJHoJK<(9HU+|qX+9x`una)0{4-`Ua J=e6r$$zKsC_cr0gv*T@u3dDKHC zjhA4^vXB7j&G>h{UHGEA|Exe{d{&b52yG$#*BKU+G=S(r{G(-VCH0b~Iqn*|nK)v7 zjy$FRkKLyYUoamG1@G=6<1rPLdNd9Wh6cJl0!5?h9YF(~oluug2VG|-!NI|jIj3EW zV=W9fj6XrF$00m_Ph`osc1R( TF2;$DU8t+#pzgy4{e5INdU6 zw%nvR6wh&4*pu$9`RuDdzMmTRhI>EEn1>!Q^7_Gi X7pbNKGz=7(Mk|3LiU!?8?8G0QN@t>d9}qY1W}hYeZFeGhFb?!%V1iSCLa6ONWm zc5C8sUx s+nzBS=c?mCVij9cXaDZ3WUuZJS)vkTI2$vywU#PbU3}2P&f> z?>f81GG^A d=mWuj=%)bX1=O^7>4_WMiGY!-tYfVz&-qjST|q zp;xa&PlIMrf(R9vl}~?q{Sh*-&-fR2!1Q=s!Z9A}k+{+JkLWdjvC-1f0*F$2Wx5P2 zjy@DQK|xwjt=j!<63;oCK%2{{jMuB8cjY|Z_9+J(qZ-lqsu5-#2b|k{?)=u@h=W_5 zV(uIS#3zeZ)H5!aIcoPGu5WeDl^`!y7!n+mY65q0YS~Ht`}RznJ?q!V-@XxiJ7j)_ zfxD5&-=(c7EgevpBUTI6W)Cc=e9)~8aNjdNy&6QB8A00+5|GQN-*nwoI+4(%@4mOU z2q)}w^)%~=-nSo4Cr>G5_h5IQ`o0F8P ={Ik&DQ`_NghmNxkj2K&d0gHv-O~hK>;bsf)Nc}3jO&d2Nrh>td@HFdAaM$ zxyxyn2;zTZXWA trR_pAc L?7DEIqV^Kx^qpD9)ZSL48=lC<&oifdylJj+soOPCT9W3qLZ-dglyZpX zRJFK`VZ#*!@smLPoqPTrO-h-65$H1cD}LTn!e%lna8UP(N*}`OpLUaR89cF&z>TYP zI2~_rB~??*`xW(SiPLF(kRU5}c+!Yo-K;mC29rPx7uu1g$2?}zN1UC{%%dUE>Q?QE zS~1Tr-)|Y@A14j;`w<85;oj>X?P})Fd5=h 2r4u@n9?8n-qBXT(_G>T$Zy9hWN%9*@ROJ z3#9GqiUH#Qi !<{zIVdMQOerpY$o8jAIR7f32gl@%< zzxoo1msc*jfc5*GRzH>8d!3v`d?P$P$1*94(OmK!BOXlRW9x5}y1~8Uxe^vBcpq {IMHq?MF&dQ6%7!uK{i>!L#cR0y7k($*$>gzToF zm&5Tq`c2wrfx(0W?2#3$FI=*kUqJ>a0tMw#>qd;Pc<*NY*9koaub-_aJwu(#+mHKP zL?wUst NQH8G;rB@$ zNOSBJ>tQBEe(|UW#-oRuE(zg7`Um=iLxXy@rk$>u$=?#t2IxSZh*eIqFU_J$2vx6f z=gKCd4l<~g8I4w6pu0sMnw8Y=_nJFAD~zs-PqZDAI0-P|L{6bt7T!+zg->xLz1q?{ zqxycS=oQ?M@r}@@O%{jVApUW!;*Ae|6{r{{boWz #J5%19d1 zYK5_Vm;-u %W#gu{tK8bxTTgn-pbttT(dMWxArIqyi@YsP8_#! z`)Ux@rAd_!D5MfpllAB571e0s9s=`>Qh{mm@)rcko8>;wx$X}4=v4iIn)6Ajv!2 eq*kKZEOK{f8Bz*gzgA{cTh{2MM@R)7SBFu4wV2C}(SUOF!;7ml> za}c;Ret{bGtgR9fF!!S>Z;H^)?&8R~_xg8a5qsr0Tjjbpxf#tB;V@&AN5+_YdsSHo zF@0w-a 8-KF`E_tZNrIDYDEmC+Xu;7vg1*gxEa!0#o>&wNPM{Uvin@ ChmRdiyA<9NvU%ZeN0{S)+YX?TkF6}~Z|4d S9&mJYeks%7 pwlio~OEkL@WOi P)lUop`-XRPxP_B>gynCl|&P z?I|nG1<<2;m%3=%Z&h0mF@+<2#!D$M%(EEvH(z^D)TT{5V{;w%)&+)qJ~X3rhLNJp z#WJLa2RlTLu9~jz6%Uhs9+BwcMlRm`9xH2bJ`Q^nvVHX-BrzczvdUzVOuGi3Fz1{< zA`dnW Qp|on6@>FO_@?cXRC<8a!d7- z^FT4wiE*S0)NJS`jIJ!n7MSu&QcK?%YQsvwmgnfNdLQ^2m$l8n2tb5cp96b HLOBE9B$NG&W|9+2 zEO_@prmHLdD>G4L$BfO?+PLFJH1f+VJjO6?5;s}6svn-?J9@ATh~>*0jQi0af7VJG z{vLz@X?h<-eq-gZ*>~-er}n__%TkSz1RGO9zN4W@+}&Yb{2X*8ccWSMqnRn}zqDrP zRBq_5)9}n2h^hQqQiLAXUW7Pz*z+L%N`ZduEC{kjx?YYk!Sp?81Y7Hd01=;Ji4nKs zbSLj@Bu+a4DoEoIEbS7Y_N{7}VJNI{`7-NZEo-xwwl>O_Z}lD{BU=~)RQGCH1CU u$ESkBTp`aUdLiagYL*!U85fuWhU{xs(1nK zO``jHnwJQ85oYwmKc_JBB~Z~o^23tfy7yO78q~@T^)&y#aTPWo6A%U_5O1RnH>8nl zb?gt5ATtW!awRkVORFr@0SG-W75=|Wvb2}d!R58*yua+vL;8C%2gokN08u=gKm8so z;~5_GgW6*(C_bLo@%fX?fR^dM*BH{DO->6AksrL7wCX#Z%H=i&**)|>OnGc=Lyzan z`+AlVp_;XicX3vK(g>ekGgW({f;jJ5*EGJOrq HX7_KpUl-0{nDl3kl5YHqQ2r2g1iFg@{@{@5d%-bE>rfM)AA%x 35zb5InVmTyX)#l77Ca! z1=q |0wQ|?f>tJ^S(7`tyf^W=I~3q)x{Yefy4Fd11tQw!-c zNE)6E32zmkNnp 2Q@Y?#Z0o}X%E*hQu6LX`{udsvFJjMHSh3PW;#hcdVHC%ClJ7G zTxW+_#kCV$6D@NPKK)N+1N)sJ)vLU^T*lY;7GG}Xs@6ywBC1GNdTLX4zocM#>?T+| zA4R~-<&6mUB=lfk{8keaL(7FN3?tY+)u*L^aVfDXx2?NyX38cV5S30S6@-M1=4G5U zt}NyJTb4+PabDvzl*`>A{zGleHW(2>kf7+{M{d^Gy8fV4$^O&${a;)A20Shm=sf~C zsdp=&2+2bitcII1SYfnjy~oTILj#_^MIawf76AE~kJzm?I?1;wpt$k5U>h<-wD#wa zkQX?Jw+sTfyp0R6cD6>61=D?(pgI{C*6$OWd3NyJL3oPBl*`K%wr+;uQ5_{l59x=Y zV7>-Q1zT>%LH(4)f$CaWkgcPR;OIbVrKU|uf#p2Q=zL-ztokJ*zcow1GF6H|Icda@ z_IT)Aul` Z4?MaJ$oaDHIc*N@+X6zyBiOgoFe( z{HJ|{Xb|yjSIXLK52z-BwyM+~6IPI9(->+PLAd vIW4H(p@rxvO7}qJW%FZf48}RRZu7F3rCp8Ib>Q`&~;CqWrGM(DcUAe#Y}md z&J2-qacsM{gutQHSJfO@E362ZY*mj}&V@g$bU*@Qc53RMp+fJNK_7YSghv<^!Yx;v zlUTkDWb3|cqrG!FNs93{Se!Gt$Y~fCHaYOPl0<=0_%vi6s0f$Y`7@ke+8RCX!4v5; z-WHXQD~o`)!s=KcF_9QwQEta&=(6vmje(WFe8g+OXG4v!Sb2)uIyh%Oj6Or)! M zihUK`4R7+&eV>^37Tl0?Kq2jxQGfID=)$nStlh;tVeF28sCtViwecuAYUGB#Bebma zwcRHjN7Uz;KX6MzUO6z{L87ChYh5QjNs*ZhNW-H6jZSdi?yg7w;L;xGz)=;;kmzx3 zZtqBoUB*wkY<|9%QKb)?d9q%U#yvU2nL v z&VfCc|27O#u%biq$?lrX`l{G?Tt*3H
WOhBl`2! z_ArYwdg!y-TK^WU_Dfc~DD9R7OyXbm_ofr+d`Og>YEj9>JuA!|CVg3FG8AxKsAx`O zCTQsJsG_>BuWbECautxfaOz#dZE3#!Y;s1X=NF%5h2?NK!k`p-OYhopyAkT@e2Yv5 zlPrFuLPBwmUK%+#n~qnrH{d}G?*6$+uoJru0_A*~34atY048sLIuankMQvRf5?B z31A=TvI|@#(_y`<>)iQ5*9-$3eQhiS5$rrq$T(Wl*H~iG^$sW%&bGxZ1>VM69`d_* zP0$gkquHD`8sHhjiH_bXRS3nrUGn12-Bej;b^;sT*5yD=9}Q*jXY9N)Qlgbbx)M0L zd2 d#_(>JQZoxQ0927yWNsYh)0Yv3k`tU )`>9gqEnabgDxduKU+5QH^2NZeZ0wvla z?}F4~2?*R(uc8-2&n`Ylzh1_qCW|6wgm>9zVyjHGEkKN?qg@*-Hyu%w^`LXkL}KJ~ z2l*Kcq8xu7Rw7L}B%s!$A4yO5cYNvRF0jzElFrLj0Yyue5^4s%;FF_`@xz|NZJz;d zikB|M8+%n=HmcSpeZw9d{{Gmsz*t3j@lURYo4I$=6g|CW^E8YCKXXOFQtT3rHLf`J z=RrHH1&W`vvhiKKcRkiH55HHQ#eT4UG@3CI*>>GWv?^Q7Q``CA)%}zB1-|HF;D!i9 zO1xW~Yi=*CSj#@8#mL8b58TJ}sJ@-EX~EN)cB}O^`hv<-&g%=p>2ee;ikh1TmNU&Q zcqO7r(rs%lfZ!E~?Q>+8Psr^!q9AD;;rZ9!QY{}Cw5S;PJa(5}e0{herxn%<*KtS; zGO{*x2*r>Lj*q$uv)2^roxffvCWKGzNc||r1NN!@hXweC-nk#WcYEM}j8pg1-G9+# zgNAI^P9OPN`7JJ59~>uezbPm5$cL|4PUr>EI?^XITzS8*sBr|<87(p+id!h&QK;%u zs)ALj$IMr|XgM|T`J4?dk-N{Mw2*j)&NB{_-rgoXNcZu*a>Gqx~tVj_-; z#OncF=4NK+&f2BV+?+hwz8t@s+pkL7+#WY0g#%Rwl%sp<4X02%n9e8M&Q%o1d$K!+ z0@0y^@dzh|OJn}?YYVUzq-{cN`Tb_M)AG7IM&Or+_T%i)#2^X(7@cTauqF*9pM0+w z&p>slv%H~ww&aM-CY^#Mp4(+T-vK$|W1u`4Z*3p?r#k3QpMer1<$(yTmF}qm1-zdj zmpgnVLGPKO6DJmCxY|4M!$Ts9*;2@$h(lc+p!fnKyyQOCJ`cob=g%O~@cVMrhjb`D z0f0a7s_Ql6vs z<_toMbYHS66DrS?UEu7EP51SED=NrI-!=LKE0x>TH9O`LkC1>kJgvIZ#>SqV5E*3i z@*5nowPA!v{*^`}<1yG^ xB!m5~DZRNf}5 zqV83_>9aEUdu1YQ8{vvPU1@ptwZyYTwY&8*nFupwozOtk+L4lxo$+E(QtZdR^wdI~ zfb76CjTQdE3ElO?F!pV(HR3NHonlj^vq{mb&beK$Q8ve4Om?}g-Szd$zv*YRidAwY zH?TD9WW5NaT}8&