diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 990901d9..867dc3cb 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -13,7 +13,7 @@ // limitations under the License. import { defineCommand } from 'citty'; -import { lint } from '../linter/index.js'; +import { colorBlindContrastRule, DEFAULT_RULE_DESCRIPTORS, lint } from '../linter/index.js'; import { readInput, formatOutput } from '../utils.js'; export default defineCommand({ @@ -32,10 +32,17 @@ export default defineCommand({ description: 'Output format: json or text', default: 'json', }, + cvd: { + type: 'boolean', + description: 'Also run the opt-in color-blind (CVD) contrast check', + default: false, + }, }, async run({ args }) { const content = await readInput(args.file); - const report = lint(content); + const report = args.cvd + ? lint(content, { rules: [...DEFAULT_RULE_DESCRIPTORS, colorBlindContrastRule] }) + : lint(content); const output = { findings: report.findings, diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 2cf5037c..8c1e32fb 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -33,13 +33,15 @@ export type { DtcgEmitterResult, DtcgTokenFile } from './dtcg/spec.js'; // ── Advanced linting ─────────────────────────────────────────────── export { runLinter, preEvaluate } from './linter/runner.js'; -export { DEFAULT_RULES } from './linter/rules/index.js'; +export { DEFAULT_RULES, DEFAULT_RULE_DESCRIPTORS } from './linter/rules/index.js'; export type { LintRule } from './linter/rules/types.js'; export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js'; export { brokenRef, missingPrimary, contrastCheck, + colorBlindContrastCheck, + colorBlindContrastRule, orphanedTokens, tokenSummary, missingSections, diff --git a/packages/cli/src/linter/lint.ts b/packages/cli/src/linter/lint.ts index b25dbfd8..d746decb 100644 --- a/packages/cli/src/linter/lint.ts +++ b/packages/cli/src/linter/lint.ts @@ -19,12 +19,12 @@ import { runLinter } from './linter/runner.js'; import { TailwindEmitterHandler } from './tailwind/handler.js'; import type { DesignSystemState } from './model/spec.js'; import type { Finding } from './linter/spec.js'; -import type { LintRule } from './linter/rules/types.js'; +import type { LintRule, RuleDescriptor } from './linter/rules/types.js'; import type { TailwindEmitterResult } from './tailwind/spec.js'; export interface LintOptions { /** Custom lint rules. Defaults to DEFAULT_RULES if omitted. */ - rules?: LintRule[]; + rules?: LintRule[] | RuleDescriptor[]; } export interface LintReport { 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..19c69407 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/color-blind-contrast.test.ts @@ -0,0 +1,50 @@ +// 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, colorBlindContrastRule } from './color-blind-contrast.js'; +import { DEFAULT_RULE_DESCRIPTORS } from './index.js'; +import { buildState } from './test-helpers.js'; + +describe('colorBlindContrastCheck', () => { + it('emits warnings for low simulated contrast pairs', () => { + const state = buildState({ + components: { + 'button-danger': { backgroundColor: '#FFFFFF', textColor: '#E44001' }, + }, + }); + + const findings = colorBlindContrastCheck(state); + + expect(findings.some(finding => finding.message.includes('under protanopia simulation'))).toBe(true); + expect(findings.some(finding => finding.message.includes('under deuteranopia simulation'))).toBe(true); + expect(findings.every(finding => finding.path === 'components.button-danger')).toBe(true); + }); + + it('returns empty for CVD-safe contrast pairs', () => { + const state = buildState({ + components: { + 'button-good': { backgroundColor: '#FFFFFF', textColor: '#000000' }, + }, + }); + + const findings = colorBlindContrastCheck(state); + + expect(findings.length).toBe(0); + }); + + it('is not present in default rule descriptors', () => { + expect(DEFAULT_RULE_DESCRIPTORS.some(rule => rule.name === colorBlindContrastRule.name)).toBe(false); + }); +}); 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..4ab4ce15 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/color-blind-contrast.ts @@ -0,0 +1,122 @@ +// 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, parseColor } from '../../model/handler.js'; +import type { RuleDescriptor, RuleFinding } from './types.js'; + +const CVD_CONTRAST_MINIMUM = 3; + +type SimulationMatrix = readonly [ + readonly [number, number, number], + readonly [number, number, number], + readonly [number, number, number], +]; + +const COLOR_BLIND_SIMULATIONS: Array<{ name: string; matrix: SimulationMatrix }> = [ + { + name: 'protanopia', + matrix: [ + [0.567, 0.433, 0], + [0.558, 0.442, 0], + [0, 0.242, 0.758], + ], + }, + { + name: 'deuteranopia', + matrix: [ + [0.625, 0.375, 0], + [0.7, 0.3, 0], + [0, 0.3, 0.7], + ], + }, + { + name: 'tritanopia', + matrix: [ + [0.95, 0.05, 0], + [0, 0.433, 0.567], + [0, 0.475, 0.525], + ], + }, +]; + +/** + * Color-blind contrast — warns when simulated component backgroundColor/textColor + * pairs fall below the 3:1 floor. + */ +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 { name, matrix } of COLOR_BLIND_SIMULATIONS) { + const simBg = simulate(bgColor, matrix); + const simText = simulate(textColor, matrix); + const ratio = contrastRatio(simBg, simText); + if (ratio < CVD_CONTRAST_MINIMUM) { + findings.push({ + path: `components.${compName}`, + message: `component '${compName}' contrast ${ratio.toFixed(2)}:1 under ${name} simulation (bg ${simBg.hex} on fg ${simText.hex}) is below the 3:1 floor`, + }); + } + } + } + return findings; +} + +function simulate(color: ResolvedColor, matrix: SimulationMatrix): ResolvedColor { + const r = color.r / 255; + const g = color.g / 255; + const b = color.b / 255; + + const simR = matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b; + const simG = matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b; + const simB = matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b; + + return parseColor(toHex(simR, simG, simB)); +} + +function toHex(r: number, g: number, b: number): string { + return `#${toHexChannel(r)}${toHexChannel(g)}${toHexChannel(b)}`; +} + +function toHexChannel(value: number): string { + return Math.round(clamp(value) * 255) + .toString(16) + .padStart(2, '0'); +} + +function clamp(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function resolveToColor(value: ResolvedValue): ResolvedColor | null { + if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') { + return value as ResolvedColor; + } + return null; +} + +export const colorBlindContrastRule: RuleDescriptor = { + name: 'color-blind-contrast', + severity: 'warning', + description: 'Color-blind contrast — warns when simulated component backgroundColor/textColor pairs fall below the 3:1 floor.', + run: colorBlindContrastCheck, +}; diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 92a525ab..5dc1e44d 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -57,6 +57,7 @@ export const DEFAULT_RULES: LintRule[] = DEFAULT_RULE_DESCRIPTORS.map(toLintRule export { brokenRef } from './broken-ref.js'; export { missingPrimary } from './missing-primary.js'; export { contrastCheck } from './contrast-ratio.js'; +export { colorBlindContrastCheck, colorBlindContrastRule } from './color-blind-contrast.js'; export { orphanedTokens } from './orphaned-tokens.js'; export { tokenSummary } from './token-summary.js'; export { missingSections } from './missing-sections.js';