(() =>
@@ -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(