import { MODES } from '../constants'; import type { Mode } from '../types'; import { contrastRatio, parseCssColor, toLinearSrgbChannel, type RgbColor } from '../utils/color'; import { isRecord } from '../utils/common'; import type { ExpandedThemeOverride, OverrideDiagnostic, ParsedThemeOverride, RuleDefinition, SourceSetDefinition, } from './contracts'; import { createOverrideDiagnostic } from './diagnostics'; function selectionReferences(value: unknown): readonly string[] { if (!isRecord(value)) return []; if (isRecord(value.source) && typeof value.source.ref === 'string') { return [value.source.ref]; } if ( isRecord(value.light) && typeof value.light.ref === 'string' && isRecord(value.dark) && typeof value.dark.ref === 'string' ) { return [value.light.ref, value.dark.ref]; } if (Array.isArray(value.slots)) { return value.slots.flatMap((slot) => isRecord(slot) && typeof slot.ref === 'string' ? [slot.ref] : [], ); } if (Array.isArray(value.light) && Array.isArray(value.dark)) { const slots = [...(value.light as unknown[]), ...(value.dark as unknown[])]; return slots.flatMap((slot) => isRecord(slot) && typeof slot.ref === 'string' ? [slot.ref] : [], ); } return []; } function toOklab(rgb: RgbColor): readonly [number, number, number] { const r = toLinearSrgbChannel(rgb.r); const g = toLinearSrgbChannel(rgb.g); const b = toLinearSrgbChannel(rgb.b); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } function deltaE(left: RgbColor, right: RgbColor): number { const leftLab = toOklab(left); const rightLab = toOklab(right); return ( Math.sqrt( (leftLab[0] - rightLab[0]) ** 2 + (leftLab[1] - rightLab[1]) ** 2 + (leftLab[2] - rightLab[2]) ** 2, ) * 100 ); } function effectiveSeverity(rule: RuleDefinition): 'error' | 'warning' { return rule.calibration === 'provisional' ? 'warning' : rule.severity; } export function evaluateIntentRules( rules: readonly RuleDefinition[], parsed: ParsedThemeOverride, getSourceSet: (id: string) => SourceSetDefinition | undefined, ): readonly OverrideDiagnostic[] { const diagnostics: OverrideDiagnostic[] = []; for (const rule of rules) { if (rule.type !== 'allowed-values' || rule.calibration === 'deferred') continue; const selection = parsed.selections.known[rule.selectionId]; if (selection === undefined) continue; const sourceSet = getSourceSet(rule.sourceSetId); const allowed = new Set(sourceSet?.references ?? []); for (const reference of selectionReferences(selection)) { if (allowed.has(reference)) continue; diagnostics.push( createOverrideDiagnostic('override.source_not_allowed', { severity: effectiveSeverity(rule), intentPath: `selections.${rule.selectionId}`, ruleId: rule.id, message: `"${reference}" is not allowed by source set "${rule.sourceSetId}".`, }), ); } } return diagnostics; } function ruleDiagnostic( rule: Exclude, expanded: ExpandedThemeOverride, mode: Mode, tokenPath: string, relatedTokenPaths: readonly string[], message: string, ): OverrideDiagnostic { return createOverrideDiagnostic('override.rule_violation', { severity: effectiveSeverity(rule), mode, tokenPath, relatedTokenPaths, ruleId: rule.id, provenance: expanded.provenance.resolved[mode][tokenPath], artifactRevision: expanded.revision.id, message, }); } export function evaluateResolvedRules( rules: readonly RuleDefinition[], expanded: ExpandedThemeOverride, ): readonly OverrideDiagnostic[] { const diagnostics: OverrideDiagnostic[] = []; for (const rule of rules) { if (rule.type === 'allowed-values' || rule.calibration === 'deferred') continue; for (const mode of MODES) { const state = expanded.resolved[mode]; if (rule.type === 'contrast') { const source = parseCssColor(state[rule.source]); const against = parseCssColor(state[rule.against]); if (!source || !against || contrastRatio(source, against) >= rule.minRatio) continue; diagnostics.push( ruleDiagnostic( rule, expanded, mode, rule.source, [rule.against], `${rule.description}: contrast is below ${String(rule.minRatio)}:1.`, ), ); } else if (rule.type === 'proximity') { const source = parseCssColor(state[rule.source]); const against = parseCssColor(state[rule.against]); if (!source || !against || deltaE(source, against) >= rule.minDeltaE) continue; diagnostics.push( ruleDiagnostic( rule, expanded, mode, rule.source, [rule.against], `${rule.description}: visual distance is below ${String(rule.minDeltaE)}.`, ), ); } else { for (let leftIndex = 0; leftIndex < rule.targets.length; leftIndex += 1) { const leftPath = rule.targets[leftIndex]; const left = parseCssColor(state[leftPath]); if (!left) continue; for (let rightIndex = leftIndex + 1; rightIndex < rule.targets.length; rightIndex += 1) { const rightPath = rule.targets[rightIndex]; const right = parseCssColor(state[rightPath]); if (!right || deltaE(left, right) >= rule.minDeltaE) continue; diagnostics.push( ruleDiagnostic( rule, expanded, mode, leftPath, [rightPath], `${rule.description}: ${leftPath} and ${rightPath} are below ${String(rule.minDeltaE)}.`, ), ); } } } } } return diagnostics; }