From 4a513d290b458600959f90ba33794db589d029d7 Mon Sep 17 00:00:00 2001 From: dr mike Date: Sat, 27 Jun 2026 19:14:01 +0330 Subject: [PATCH] feat(linter): CVD contrast rule and unverified-contrast info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in color-blind-contrast (Brettel-Viénot-Mollon simulation) for component pairs and a default unverified-contrast info when color tokens exist without backgroundColor/textColor pairs. Fixes google-labs-code/design.md#48 Addresses google-labs-code/design.md#116 (items 1, 2 partial) --- packages/cli/src/linter/index.ts | 3 + .../linter/rules/color-blind-contrast.test.ts | 57 ++++++++ .../linter/rules/color-blind-contrast.ts | 124 ++++++++++++++++++ packages/cli/src/linter/linter/rules/index.ts | 4 + .../cli/src/linter/linter/rules/types.test.ts | 2 +- .../linter/rules/unverified-contrast.test.ts | 47 +++++++ .../linter/rules/unverified-contrast.ts | 57 ++++++++ 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/linter/linter/rules/color-blind-contrast.test.ts create mode 100644 packages/cli/src/linter/linter/rules/color-blind-contrast.ts create mode 100644 packages/cli/src/linter/linter/rules/unverified-contrast.test.ts create mode 100644 packages/cli/src/linter/linter/rules/unverified-contrast.ts diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 2cf5037c..06e1187c 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -46,6 +46,9 @@ export { missingTypography, unknownKey, tokenLikeIgnored, + unverifiedContrastCheck, + colorBlindContrastCheck, + colorBlindContrastRule, } from './linter/rules/index.js'; export { contrastRatio } from './model/handler.js'; export { TailwindEmitterHandler } from './tailwind/handler.js'; diff --git a/packages/cli/src/linter/linter/rules/color-blind-contrast.test.ts b/packages/cli/src/linter/linter/rules/color-blind-contrast.test.ts new file mode 100644 index 00000000..b349fbce --- /dev/null +++ b/packages/cli/src/linter/linter/rules/color-blind-contrast.test.ts @@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { colorBlindContrastCheck } from './color-blind-contrast.js'; +import { buildState } from './test-helpers.js'; + +describe('colorBlindContrastCheck', () => { + it('warns when brand orange fails under protanopia simulation', () => { + const state = buildState({ + colors: { accent: '#E44001', white: '#ffffff' }, + components: { + 'button-primary': { + backgroundColor: '{colors.accent}', + textColor: '{colors.white}', + }, + }, + }); + const findings = colorBlindContrastCheck(state); + expect(findings.some(f => f.message.includes('protanopia'))).toBe(true); + expect(findings.some(f => f.message.includes('deuteranopia'))).toBe(true); + }); + + it('returns empty for high-contrast black on white', () => { + const state = buildState({ + colors: { black: '#000000', white: '#ffffff' }, + components: { + 'button-good': { + backgroundColor: '{colors.black}', + textColor: '{colors.white}', + }, + }, + }); + expect(colorBlindContrastCheck(state).length).toBe(0); + }); + + it('skips components missing background or text color', () => { + const state = buildState({ + colors: { accent: '#E44001' }, + components: { + 'chip': { backgroundColor: '{colors.accent}' }, + }, + }); + expect(colorBlindContrastCheck(state).length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/color-blind-contrast.ts b/packages/cli/src/linter/linter/rules/color-blind-contrast.ts new file mode 100644 index 00000000..12ad0012 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/color-blind-contrast.ts @@ -0,0 +1,124 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DesignSystemState, ResolvedColor, ResolvedValue } from '../../model/spec.js'; +import { contrastRatio } from '../../model/handler.js'; +import type { RuleDescriptor, RuleFinding } from './types.js'; + +/** Brettel-Viénot-Mollon deficiency matrices on normalized sRGB [0, 1]. */ +const CVD_MATRICES = { + protanopia: [ + [0.567, 0.433, 0.0], + [0.558, 0.442, 0.0], + [0.0, 0.242, 0.758], + ], + deuteranopia: [ + [0.625, 0.375, 0.0], + [0.7, 0.3, 0.0], + [0.0, 0.3, 0.7], + ], + tritanopia: [ + [0.95, 0.05, 0.0], + [0.0, 0.433, 0.567], + [0.0, 0.475, 0.525], + ], +} as const; + +type CvdType = keyof typeof CVD_MATRICES; + +const CVD_FLOOR = 3; + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)); +} + +function relativeLuminance(r: number, g: number, b: number): number { + const linear = [r, g, b].map(c => { + const s = c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return s; + }); + return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!; +} + +function toHexByte(n: number): string { + return Math.round(clamp01(n) * 255).toString(16).padStart(2, '0'); +} + +function simulateCvd(color: ResolvedColor, type: CvdType): ResolvedColor { + const rgb = [color.r / 255, color.g / 255, color.b / 255]; + const m = CVD_MATRICES[type]; + const r = clamp01(m[0]![0]! * rgb[0]! + m[0]![1]! * rgb[1]! + m[0]![2]! * rgb[2]!); + const g = clamp01(m[1]![0]! * rgb[0]! + m[1]![1]! * rgb[1]! + m[1]![2]! * rgb[2]!); + const b = clamp01(m[2]![0]! * rgb[0]! + m[2]![1]! * rgb[1]! + m[2]![2]! * rgb[2]!); + const rr = Math.round(r * 255); + const gg = Math.round(g * 255); + const bb = Math.round(b * 255); + return { + type: 'color', + hex: `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`, + r: rr, + g: gg, + b: bb, + luminance: relativeLuminance(r, g, b), + }; +} + +function resolveToColor(value: ResolvedValue): ResolvedColor | null { + if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') { + return value as ResolvedColor; + } + return null; +} + +/** + * Opt-in CVD simulation — re-checks component contrast under protanopia, + * deuteranopia, and tritanopia. Not included in DEFAULT_RULES; compose via + * runLinter(state, [...DEFAULT_RULE_DESCRIPTORS, colorBlindContrastRule]). + */ +export function colorBlindContrastCheck(state: DesignSystemState): RuleFinding[] { + const findings: RuleFinding[] = []; + for (const [compName, comp] of state.components) { + const bgValue = comp.properties.get('backgroundColor'); + const textValue = comp.properties.get('textColor'); + if (!bgValue || !textValue) continue; + + const bgColor = resolveToColor(bgValue); + const textColor = resolveToColor(textValue); + if (!bgColor || !textColor) continue; + + for (const cvdType of Object.keys(CVD_MATRICES) as CvdType[]) { + const simBg = simulateCvd(bgColor, cvdType); + const simText = simulateCvd(textColor, cvdType); + const ratio = contrastRatio(simBg, simText); + if (ratio < CVD_FLOOR) { + findings.push({ + path: `components.${compName}`, + message: + `component '${compName}' contrast ${ratio.toFixed(2)}:1 under ${cvdType} simulation ` + + `(bg ${simBg.hex} on fg ${simText.hex}) is below ${CVD_FLOOR}:1 floor — ` + + `relying on color-only cues fails this audience.`, + }); + } + } + } + return findings; +} + +export const colorBlindContrastRule: RuleDescriptor = { + name: 'color-blind-contrast', + severity: 'warning', + description: + 'Opt-in CVD simulation (Brettel-Viénot-Mollon) — warns when component pairs fall below 3:1 under protanopia, deuteranopia, or tritanopia.', + run: colorBlindContrastCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 92a525ab..98d67f37 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -25,12 +25,14 @@ import { sectionOrderRule } from './section-order.js'; import { missingTypographyRule } from './missing-typography.js'; import { unknownKeyRule } from './unknown-key.js'; import { tokenLikeIgnoredRule } from './token-like-ignored.js'; +import { unverifiedContrastRule } from './unverified-contrast.js'; /** The default set of lint rule descriptors, in order. */ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [ brokenRefRule, missingPrimaryRule, contrastCheckRule, + unverifiedContrastRule, orphanedTokensRule, tokenSummaryRule, missingSectionsRule, @@ -64,4 +66,6 @@ export { missingTypography } from './missing-typography.js'; export { unknownKey } from './unknown-key.js'; export { sectionOrder } from './section-order.js'; export { tokenLikeIgnored } from './token-like-ignored.js'; +export { unverifiedContrastCheck } from './unverified-contrast.js'; +export { colorBlindContrastCheck, colorBlindContrastRule } from './color-blind-contrast.js'; export type { LintRule } from './types.js'; diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index f33b34d3..5df84454 100644 --- a/packages/cli/src/linter/linter/rules/types.test.ts +++ b/packages/cli/src/linter/linter/rules/types.test.ts @@ -40,7 +40,7 @@ describe('LintRule type', () => { }); it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => { - expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(10); + expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(11); DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => { expect(rule.name).toBeTruthy(); expect(rule.severity).toBeTruthy(); diff --git a/packages/cli/src/linter/linter/rules/unverified-contrast.test.ts b/packages/cli/src/linter/linter/rules/unverified-contrast.test.ts new file mode 100644 index 00000000..7337eb92 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unverified-contrast.test.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { unverifiedContrastCheck } from './unverified-contrast.js'; +import { buildState } from './test-helpers.js'; + +describe('unverifiedContrastCheck', () => { + it('emits info when colors exist without component contrast pairs', () => { + const state = buildState({ + colors: { primary: '#1A1C1E', neutral: '#F7F5F2' }, + }); + const findings = unverifiedContrastCheck(state); + expect(findings.length).toBe(1); + expect(findings[0]!.severity).toBe('info'); + expect(findings[0]!.message).toMatch(/contrast cannot be verified/); + }); + + it('returns empty when a component declares bg/text pair', () => { + const state = buildState({ + colors: { primary: '#1A1C1E', onPrimary: '#FFFFFF' }, + components: { + 'button-primary': { + backgroundColor: '{colors.primary}', + textColor: '{colors.onPrimary}', + }, + }, + }); + expect(unverifiedContrastCheck(state).length).toBe(0); + }); + + it('returns empty when no colors are defined', () => { + const state = buildState({}); + expect(unverifiedContrastCheck(state).length).toBe(0); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/unverified-contrast.ts b/packages/cli/src/linter/linter/rules/unverified-contrast.ts new file mode 100644 index 00000000..84a5dfbe --- /dev/null +++ b/packages/cli/src/linter/linter/rules/unverified-contrast.ts @@ -0,0 +1,57 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DesignSystemState, ResolvedValue } from '../../model/spec.js'; +import type { RuleDescriptor, RuleFinding } from './types.js'; + +function hasContrastPair(state: DesignSystemState): boolean { + for (const comp of state.components.values()) { + const bg = comp.properties.get('backgroundColor'); + const text = comp.properties.get('textColor'); + if (bg && text) return true; + } + return false; +} + +function isColorValue(value: ResolvedValue): boolean { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'color'; +} + +/** + * Warns when color tokens exist but no component declares backgroundColor/textColor + * pairs for the contrast-ratio rule to evaluate. + */ +export function unverifiedContrastCheck(state: DesignSystemState): RuleFinding[] { + if (state.colors.size === 0) return []; + if (hasContrastPair(state)) return []; + + const hasResolvableColors = [...state.colors.values()].some(isColorValue); + if (!hasResolvableColors) return []; + + return [{ + path: 'colors', + severity: 'info', + message: + `${state.colors.size} color token(s) defined but no component backgroundColor/textColor pairs — ` + + 'contrast cannot be verified until at least one component pair is declared.', + }]; +} + +export const unverifiedContrastRule: RuleDescriptor = { + name: 'unverified-contrast', + severity: 'info', + description: + 'Surfaces when color tokens exist without any component-level contrast pairs to verify.', + run: unverifiedContrastCheck, +};