From e4a49109d981e45a90547b1e3a4fac630237db71 Mon Sep 17 00:00:00 2001 From: John Crafts <5889731+Artsen@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:41:16 -0400 Subject: [PATCH 1/2] fix: correct output precision --- backend/accessibility.py | 4 +- backend/color_suggestions.py | 139 +++++++++++++----- docs/api-contracts.md | 9 ++ docs/color-analysis.md | 22 ++- docs/user-guide.md | 8 + docs/writing-style.md | 2 + frontend/e2e/workflow.spec.ts | 3 +- frontend/src/App.test.tsx | 33 ++++- frontend/src/App.tsx | 14 +- frontend/src/components/AnalysisResults.tsx | 12 +- frontend/src/components/AppShell.test.tsx | 42 +++++- frontend/src/components/AppShell.tsx | 48 ++++-- .../src/components/ReviewWorkspace.test.tsx | 13 ++ frontend/src/components/ReviewWorkspace.tsx | 11 +- frontend/src/components/ui/StatusBadge.tsx | 8 +- frontend/src/contrast.test.ts | 24 +++ frontend/src/contrast.ts | 23 +++ tests/test_accessibility.py | 27 ++-- tests/test_api.py | 3 + tests/test_color_suggestions.py | 107 ++++++++++++++ 20 files changed, 465 insertions(+), 87 deletions(-) create mode 100644 tests/test_color_suggestions.py diff --git a/backend/accessibility.py b/backend/accessibility.py index fb91e6f..6e60f9b 100644 --- a/backend/accessibility.py +++ b/backend/accessibility.py @@ -58,7 +58,7 @@ def wcag_rating(ratio): Dictionary with AA and AAA threshold results for normal and large text """ return { - "ratio": round(ratio, 2), + "ratio": ratio, "aa_normal": ratio >= AA_NORMAL_MINIMUM, "aa_large": ratio >= AA_LARGE_MINIMUM, "aaa_normal": ratio >= AAA_NORMAL_MINIMUM, @@ -124,7 +124,7 @@ def analyze_accessibility(colors): results["issues"].append( { "type": "low_contrast", - "message": f"Low contrast detected between {color1['hex']} and {color2['hex']} (ratio: {rating['ratio']})", + "message": f"Low contrast detected between {color1['hex']} and {color2['hex']}.", "severity": "warning", "color1": color1["hex"], "color2": color2["hex"], diff --git a/backend/color_suggestions.py b/backend/color_suggestions.py index cb98783..1bc4f07 100644 --- a/backend/color_suggestions.py +++ b/backend/color_suggestions.py @@ -14,6 +14,91 @@ def normalize_hue(hue): return hue +def circular_hue_difference(base_hue, final_hue): + """Return the shortest signed hue difference from base to final.""" + difference = (final_hue - base_hue) % 360 + return difference - 360 if difference > 180 else difference + + +def describe_canonical_change(base_hsl, final_hsl): + """Describe differences between canonical base and returned HSL values.""" + hue_difference = circular_hue_difference(base_hsl["h"], final_hsl["h"]) + if hue_difference == 0: + hue_description = "Same hue" + elif abs(hue_difference) == 180: + hue_description = "Hue shifted 180\u00b0" + else: + direction = "clockwise" if hue_difference > 0 else "counterclockwise" + hue_description = f"Hue shifted {abs(hue_difference)}\u00b0 {direction}" + + def describe_channel(channel, difference): + if difference == 0: + return f"{channel} unchanged" + direction = "increased" if difference > 0 else "decreased" + return ( + f"{channel} {direction} {abs(difference)} percentage " + f"{'point' if abs(difference) == 1 else 'points'}" + ) + + saturation_description = describe_channel( + "saturation", final_hsl["s"] - base_hsl["s"] + ) + lightness_description = describe_channel( + "lightness", final_hsl["l"] - base_hsl["l"] + ) + return f"{hue_description}; {saturation_description}; {lightness_description}." + + +def accurate_suggestion_name(name, base_hsl, final_hsl): + """Keep directional suggestion names aligned with the returned color.""" + hue_difference = circular_hue_difference(base_hsl["h"], final_hsl["h"]) + saturation_difference = final_hsl["s"] - base_hsl["s"] + lightness_difference = final_hsl["l"] - base_hsl["l"] + + hue_relationship_names = ( + "Complement", + "Triadic", + "Analogous", + "Split", + "Tetradic", + "Rectangular", + "Second Base", + ) + if ( + final_hsl["s"] == 0 + and hue_difference == 0 + and any(term in name for term in hue_relationship_names) + ): + return "Neutral Tone" + + if "Tint" in name and lightness_difference <= 0: + suffix = name.rsplit(maxsplit=1)[-1] + suffix = f" {suffix}" if suffix.isdigit() else "" + name = ( + f"{'Darker' if lightness_difference < 0 else 'Same-Lightness'} Tone{suffix}" + ) + elif "Lighter" in name and lightness_difference <= 0: + replacement = "Darker" if lightness_difference < 0 else "Same-Lightness" + name = name.replace("Lighter", replacement) + if "Shade" in name and lightness_difference >= 0: + suffix = name.rsplit(maxsplit=1)[-1] + suffix = f" {suffix}" if suffix.isdigit() else "" + name = ( + f"{'Lighter' if lightness_difference > 0 else 'Same-Lightness'}" + f" Tone{suffix}" + ) + elif "Darker" in name and lightness_difference >= 0: + replacement = "Lighter" if lightness_difference > 0 else "Same-Lightness" + name = name.replace("Darker", replacement) + if "Saturated" in name and saturation_difference <= 0: + replacement = "Desaturated" if saturation_difference < 0 else "Same-Saturation" + name = name.replace("Saturated", replacement) + if "Desaturated" in name and saturation_difference >= 0: + replacement = "Saturated" if saturation_difference > 0 else "Same-Saturation" + name = name.replace("Desaturated", replacement) + return name + + def generate_complementary(base_color): """ Generate complementary color (180° opposite). @@ -28,21 +113,18 @@ def generate_complementary(base_color): "saturation": s, "lightness": l, "name": "Direct Complement", - "description": "Hue shifted by 180°; saturation and lightness unchanged", }, { "hue": comp_hue, "saturation": min(100, s + 15), "lightness": max(20, l - 20), "name": "Darker Complement", - "description": "Hue +180°, saturation +15, lightness -20", }, { "hue": comp_hue, "saturation": max(30, s - 20), "lightness": min(90, l + 20), "name": "Lighter Complement", - "description": "Hue +180°, saturation -20, lightness +20", }, ] @@ -57,7 +139,7 @@ def generate_complementary(base_color): ], "common_associations": "Emphasis, opposition, and color separation", "examples": "Red & Green, Blue & Orange, Yellow & Purple", - "suggestions": [convert_hsl_to_color(s) for s in suggestions], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -79,14 +161,12 @@ def generate_triadic(base_color): "saturation": s, "lightness": l, "name": f"Triadic Partner {offset}°", - "description": f"Hue +{offset}°; saturation and lightness unchanged", }, { "hue": tri_hue, "saturation": min(100, s + 10), "lightness": l, "name": f"Saturated Triadic {offset}°", - "description": f"Hue +{offset}°, saturation +10, lightness unchanged", }, ] ) @@ -102,7 +182,7 @@ def generate_triadic(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -123,7 +203,6 @@ def generate_analogous(base_color): "saturation": s, "lightness": l, "name": f"Analogous {abs(offset)}° {'Left' if offset < 0 else 'Right'}", - "description": f"Adjacent color {abs(offset)}° away", } ) @@ -138,7 +217,7 @@ def generate_analogous(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -165,14 +244,12 @@ def generate_split_complementary(base_color): "saturation": s, "lightness": l, "name": f"Split Complement {'+30°' if offset > 0 else '-30°'}", - "description": f"Flanking the complement by {abs(offset)}°", }, { "hue": split_hue, "saturation": max(40, s - 15), "lightness": min(85, l + 15), "name": f"Lighter Split {'+30°' if offset > 0 else '-30°'}", - "description": f"Hue {150 if offset < 0 else 210:+d}°, saturation -15, lightness +15", }, ] ) @@ -188,7 +265,7 @@ def generate_split_complementary(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -209,7 +286,6 @@ def generate_tetradic(base_color): "saturation": s, "lightness": l, "name": f"Tetradic {offset}°", - "description": f"Square harmony partner at {offset}°", } ) @@ -224,7 +300,7 @@ def generate_tetradic(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -247,7 +323,6 @@ def generate_rectangular(base_color): "saturation": s, "lightness": l, "name": f"Rectangular {offset}°", - "description": f"Rectangle harmony at {offset}°", } ) @@ -262,7 +337,7 @@ def generate_rectangular(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -278,42 +353,36 @@ def generate_monochromatic(base_color): "saturation": max(10, s - 30), "lightness": min(95, l + 30), "name": "Lighter Tint", - "description": "Same hue, saturation -30, lightness +30", }, { "hue": h, "saturation": max(5, s - 40), "lightness": min(98, l + 40), "name": "Very Light Tint", - "description": "Same hue, saturation -40, lightness +40", }, { "hue": h, "saturation": min(100, s + 20), "lightness": max(15, l - 30), "name": "Darker Shade", - "description": "Same hue, saturation +20, lightness -30", }, { "hue": h, "saturation": min(100, s + 10), "lightness": max(10, l - 40), "name": "Very Dark Shade", - "description": "Same hue, saturation +10, lightness -40", }, { "hue": h, "saturation": max(15, s - 25), "lightness": l, "name": "Desaturated Tone", - "description": "Same hue, saturation -25, lightness unchanged", }, { "hue": h, "saturation": min(100, s + 30), "lightness": l, "name": "Saturated Tone", - "description": "Same hue, saturation +30, lightness unchanged", }, ] @@ -328,7 +397,7 @@ def generate_monochromatic(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -354,14 +423,12 @@ def generate_double_complementary(base_color): "saturation": s, "lightness": l, "name": "Second Base", - "description": "30° from original", }, { "hue": comp_hue, "saturation": s, "lightness": l, "name": "Second Complement", - "description": "Complement of second base", }, ] ) @@ -373,7 +440,6 @@ def generate_double_complementary(base_color): "saturation": s, "lightness": l, "name": "Original Complement", - "description": "Complement of base color", } ) @@ -388,7 +454,7 @@ def generate_double_complementary(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } @@ -408,7 +474,6 @@ def generate_shades_tints(base_color): "saturation": s, "lightness": min(98, l + lightness_offset), "name": f"Tint {i}", - "description": f"{lightness_offset}% lighter", } ) @@ -420,7 +485,6 @@ def generate_shades_tints(base_color): "saturation": s, "lightness": max(5, l - lightness_offset), "name": f"Shade {i}", - "description": f"{lightness_offset}% darker", } ) @@ -435,23 +499,28 @@ def generate_shades_tints(base_color): ], "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], + "suggestions": [convert_hsl_to_color(s, base_color) for s in suggestions], } -def convert_hsl_to_color(hsl_obj): - """Convert HSL suggestion object to full color object.""" +def convert_hsl_to_color(hsl_obj, base_color): + """Convert a candidate to a canonical color and describe the returned values.""" h, s, l = hsl_obj["hue"], hsl_obj["saturation"], hsl_obj["lightness"] rgb = hsl_to_rgb([h, s, l]) canonical_hsl = rgb_to_hsl(rgb) + final_hsl = { + "h": canonical_hsl[0], + "s": canonical_hsl[1], + "l": canonical_hsl[2], + } hex_color = "#{:02x}{:02x}{:02x}".format(*rgb) return { "hex": hex_color, "rgb": {"r": rgb[0], "g": rgb[1], "b": rgb[2]}, - "hsl": {"h": canonical_hsl[0], "s": canonical_hsl[1], "l": canonical_hsl[2]}, - "name": hsl_obj["name"], - "description": hsl_obj["description"], + "hsl": final_hsl, + "name": accurate_suggestion_name(hsl_obj["name"], base_color["hsl"], final_hsl), + "description": describe_canonical_change(base_color["hsl"], final_hsl), } diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 795afd0..c309275 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -118,6 +118,10 @@ Each pair contains `color1`, `color2`, `ratio`, `aaNormal`, `aaLarge`, `aaaNormal`, and `aaaLarge`. These fields report contrast thresholds for the pair. They do not prove complete accessibility or WCAG conformance. +`ratio` preserves the calculated floating-point precision. Threshold booleans +use that value. API consumers must not infer pass or fail from a value rounded +for display. + ## Suggestion response Each item in `suggestions` contains: @@ -129,6 +133,11 @@ Each harmony item contains `type`, `angle`, `description`, `useCases`, `commonAssociations`, `examples`, and `suggestions`. Each suggested color contains `name`, `description`, HEX, RGB, and HSL values. +A suggested-color `description` compares the canonical base HSL with the final +canonical HSL returned in the same object. Saturation and lightness differences +are percentage-point differences. The description does not report an +unbounded intermediate adjustment. + `commonAssociations` is conventional guidance. It is not a measured result. This field replaces the removed `mood` field and is a breaking response-contract change. diff --git a/docs/color-analysis.md b/docs/color-analysis.md index ec3fee4..f4f0884 100644 --- a/docs/color-analysis.md +++ b/docs/color-analysis.md @@ -176,6 +176,12 @@ Review applies the ratio to typed role checks: - Focus-indicator color checks use a 3:1 threshold for each evaluated adjacent color pair. +The API preserves the calculated ratio instead of rounding it to two decimal +places. Review normally shows two decimal places. When two-decimal rounding +would make a failing value appear equal to a threshold, Review truncates the +display to four decimal places. Pass or fail always uses the full calculated +value. + 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 @@ -187,11 +193,17 @@ It does not prove complete interface accessibility or WCAG conformance. ## Suggestions 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. +The API converts each candidate through RGB and then derives the final canonical +HSL value. Suggested-color descriptions compare the canonical base color with +that returned HSL value. Hue changes use the shortest circular direction. +Saturation and lightness changes use percentage points. Range limits and RGB +canonicalization can make the final change smaller than the requested +intermediate change. + +`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 diff --git a/docs/user-guide.md b/docs/user-guide.md index fb0ead1..557dd22 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -143,6 +143,10 @@ 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. +ColorCraft evaluates contrast with the full calculated ratio. Most ratios show +two decimal places. A failing value near a threshold shows four decimal places +when ordinary rounding would make it appear equal to the threshold. + ### Suggestions 1. Select **Suggestions**. @@ -156,6 +160,10 @@ approaches. **Common associations** and **Common applications** are conventional guidance. They are not measured suitability. ColorCraft discards suggestion results when a palette color changes. +Each suggested-color description refers to the final returned color after +range limits and RGB canonicalization. Saturation and lightness changes are +stated in percentage points. + ## Export a palette 1. Select **Export**. diff --git a/docs/writing-style.md b/docs/writing-style.md index 3789dd0..86ada08 100644 --- a/docs/writing-style.md +++ b/docs/writing-style.md @@ -183,6 +183,7 @@ contents, clipboard contents, or unrelated palette data in user-facing errors. | angular deviation | The measured difference between detected and expected hue geometry. | State the unit when it is not already clear. | | 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. | +| displayed contrast ratio | The readable contrast ratio shown in the interface. | Preserve enough precision that a failing value does not appear equal to or greater than its threshold. | | 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. | @@ -192,6 +193,7 @@ contents, clipboard contents, or unrelated palette data in user-facing errors. | 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. | +| canonical suggested color | The final RGB and HSL color returned after range limits and RGB canonicalization. | Describe changes from the canonical base color. Use *percentage points* for saturation and lightness differences. | | 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. | diff --git a/frontend/e2e/workflow.spec.ts b/frontend/e2e/workflow.spec.ts index 409f169..e03f2f4 100644 --- a/frontend/e2e/workflow.spec.ts +++ b/frontend/e2e/workflow.spec.ts @@ -67,8 +67,7 @@ test('trust-sensitive Create, Review, Suggestions, and Export states are explici }) => { 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', + await expect(page.getByText('Loopback only')).toHaveAccessibleDescription( /loopback traffic only/i, ) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ce0cf27..ad62a12 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -81,8 +81,9 @@ describe('ColorCraft workspace', () => { 'Extract, refine, and review color palettes from images.', ), ).toBeInTheDocument() - expect(await screen.findByText('Loopback only')).toHaveAttribute( - 'title', + expect( + await screen.findByText('Loopback only'), + ).toHaveAccessibleDescription( expect.stringMatching(/loopback traffic only/i), ) }) @@ -103,12 +104,36 @@ describe('ColorCraft workspace', () => { capabilities: [], }) render() - expect(await screen.findByText('LAN enabled')).toHaveAttribute( - 'title', + expect(await screen.findByText('LAN enabled')).toHaveAccessibleDescription( expect.stringMatching(/trusted LAN access/i), ) }) + it('shows loading before metadata resolves and unavailable after failure', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let rejectMetadata: ((reason?: unknown) => void) | undefined + vi.mocked(getMetadata).mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectMetadata = reject + }), + ) + render() + expect(screen.getByText('Checking network status')).toBeInTheDocument() + expect( + screen.queryByText('Network status unavailable'), + ).not.toBeInTheDocument() + + rejectMetadata?.(new Error('metadata unavailable')) + expect( + await screen.findByText('Network status unavailable'), + ).toHaveAccessibleDescription(/could not confirm/i) + expect(consoleError).toHaveBeenCalledWith( + 'Could not read runtime metadata:', + expect.any(Error), + ) + consoleError.mockRestore() + }) + 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 8986b42..b7546d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { analyzeColors, extractColors, getMetadata } from './api/client' import type { Analysis, Color } from './api/contracts' import { errorMessage } from './api/errors' import AppShell from './components/AppShell' +import type { NetworkStatus } from './components/AppShell' import ColorPalette from './components/ColorPalette' import ExportWorkspace from './components/ExportWorkspace' import ImageColorPicker from './components/ImageColorPicker' @@ -76,9 +77,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 [networkStatus, setNetworkStatus] = useState({ + state: 'loading', + }) const [confirmNewPalette, setConfirmNewPalette] = useState(false) const [pickerTarget, setPickerTarget] = useState(null) const [reviewTab, setReviewTab] = useState(() => @@ -171,9 +172,12 @@ function App() { useEffect(() => { const controller = new AbortController() void getMetadata(controller.signal) - .then((metadata) => setNetworkMode(metadata.networkMode)) + .then((metadata) => + setNetworkStatus({ state: 'available', mode: metadata.networkMode }), + ) .catch((error: unknown) => { if (!controller.signal.aborted) { + setNetworkStatus({ state: 'unavailable' }) console.error('Could not read runtime metadata:', error) } }) @@ -822,7 +826,7 @@ function App() { title={headerTitle} sourceName={headerSource} summary={headerSummary} - networkMode={networkMode} + networkStatus={networkStatus} onNavigate={navigate} onNewPalette={requestNewPalette} recentPalettes={savedPalettes.map(({ id, name }) => ({ id, name }))} diff --git a/frontend/src/components/AnalysisResults.tsx b/frontend/src/components/AnalysisResults.tsx index b6c881a..0d05d4b 100644 --- a/frontend/src/components/AnalysisResults.tsx +++ b/frontend/src/components/AnalysisResults.tsx @@ -1,5 +1,6 @@ import { Accessibility, ScanLine } from 'lucide-react' import type { Analysis, Color } from '../api/contracts' +import { formatContrastRatio } from '../contrast' import Metric from './ui/Metric' import Notice from './ui/Notice' import Panel from './ui/Panel' @@ -161,7 +162,9 @@ export default function AnalysisResults({ analysis }: AnalysisResultsProps) {

{issue.message}

{issue.color1} + {issue.color2} • Ratio:{' '} - {issue.ratio?.toFixed(2) ?? 'N/A'} + {issue.ratio == null + ? 'N/A' + : formatContrastRatio(issue.ratio)} ))} @@ -189,7 +192,12 @@ export default function AnalysisResults({ analysis }: AnalysisResultsProps) { style={{ backgroundColor: pair.color2 }} /> - {pair.ratio?.toFixed(2) ?? 'N/A'}:1 + + {pair.ratio == null + ? 'N/A' + : formatContrastRatio(pair.ratio)} + :1 +
diff --git a/frontend/src/components/AppShell.test.tsx b/frontend/src/components/AppShell.test.tsx index 8ed1fcf..74c19f4 100644 --- a/frontend/src/components/AppShell.test.tsx +++ b/frontend/src/components/AppShell.test.tsx @@ -27,7 +27,7 @@ describe('AppShell navigation', () => { title="ColorCraft" sourceName="Local color utility" summary="Create a palette." - networkMode="loopback" + networkStatus={{ state: 'available', mode: 'loopback' }} onNavigate={vi.fn()} onNewPalette={vi.fn()} > @@ -61,7 +61,7 @@ describe('AppShell navigation', () => { title="Current palette" sourceName="Untitled palette" summary="Two colors · created manually" - networkMode="lan" + networkStatus={{ state: 'available', mode: 'lan' }} onNavigate={onNavigate} onNewPalette={vi.fn()} > @@ -77,9 +77,43 @@ 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(screen.getByText('LAN enabled')).toHaveAccessibleDescription( expect.stringMatching(/trusted LAN access/i), ) }) + + it('distinguishes loading from unavailable network metadata', () => { + const props = { + view: 'create' as const, + navigation: { + review: { available: false, reason: 'Add two colors.' }, + export: { available: false, reason: 'Add one color.' }, + }, + title: 'ColorCraft', + sourceName: 'Local color utility', + summary: 'Create a palette.', + onNavigate: vi.fn(), + onNewPalette: vi.fn(), + } + const view = render( + + Workspace + , + ) + expect( + screen.getByText('Checking network status'), + ).toHaveAccessibleDescription(/checking the resolved network exposure/i) + expect( + screen.queryByText('Network status unavailable'), + ).not.toBeInTheDocument() + + view.rerender( + + Workspace + , + ) + expect( + screen.getByText('Network status unavailable'), + ).toHaveAccessibleDescription(/could not confirm/i) + }) }) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 60c81a0..a11eb91 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -18,13 +18,18 @@ interface NavigationState { export: { available: boolean; reason: string } } +export type NetworkStatus = + | { state: 'loading' } + | { state: 'available'; mode: 'loopback' | 'lan' } + | { state: 'unavailable' } + interface AppShellProps { view: WorkspaceView navigation: NavigationState title: string sourceName: string summary: string - networkMode: 'loopback' | 'lan' | null + networkStatus: NetworkStatus onNavigate: (view: WorkspaceView) => void onNewPalette: () => void recentPalettes?: Array<{ id: string; name: string }> @@ -46,7 +51,7 @@ export default function AppShell({ title, sourceName, summary, - networkMode, + networkStatus, onNavigate, onNewPalette, recentPalettes = [], @@ -54,6 +59,23 @@ export default function AppShell({ headerActions, children, }: AppShellProps) { + const networkStatusLabel = + networkStatus.state === 'loading' + ? 'Checking network status' + : networkStatus.state === 'unavailable' + ? 'Network status unavailable' + : networkStatus.mode === 'loopback' + ? 'Loopback only' + : 'LAN enabled' + const networkStatusDescription = + networkStatus.state === 'loading' + ? 'ColorCraft is checking the resolved network exposure mode.' + : networkStatus.state === 'unavailable' + ? 'ColorCraft could not confirm the current network exposure mode.' + : networkStatus.mode === 'loopback' + ? 'The resolved web and API configuration accepts loopback traffic only.' + : 'The resolved ColorCraft configuration permits trusted LAN access.' + const availability = (target: WorkspaceView) => { if (target === 'create') return { available: true, reason: '' } if (target === 'library') return { available: true, reason: '' } @@ -175,21 +197,19 @@ export default function AppShell({
{headerActions} - {networkMode === 'loopback' - ? 'Loopback only' - : networkMode === 'lan' - ? 'LAN enabled' - : 'Network status unavailable'} + {networkStatusLabel} + + {networkStatusDescription} +
{children}
diff --git a/frontend/src/components/ReviewWorkspace.test.tsx b/frontend/src/components/ReviewWorkspace.test.tsx index 4930a5a..ada9214 100644 --- a/frontend/src/components/ReviewWorkspace.test.tsx +++ b/frontend/src/components/ReviewWorkspace.test.tsx @@ -66,6 +66,15 @@ function measuredAnalysis(): Analysis { aaaNormal: true, aaaLarge: true, }, + { + color1: '#FF0000', + color2: '#0000FF', + ratio: 4.4999, + aaNormal: false, + aaLarge: true, + aaaNormal: false, + aaaLarge: false, + }, ], }, } @@ -193,6 +202,10 @@ describe('ReviewWorkspace outcomes', () => { expect( screen.getByText('Advanced: all-pairs text contrast matrix'), ).toBeInTheDocument() + fireEvent.click( + screen.getByText('Advanced: all-pairs text contrast matrix'), + ) + expect(screen.getByText('4.4999:1')).toBeInTheDocument() view.unmount() }) diff --git a/frontend/src/components/ReviewWorkspace.tsx b/frontend/src/components/ReviewWorkspace.tsx index 9066302..0484bcc 100644 --- a/frontend/src/components/ReviewWorkspace.tsx +++ b/frontend/src/components/ReviewWorkspace.tsx @@ -8,6 +8,7 @@ import { import type { Analysis, Color, HarmonyRelationship } from '../api/contracts' import { contrastRatio, + formatContrastRatio, paletteRoles, resultsForContrastCheck, roleChecks, @@ -473,7 +474,13 @@ function Contrast({

{foreground.hex} on{' '} {background.hex} ·{' '} - {ratio.toFixed(2)} to 1 + + {formatContrastRatio( + ratio, + results.map((result) => result.threshold), + )}{' '} + to 1 +

@@ -519,7 +526,7 @@ function Contrast({ {pair.color1} + {pair.color2} - {pair.ratio.toFixed(2)}:1 + {formatContrastRatio(pair.ratio)}:1
diff --git a/frontend/src/components/ui/StatusBadge.tsx b/frontend/src/components/ui/StatusBadge.tsx index 9aea53d..497d1ce 100644 --- a/frontend/src/components/ui/StatusBadge.tsx +++ b/frontend/src/components/ui/StatusBadge.tsx @@ -7,13 +7,19 @@ export default function StatusBadge({ children, variant = 'neutral', title, + describedBy, }: { children: ReactNode variant?: StatusVariant title?: string + describedBy?: string }) { return ( - + {children} ) diff --git a/frontend/src/contrast.test.ts b/frontend/src/contrast.test.ts index 90f9eab..046f37e 100644 --- a/frontend/src/contrast.test.ts +++ b/frontend/src/contrast.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { blue, red } from './test/fixtures' import { contrastRatio, + formatContrastRatio, pruneRoleAssignments, resultsForContrastCheck, } from './contrast' @@ -46,6 +47,29 @@ describe('role contrast state', () => { }, ) + it.each([3, 4.5, 7])( + 'formats and evaluates values around the %s:1 boundary without contradiction', + (threshold) => { + const below = threshold - 0.0001 + const exact = threshold + const above = threshold + 0.0001 + const kind = threshold === 3 ? 'nonText' : 'text' + const resultAt = (ratio: number) => + resultsForContrastCheck(kind, ratio).find( + (result) => result.threshold === threshold, + ) + + expect(resultAt(below)?.pass).toBe(false) + expect(resultAt(exact)?.pass).toBe(true) + expect(resultAt(above)?.pass).toBe(true) + expect(formatContrastRatio(below, [threshold])).toBe( + `${threshold - 0.0001}`, + ) + expect(formatContrastRatio(exact, [threshold])).toBe(threshold.toFixed(2)) + expect(formatContrastRatio(above, [threshold])).toBe(threshold.toFixed(2)) + }, + ) + it('keeps only role assignments whose color still exists', () => { expect( pruneRoleAssignments( diff --git a/frontend/src/contrast.ts b/frontend/src/contrast.ts index d1cda65..4a4b38f 100644 --- a/frontend/src/contrast.ts +++ b/frontend/src/contrast.ts @@ -22,6 +22,11 @@ export const textContrastThresholds = { aaaLarge: 4.5, } as const export const nonTextContrastThreshold = 3 +export const contrastDisplayThresholds = [ + nonTextContrastThreshold, + textContrastThresholds.aaNormal, + textContrastThresholds.aaaNormal, +] as const export interface ContrastResult { label: string @@ -128,6 +133,24 @@ export function contrastRatio( return (lighter + 0.05) / (darker + 0.05) } +export function formatContrastRatio( + ratio: number, + thresholds: readonly number[] = contrastDisplayThresholds, +): string { + const roundedToTwo = Number(ratio.toFixed(2)) + const roundedUpToFailureBoundary = thresholds.some( + (threshold) => ratio < threshold && roundedToTwo >= threshold, + ) + if (!roundedUpToFailureBoundary) return ratio.toFixed(2) + + const precision = 10_000 + const floatingPointTolerance = + Number.EPSILON * Math.max(1, Math.abs(ratio)) * 4 + const truncated = + Math.floor((ratio + floatingPointTolerance) * precision) / precision + return truncated.toFixed(4) +} + export function resultsForContrastCheck( kind: ContrastCheckKind, ratio: number, diff --git a/tests/test_accessibility.py b/tests/test_accessibility.py index 10c9d2d..ef39efd 100644 --- a/tests/test_accessibility.py +++ b/tests/test_accessibility.py @@ -10,20 +10,25 @@ def test_known_black_white_and_identical_contrast(): @pytest.mark.parametrize( - ("ratio", "field", "passes"), + ("threshold", "field"), [ - (4.4999, "aa_normal", False), - (4.5, "aa_normal", True), - (2.9999, "aa_large", False), - (3.0, "aa_large", True), - (6.9999, "aaa_normal", False), - (7.0, "aaa_normal", True), - (4.4999, "aaa_large", False), - (4.5, "aaa_large", True), + (3.0, "aa_large"), + (4.5, "aa_normal"), + (4.5, "aaa_large"), + (7.0, "aaa_normal"), ], ) -def test_wcag_threshold_boundaries(ratio: float, field: str, passes: bool): - assert wcag_rating(ratio)[field] is passes +@pytest.mark.parametrize( + ("difference", "passes"), + [(-0.0001, False), (0, True), (0.0001, True)], +) +def test_wcag_threshold_boundaries_preserve_precision( + threshold: float, field: str, difference: float, passes: bool +): + ratio = threshold + difference + rating = wcag_rating(ratio) + assert rating[field] is passes + assert rating["ratio"] == ratio def test_known_srgb_contrast_values(): diff --git a/tests/test_api.py b/tests/test_api.py index 9bfc0bd..660c567 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -339,6 +339,9 @@ def test_accessibility_response_schema(client: TestClient): "color2", "ratio", } + ratio = accessibility["pairs"][0]["ratio"] + assert ratio != round(ratio, 2) + assert accessibility["issues"][0]["ratio"] == ratio def test_suggestion_response_schema(client: TestClient): diff --git a/tests/test_color_suggestions.py b/tests/test_color_suggestions.py new file mode 100644 index 0000000..503b6f5 --- /dev/null +++ b/tests/test_color_suggestions.py @@ -0,0 +1,107 @@ +import pytest +from color_extractor import hsl_to_rgb, rgb_to_hsl +from color_suggestions import ( + convert_hsl_to_color, + describe_canonical_change, + generate_all_suggestions, + generate_monochromatic, +) + + +def canonical_color(hue: int, saturation: int, lightness: int): + rgb = hsl_to_rgb([hue, saturation, lightness]) + canonical_hsl = rgb_to_hsl(rgb) + return { + "hex": "#{:02x}{:02x}{:02x}".format(*rgb), + "rgb": {"r": rgb[0], "g": rgb[1], "b": rgb[2]}, + "hsl": { + "h": canonical_hsl[0], + "s": canonical_hsl[1], + "l": canonical_hsl[2], + }, + } + + +def test_descriptions_match_every_final_canonical_suggestion(): + bases = [ + canonical_color(359, 1, 50), + canonical_color(1, 99, 50), + canonical_color(30, 100, 1), + canonical_color(210, 100, 99), + canonical_color(17, 33, 47), + ] + + for base in bases: + result = generate_all_suggestions(base) + for harmony in result["harmonies"]: + for suggestion in harmony["suggestions"]: + assert suggestion["description"] == describe_canonical_change( + base["hsl"], suggestion["hsl"] + ) + assert "%" not in suggestion["description"] + assert ( + "percentage point" in suggestion["description"] + or "unchanged" in suggestion["description"] + ) + + +def test_range_limits_correct_directional_names_and_descriptions(): + near_black = canonical_color(30, 100, 1) + near_white = canonical_color(210, 100, 99) + + black_variations = generate_monochromatic(near_black)["suggestions"] + corrected_dark_name = next( + suggestion + for suggestion in black_variations + if "Lighter Tone" in suggestion["name"] + ) + assert "lightness increased" in corrected_dark_name["description"] + + white_variations = generate_monochromatic(near_white)["suggestions"] + corrected_tint_name = next( + suggestion + for suggestion in white_variations + if "Darker Tone" in suggestion["name"] + ) + assert "lightness decreased" in corrected_tint_name["description"] + + +def test_rgb_canonicalization_drives_returned_description(): + base = canonical_color(0, 0, 1) + suggestion = convert_hsl_to_color( + { + "hue": 0, + "saturation": 1, + "lightness": 1, + "name": "Saturated Tone", + }, + base, + ) + + assert suggestion["hsl"] == {"h": 0, "s": 0, "l": 1} + assert suggestion["description"] == ( + "Same hue; saturation unchanged; lightness unchanged." + ) + assert suggestion["name"] == "Same-Saturation Tone" + + +@pytest.mark.parametrize( + ("base_hue", "final_hue", "direction"), + [(359, 1, "clockwise"), (1, 359, "counterclockwise")], +) +def test_circular_hue_description_uses_shortest_direction( + base_hue: int, final_hue: int, direction: str +): + base = canonical_color(base_hue, 100, 50) + suggestion = convert_hsl_to_color( + { + "hue": final_hue, + "saturation": 100, + "lightness": 50, + "name": "Nearby Hue", + }, + base, + ) + + assert suggestion["hsl"]["h"] == final_hue + assert suggestion["description"].startswith(f"Hue shifted 2° {direction};") From 1355e20424cde5cf19637be6ef316655f2169c4c Mon Sep 17 00:00:00 2001 From: John Crafts <5889731+Artsen@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:25:35 -0400 Subject: [PATCH 2/2] fix: preserve contrast threshold invariant --- frontend/src/contrast.test.ts | 20 ++++++++++++++------ frontend/src/contrast.ts | 8 ++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/frontend/src/contrast.test.ts b/frontend/src/contrast.test.ts index 046f37e..2f95edd 100644 --- a/frontend/src/contrast.test.ts +++ b/frontend/src/contrast.test.ts @@ -50,26 +50,34 @@ describe('role contrast state', () => { it.each([3, 4.5, 7])( 'formats and evaluates values around the %s:1 boundary without contradiction', (threshold) => { - const below = threshold - 0.0001 + const epsilon = Number.EPSILON * Math.max(1, threshold) + const failingValues = [threshold - 0.0001, threshold - epsilon] const exact = threshold - const above = threshold + 0.0001 + const above = threshold + epsilon const kind = threshold === 3 ? 'nonText' : 'text' const resultAt = (ratio: number) => resultsForContrastCheck(kind, ratio).find( (result) => result.threshold === threshold, ) - expect(resultAt(below)?.pass).toBe(false) + for (const failingValue of failingValues) { + const formatted = formatContrastRatio(failingValue, [threshold]) + expect(resultAt(failingValue)?.pass).toBe(false) + expect(Number(formatted)).toBeLessThan(threshold) + expect(formatted).toMatch(/^\d+\.\d{4}$/) + } expect(resultAt(exact)?.pass).toBe(true) expect(resultAt(above)?.pass).toBe(true) - expect(formatContrastRatio(below, [threshold])).toBe( - `${threshold - 0.0001}`, - ) expect(formatContrastRatio(exact, [threshold])).toBe(threshold.toFixed(2)) expect(formatContrastRatio(above, [threshold])).toBe(threshold.toFixed(2)) }, ) + it('keeps ordinary two-decimal contrast formatting unchanged', () => { + expect(formatContrastRatio(4.5422)).toBe('4.54') + expect(formatContrastRatio(21)).toBe('21.00') + }) + it('keeps only role assignments whose color still exists', () => { expect( pruneRoleAssignments( diff --git a/frontend/src/contrast.ts b/frontend/src/contrast.ts index 4a4b38f..7f8fff0 100644 --- a/frontend/src/contrast.ts +++ b/frontend/src/contrast.ts @@ -143,12 +143,8 @@ export function formatContrastRatio( ) if (!roundedUpToFailureBoundary) return ratio.toFixed(2) - const precision = 10_000 - const floatingPointTolerance = - Number.EPSILON * Math.max(1, Math.abs(ratio)) * 4 - const truncated = - Math.floor((ratio + floatingPointTolerance) * precision) / precision - return truncated.toFixed(4) + const [integerPart, fractionalPart = ''] = ratio.toString().split('.') + return `${integerPart}.${fractionalPart.padEnd(4, '0').slice(0, 4)}` } export function resultsForContrastCheck(