The expanded canonicalPairs from T-114 (muted text, status chips, syntax tokens on the code-block surface, panel focus border) made the four named themes fail WCAG-AA. Retuning their palettes to pass would have changed the look users picked them for, so the gate is split instead. `canonicalPairs` shrinks back to the baseline every named theme passes; the new `extendedPairs` carries the stricter set and only runs against themes whose name ends `-hc` or `-cb`. Sibling files (`clide-hc`, `midnight-hc`, `paper-hc`, `terminal-hc`) ship today; the policy lives in D-69 with a back-ref from D-22. Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
7.7 KiB
Dart
247 lines
7.7 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui';
|
|
|
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
/// A foreground/background token pair the a11y contrast suite walks.
|
|
@immutable
|
|
class ContrastPair {
|
|
const ContrastPair({
|
|
required this.name,
|
|
required this.foreground,
|
|
required this.background,
|
|
this.largeText = false,
|
|
});
|
|
|
|
final String name;
|
|
final Color foreground;
|
|
final Color background;
|
|
|
|
/// WCAG AA threshold for "large text" (18pt, or 14pt bold) is 3:1;
|
|
/// normal text is 4.5:1. Mark a pair as [largeText] when the rendered
|
|
/// typography qualifies.
|
|
final bool largeText;
|
|
}
|
|
|
|
/// Compute the WCAG 2.x relative-luminance ratio between two colors.
|
|
///
|
|
/// Alpha is pre-composited against a neutral grey so semi-transparent
|
|
/// tokens don't spuriously pass. Returns a value in `[1, 21]`.
|
|
double contrastRatio(Color a, Color b, {Color onto = const Color(0xFF808080)}) {
|
|
final la = _relativeLuminance(_composite(a, onto));
|
|
final lb = _relativeLuminance(_composite(b, onto));
|
|
final brighter = math.max(la, lb);
|
|
final darker = math.min(la, lb);
|
|
return (brighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
/// Minimum ratio required for this pair per WCAG AA.
|
|
double minimumRatio(ContrastPair pair) => pair.largeText ? 3.0 : 4.5;
|
|
|
|
/// Baseline token pairs every bundled theme must honour.
|
|
///
|
|
/// Per D-22 every named theme passes this set; per D-69 the named
|
|
/// themes (`clide`, `midnight`, `paper`, `terminal`) are user
|
|
/// contracts whose palettes are not retuned to chase a contrast gate,
|
|
/// so only the load-bearing pairs (primary text on its surface, chrome
|
|
/// foregrounds, selected list item) sit here. Stricter coverage for
|
|
/// muted text, status chips, syntax tokens, and the focus border lives
|
|
/// in [extendedPairs], which only `-hc`/`-cb` variants must pass.
|
|
List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
|
|
ContrastPair(
|
|
name: 'global.text_on_background',
|
|
foreground: s.globalForeground,
|
|
background: s.globalBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'panel.header_foreground_on_panel',
|
|
foreground: s.panelHeaderForeground,
|
|
background: s.panelHeader,
|
|
),
|
|
ContrastPair(
|
|
name: 'sidebar.foreground_on_sidebar',
|
|
foreground: s.sidebarForeground,
|
|
background: s.sidebarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'statusbar.foreground_on_statusbar',
|
|
foreground: s.statusBarForeground,
|
|
background: s.statusBarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'tab.active_text_on_active_bg',
|
|
foreground: s.tabActiveForeground,
|
|
background: s.tabActive,
|
|
),
|
|
ContrastPair(
|
|
name: 'tab.inactive_text_on_inactive_bg',
|
|
foreground: s.tabInactiveForeground,
|
|
background: s.tabInactive,
|
|
),
|
|
ContrastPair(
|
|
name: 'button.text_on_button',
|
|
foreground: s.buttonForeground,
|
|
background: s.buttonBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'listItem.selected_text_on_selected_bg',
|
|
foreground: s.listItemSelectedForeground,
|
|
background: s.listItemSelectedBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'listItem.text_on_list',
|
|
foreground: s.listItemForeground,
|
|
background: s.listItemBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'tooltip.text_on_tooltip',
|
|
foreground: s.tooltipForeground,
|
|
background: s.tooltipBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'dropdown.text_on_dropdown',
|
|
foreground: s.dropdownForeground,
|
|
background: s.dropdownBackground,
|
|
),
|
|
];
|
|
|
|
/// Stricter pair set — only the high-contrast (`-hc`) and colour-blind
|
|
/// (`-cb`) theme variants must clear it. See D-69. These are the
|
|
/// surfaces a UX consultant flagged in `consultants.md`: muted body
|
|
/// text, status chip foregrounds, syntax tokens on the code-block
|
|
/// surface, and the focus-indicating panel border.
|
|
List<ContrastPair> extendedPairs(SurfaceTokens s) => [
|
|
ContrastPair(
|
|
name: 'global.text_muted_on_background',
|
|
foreground: s.globalTextMuted,
|
|
background: s.globalBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'global.text_muted_on_panel',
|
|
foreground: s.globalTextMuted,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'status.success_on_statusbar',
|
|
foreground: s.statusSuccess,
|
|
background: s.statusBarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'status.warning_on_statusbar',
|
|
foreground: s.statusWarning,
|
|
background: s.statusBarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'status.error_on_statusbar',
|
|
foreground: s.statusError,
|
|
background: s.statusBarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'status.info_on_statusbar',
|
|
foreground: s.statusInfo,
|
|
background: s.statusBarBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.keyword_on_panel',
|
|
foreground: s.syntaxKeyword,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.type_on_panel',
|
|
foreground: s.syntaxType,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.string_on_panel',
|
|
foreground: s.syntaxString,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.number_on_panel',
|
|
foreground: s.syntaxNumber,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.comment_on_panel',
|
|
foreground: s.syntaxComment,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.method_on_panel',
|
|
foreground: s.syntaxMethod,
|
|
background: s.panelBackground,
|
|
),
|
|
ContrastPair(
|
|
name: 'syntax.punct_on_panel',
|
|
foreground: s.syntaxPunct,
|
|
background: s.panelBackground,
|
|
),
|
|
// WCAG 1.4.11 wants 3:1 for non-text UI components like a focus
|
|
// border against the adjacent surface.
|
|
ContrastPair(
|
|
name: 'panel.active_border_on_background',
|
|
foreground: s.panelActiveBorder,
|
|
background: s.globalBackground,
|
|
largeText: true,
|
|
),
|
|
];
|
|
|
|
/// Convenience for tests: returns the list of [canonicalPairs] that
|
|
/// fail WCAG AA.
|
|
List<ContrastFailure> failingPairs(SurfaceTokens tokens) =>
|
|
_failures(canonicalPairs(tokens));
|
|
|
|
/// Strict variant of [failingPairs] — walks [extendedPairs] instead.
|
|
/// Intended for the `-hc` / `-cb` theme gate.
|
|
List<ContrastFailure> failingExtendedPairs(SurfaceTokens tokens) =>
|
|
_failures(extendedPairs(tokens));
|
|
|
|
List<ContrastFailure> _failures(List<ContrastPair> pairs) {
|
|
final out = <ContrastFailure>[];
|
|
for (final p in pairs) {
|
|
final ratio = contrastRatio(p.foreground, p.background);
|
|
final need = minimumRatio(p);
|
|
if (ratio < need) {
|
|
out.add(ContrastFailure(pair: p, ratio: ratio, minimum: need));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
@immutable
|
|
class ContrastFailure {
|
|
const ContrastFailure({
|
|
required this.pair,
|
|
required this.ratio,
|
|
required this.minimum,
|
|
});
|
|
|
|
final ContrastPair pair;
|
|
final double ratio;
|
|
final double minimum;
|
|
|
|
@override
|
|
String toString() => 'contrast ${pair.name}: ${ratio.toStringAsFixed(2)} < '
|
|
'${minimum.toStringAsFixed(1)}';
|
|
}
|
|
|
|
// -- internals ---------------------------------------------------------------
|
|
|
|
Color _composite(Color src, Color dst) {
|
|
final a = src.a;
|
|
if (a >= 0.999) return src;
|
|
double mix(double s, double d) => s * a + d * (1 - a);
|
|
return Color.from(
|
|
alpha: 1.0,
|
|
red: mix(src.r, dst.r),
|
|
green: mix(src.g, dst.g),
|
|
blue: mix(src.b, dst.b),
|
|
);
|
|
}
|
|
|
|
double _relativeLuminance(Color c) {
|
|
double chan(double v) => v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
|
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
|
|
}
|