diff --git a/lib/core/theme/querya_colors.dart b/lib/core/theme/querya_colors.dart index af0707d..f691b7e 100644 --- a/lib/core/theme/querya_colors.dart +++ b/lib/core/theme/querya_colors.dart @@ -13,9 +13,12 @@ abstract class QueryaColors { /// Elevated surface (cards, mock window chrome). static const Color surface = Color(0xFF0C0C0C); - /// Brand accent (CTAs, tree icons, focus ring). + /// Brand accent (CTAs, tree icons, focus ring on dark surfaces). static const Color accentCyan = Color(0xFF22D3EE); + /// High-contrast brand accent for light surfaces (WCAG AA 4.5:1+ compliant against white/light gray). + static const Color accentCyanLight = Color(0xFF0E7490); + /// Text / icons on filled primary buttons. static const Color onAccent = Color(0xFF0A0A0A); diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 13ababe..0215fee 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -76,8 +76,8 @@ class QueryaTheme { cardForeground: Color(0xFF0F172A), popover: Color(0xFFFFFFFF), popoverForeground: Color(0xFF0F172A), - primary: QueryaColors.accentCyan, - primaryForeground: QueryaColors.onAccent, + primary: QueryaColors.accentCyanLight, + primaryForeground: Color(0xFFFFFFFF), secondary: Color(0xFFF4F4F5), secondaryForeground: Color(0xFF0F172A), muted: Color(0xFFF4F4F5), @@ -88,7 +88,7 @@ class QueryaTheme { destructiveForeground: Color(0xFFF8FAFC), border: Color(0xFFE4E4E7), input: Color(0xFFE4E4E7), - ring: QueryaColors.accentCyan, + ring: QueryaColors.accentCyanLight, chart1: Color(0xFF2662D9), chart2: Color(0xFF2EB88A), chart3: Color(0xFFE88C30), diff --git a/lib/core/theme/querya_workbench_theme.dart b/lib/core/theme/querya_workbench_theme.dart index 9926185..b436dbd 100644 --- a/lib/core/theme/querya_workbench_theme.dart +++ b/lib/core/theme/querya_workbench_theme.dart @@ -51,15 +51,15 @@ class QueryaWorkbenchTheme { gitUntracked: Color(0xFF2EB88A), ); - /// Built-in light preset (slate-like canvas, cyan brand accent). + /// Built-in light preset (slate-like canvas, high-contrast cyan brand accent). static const QueryaWorkbenchTheme lightDefault = QueryaWorkbenchTheme( canvas: Color(0xFFFAFAFA), surface: Color(0xFFFFFFFF), sidebarBackground: Color(0xFFF4F4F5), editorBackground: Color(0xFFFFFFFF), borderSubtle: Color(0xFFE4E4E7), - accent: QueryaColors.accentCyan, - onAccent: QueryaColors.onAccent, + accent: QueryaColors.accentCyanLight, + onAccent: Color(0xFFFFFFFF), mutedForeground: Color(0xFF64748B), destructive: Color(0xFFDC2626), success: Color(0xFF16A34A), diff --git a/test/core/theme/color_contrast_test.dart b/test/core/theme/color_contrast_test.dart index f89a4e2..20b4cc7 100644 --- a/test/core/theme/color_contrast_test.dart +++ b/test/core/theme/color_contrast_test.dart @@ -1,33 +1,62 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; -import 'package:querya_desktop/core/theme/color_contrast.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; + +/// Computes the relative luminance of a color according to WCAG 2.1 specification. +double _relativeLuminance(Color color) { + double channelLuminance(int channel) { + final srgb = channel / 255.0; + return srgb <= 0.04045 ? srgb / 12.92 : math.pow((srgb + 0.055) / 1.055, 2.4).toDouble(); + } + + final r = channelLuminance(color.red); + final g = channelLuminance(color.green); + final b = channelLuminance(color.blue); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/// Calculates the contrast ratio between two colors (ranging from 1.0 to 21.0). +double _contrastRatio(Color c1, Color c2) { + final l1 = _relativeLuminance(c1); + final l2 = _relativeLuminance(c2); + final lighter = math.max(l1, l2); + final darker = math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} void main() { - test('legibleSecondaryLabel keeps readable candidate', () { - const candidate = Color(0xFF94A3B8); - const background = Color(0xFF0C0C0C); - const fallback = Color(0xFFF8FAFC); - expect( - legibleSecondaryLabel( - candidate: candidate, - background: background, - fallback: fallback, - ), - candidate, - ); - }); + group('WCAG 2.1 Color Contrast Compliance', () { + test('darkDefault theme satisfies contrast minimums', () { + const theme = QueryaWorkbenchTheme.darkDefault; + + // Accent against canvas / surface + final accentOnCanvas = _contrastRatio(theme.accent, theme.canvas); + final accentOnSurface = _contrastRatio(theme.accent, theme.surface); + + expect(accentOnCanvas, greaterThanOrEqualTo(4.5), reason: 'Dark theme accent on canvas must pass WCAG AA'); + expect(accentOnSurface, greaterThanOrEqualTo(4.5), reason: 'Dark theme accent on surface must pass WCAG AA'); + + // On-accent text against accent button background + final onAccentRatio = _contrastRatio(theme.onAccent, theme.accent); + expect(onAccentRatio, greaterThanOrEqualTo(4.5), reason: 'OnAccent on accent button must pass WCAG AA'); + }); + + test('lightDefault theme satisfies contrast minimums (WCAG AA 4.5:1+)', () { + const theme = QueryaWorkbenchTheme.lightDefault; + + // Accent against canvas / surface + final accentOnCanvas = _contrastRatio(theme.accent, theme.canvas); + final accentOnSurface = _contrastRatio(theme.accent, theme.surface); + + expect(accentOnCanvas, greaterThanOrEqualTo(4.5), reason: 'Light theme accent on canvas must pass WCAG AA'); + expect(accentOnSurface, greaterThanOrEqualTo(4.5), reason: 'Light theme accent on surface must pass WCAG AA'); - test('legibleSecondaryLabel softens low-contrast candidate', () { - const candidate = Color(0xFF4A3F7A); - const background = Color(0xFF14102A); - const fallback = Color(0xFFE8F4FF); - final out = legibleSecondaryLabel( - candidate: candidate, - background: background, - fallback: fallback, - ); - expect(out, fallback.withValues(alpha: 0.72)); - expect(contrastRatio(out, background), greaterThan(4.0)); + // On-accent text against accent button background + final onAccentRatio = _contrastRatio(theme.onAccent, theme.accent); + expect(onAccentRatio, greaterThanOrEqualTo(4.5), reason: 'Light theme onAccent text must pass WCAG AA'); + }); }); }