@colorlint/core

Framework-agnostic core engine for UI color accessibility, contrast, and design-system palette analysis


Keywords
accessibility, a11y, wcag, contrast, contrast-ratio, color, color-contrast, design-system, design-tokens, css, dom, linter, a11y-testing, accessibility-checker, accessibility-testing, accessibility-tools, angular, color-contrast-checker, color-palettes, devtools, javascript, javascript-library, react, reactjs, typescript, vanilla-javascript, vue
License
MIT
Install
npm install @colorlint/core@0.1.2

Documentation

ColorLint

npm license node

A framework-agnostic UI color accessibility & design-system compatibility analyzer. It inspects rendered DOM/CSSOM state — via getComputedStyle, not source files — and reports insufficient contrast, out-of-palette colors, and where each color came from, independently of any UI framework.

Package Version What it is
@colorlint/core npm Framework-agnostic engine (zero deps) — see package README
@colorlint/react npm useColorAccessibility hook — see package README
@colorlint/angular npm ColorAccessibilityService — see package README
@colorlint/vue npm useColorAccessibility composable — see package README

Source: github.com/senthiljruby/colorlint

1. Problem statement

Design systems drift. A button's color might come from an inline style, a CSS Modules class, a styled-components rule, a design token, or plain inheritance — and by the time it reaches the browser, none of that provenance is visible in code review. Two questions matter and are usually conflated into one vague "accessibility issue":

  • Is this color accessible? (WCAG contrast against its real effective background)
  • Is this color allowed? (does it belong to the approved design-system palette?)

A color can fail either, both, or neither, independently — see "Accessibility vs. design system" in docs/ARCHITECTURE.md for why this library never merges the two into one verdict. This library answers both questions by inspecting what the browser actually renders, attributes each answer to a likely source (inline style / stylesheet rule / CSS variable / inheritance / pseudo-element), and produces ranked, actionable replacement suggestions — without ever touching the DOM or your CSS.

2. Architecture

Framework UI (React / Angular / Vue / Svelte / Vanilla / Web Components)
                        │
                        ▼
      Integration Layer (thin, optional framework adapters)
                        │
                        ▼
   ┌────────────────────────────────────────────────────┐
   │                Accessibility Core                   │
   │  DOM Inspector → Style Resolver → Color Parser       │
   │  → Contrast Analyzer + Palette Matcher → Rule Engine │
   │  → Suggestion Engine → Reporters                     │
   └────────────────────────────────────────────────────┘

@colorlint/core has zero runtime dependencies and no knowledge of React/Angular/Vue. Each adapter is a thin binding: it creates a core ColorAccessibilityAnalyzer, wires its lifecycle to the framework's (mount/unmount, refs, DI), and re-exports results. All analysis logic — color parsing, contrast math, background compositing, source attribution, palette matching, rule evaluation, suggestion ranking — lives in core and is unit-tested independently of any framework.

Modules inside core (packages/core/src/):

Module Responsibility
color/ Parse every CSS color syntax (hex/rgb/hsl/hwb/lab/lch/oklab/oklch/named/transparent/currentColor/var()) into normalized RGBA; OKLab conversion for perceptual distance.
contrast/ WCAG relative luminance + contrast ratio; configurable AA/AAA × normal/large thresholds.
dom/ Computed-style caching, effective-background compositing (walks translucent ancestor layers), color-source attribution, selector generation, SVG paint properties, element-role classification.
palette/ Perceptual (OKLab) nearest-color matching against an approved palette.
rules/ Pluggable AccessibilityRule engine + built-in WCAG-contrast and palette rules.
suggestions/ Ranked, read-only replacement-color suggestions and CSS fix snippets.
reporters/ JSON / console / HTML report rendering from the same AnalysisResult.
analyzer/ Orchestration: config normalization, traversal, caching, MutationObserver-driven incremental re-analysis, violation aggregation.

See docs/ARCHITECTURE.md for the full design rationale and the limitations that were deliberately designed around rather than glossed over.

3. Installation

npm install @colorlint/core
# optional, only if you use the corresponding framework:
npm install @colorlint/react
npm install @colorlint/angular
npm install @colorlint/vue

Framework adapters declare their framework as a peerDependency — installing @colorlint/core alone pulls in nothing else.

4. Vanilla JS example

import { createColorAccessibilityAnalyzer } from "@colorlint/core";

const analyzer = createColorAccessibilityAnalyzer({
  rules: {
    wcag: { enabled: true, level: "AA" },
    palette: {
      enabled: true,
      colors: [
        { name: "Text Primary", value: "#1D1D1F" },
        { name: "Text Secondary", value: "#6E6E73" },
      ],
    },
  },
});

const result = analyzer.analyze(document.body);
console.log(result.violations);
console.log(analyzer.generateReport({ format: "console" }));

A runnable version lives in examples/vanilla — open index.html via any static file server (ES module <script type="module"> requires http(s)://, not file://).

5. React example

import { useRef } from "react";
import { useColorAccessibility } from "@colorlint/react";

function Card() {
  const ref = useRef<HTMLDivElement>(null);
  const { violations } = useColorAccessibility(ref, {
    rules: { wcag: { enabled: true, level: "AA" } },
    observe: true, // keep re-analyzing on DOM mutation
  });

  return (
    <div ref={ref}>
      <p style={{ color: "#aaa" }}>Low-contrast text</p>
      {violations.length > 0 && <p>{violations.length} color issue(s) found</p>}
    </div>
  );
}

Full app: examples/react (npm run dev inside that folder).

6. Angular example

import { Component, ElementRef, AfterViewInit, ViewChild } from "@angular/core";
import { ColorAccessibilityService } from "@colorlint/angular";

@Component({ selector: "app-card", template: `<div #card><p style="color:#aaa">Low-contrast text</p></div>` })
export class CardComponent implements AfterViewInit {
  @ViewChild("card") card!: ElementRef<HTMLElement>;
  constructor(private a11y: ColorAccessibilityService) {}

  ngAfterViewInit() {
    const result = this.a11y.analyze(this.card.nativeElement);
    console.log(result.violations);
  }
}

ColorAccessibilityService is providedIn: 'root' — one shared analyzer per app by default. Call configure() once at startup for non-default rules/palette.

7. Vue example

<script setup lang="ts">
import { ref } from "vue";
import { useColorAccessibility } from "@colorlint/vue";

const cardRef = ref<HTMLElement | null>(null);
const { violations } = useColorAccessibility(cardRef, { observe: true });
</script>

<template>
  <div ref="cardRef">
    <p style="color:#aaa">Low-contrast text</p>
  </div>
  <p v-if="violations.length">{{ violations.length }} color issue(s) found</p>
</template>

8. Configuration

createColorAccessibilityAnalyzer({
  rules: {
    wcag: { enabled: true, level: "AA", thresholds: { aaNormal: 4.5, aaLarge: 3, aaaNormal: 7, aaaLarge: 4.5 } },
    palette: { enabled: true, colors: [...], nearMatchThreshold: 0.05 },
  },
  customRules: [myBrandRule()],
  ignore: { selectors: [".third-party-widget", "[data-a11y-ignore]"], ruleIds: [], properties: [] },
  include: { selectors: ["button", "a", "input", "[role='button']"] },
  performance: { maxElements: 10000, maxDepth: 200, observerDebounceMs: 150 },
  severityOverrides: { "wcag-contrast": "error", "palette-match": "warning" },
});

ignore always wins over include. include.selectors (when set) restricts analysis to only matching elements.

9. Rules

Two built-ins, both independent, both optional:

  • wcag-contrast — flags any color-bearing property whose analysis produced a failing ContrastResult (text uses WCAG text thresholds; borders/outlines/SVG paint use the WCAG 1.4.11 non-text 3:1 threshold). Severity: error.
  • palette-match — flags colors that are not-supported (severity warning) and surfaces near-match colors as info even though they technically "pass", nudging toward the canonical token value. An exact-match produces no finding at all.

Custom rules implement the same interface and run alongside the built-ins:

import type { AccessibilityRule } from "@colorlint/core";

const noBrightRed: AccessibilityRule = {
  id: "no-bright-red",
  evaluate(ctx) {
    const hex = ctx.propertyAnalysis.foreground?.hex;
    if (hex?.toLowerCase() === "#ff0000") {
      return { passes: false, severity: "warning", message: `${ctx.selector} uses pure red, which is reserved for error states.` };
    }
    return null; // not applicable
  },
};

createColorAccessibilityAnalyzer({ customRules: [noBrightRed] });

A rule that throws is caught per-call and skipped — it cannot abort the rest of the analysis.

10. Palette configuration

rules: {
  palette: {
    enabled: true,
    colors: [
      { name: "Primary", value: "#007AFF" },
      { name: "Text Primary", value: "#1D1D1F", token: "--color-text-primary" },
      "#6E6E73", // bare strings are accepted too
    ],
    nearMatchThreshold: 0.05, // OKLab Euclidean distance
  },
}

Matching uses OKLab perceptual distance, not raw RGB delta — an equal RGB channel difference is not equally noticeable at every lightness level, so a naive RGB-distance palette matcher over- or under-flags depending on how dark or light the color is.

11. Suggestions

Read-only, ranked, never applied automatically:

const suggestions = analyzer.getSuggestions(violation);
// [{ color: "#6E6E73", score: 0.97, reason: "...", fromPalette: true, predictedContrastRatio: 5.07 }, ...]

Ranking priority: approved palette membership → WCAG compliance → minimal visual change from the original color (an OKLab-space lightness search, not "just make it black"). For a concrete fix:

import { getFix } from "@colorlint/core";

const fix = getFix(violation, { strategy: "palette-first" }); // or accessibility-first / minimal-change / brand-first
// { property: "color", currentValue: "#777777", suggestedValue: "#6E6E73",
//   location: "stylesheet", cssSnippet: { before: "color: #777777;", after: "color: #6E6E73;" } }

location reflects where the fix should be applied (inline-style / stylesheet / css-variable / design-token), based on the violation's detected source.

12. Reports

analyzer.generateReport({ format: "object" });  // AnalysisResult (default)
analyzer.generateReport({ format: "json" });    // string
analyzer.generateReport({ format: "console" }); // prints + returns string
analyzer.generateReport({ format: "html" });    // self-contained HTML table, no external resources

AnalysisResult also includes aggregatedFindings: violations sharing one root cause (a CSS variable, a stylesheet rule, or a duplicated inline literal) are rolled up so a design-system fix can target the cause once —

{ "kind": "css-variable", "description": "...", "affectedElementCount": 37,
  "recommendation": "Fix the design token `--color-text-secondary` once rather than each affected component individually. (37 elements affected.)" }

13. MutationObserver

const handle = analyzer.observe(document.body, {
  debounceMs: 150,
  onViolation: (violation) => console.log(violation),
});
// later:
handle.disconnect();

Mutations are batched within debounceMs, and only the smallest set of dirty subtrees is re-analyzed (never the whole document) — a highly dynamic SPA doesn't pay for a full-tree walk on every re-render. observe() never overwrites the result of your last analyze() call; it's a separate, additive stream of incremental findings.

14. Performance

  • Computed styles are memoized per element (and per pseudo-element) via WeakMap for the lifetime of one analysis pass — no repeated forced-reflow-prone lookups.
  • Effective-background compositing is memoized per element and computed recursively, so shared ancestors are only composited once even across thousands of siblings.
  • performance.maxElements / maxDepth cap a single pass; exceeding the cap sets summary.truncated = true rather than hanging.
  • ignore/include selectors prune traversal, not just filter results afterward.

15. Browser compatibility & limitations

Targets evergreen browsers with getComputedStyle, MutationObserver, and ShadowRoot support (all current Chrome/Firefox/Safari/Edge). Explicit, honest limitations rather than silently-wrong results:

  • Transparent/gradient/image backgrounds: effective-background compositing walks ancestor background-color layers only. A background-image (gradient or photo) in that chain is flagged via a note and lowers confidence — it is never guessed at.
  • Pseudo-elements: getComputedStyle(el, '::before'|'::after') is used per spec; ::placeholder and other pseudo-elements are not attempted, since CSSOM support for reading them is inconsistent across browsers.
  • Source attribution is best-effort: stylesheet-rule matching approximates the cascade via source order + element.matches(), without full CSS specificity/!important resolution — used only for human-readable "where did this come from" attribution, never for the actual color value (which always comes from getComputedStyle). Cross-origin stylesheets can't be inspected (cssRules throws) and are skipped with a recorded warning, not silently dropped.
  • Closed Shadow DOM is fundamentally invisible to page scripts by browser design — element.shadowRoot is null for both "no shadow root" and "closed shadow root", so summary.shadowRootsSkipped cannot be a reliable count. Open shadow roots are traversed recursively.
  • Interactive states (:hover, :focus, :active, :visited) cannot be read without forcing that state — the library does not fabricate results for states it cannot observe; violations tied to guessed semantic roles carry confidence: "low" or "medium" instead of asserting certainty.
  • Semantic intent (is this text a heading vs. decorative marketing copy?) is inferred only from DOM-observable facts (tag, role, computed font-size/weight, disabled) — never from guessed intent.
  • jsdom test environments: getComputedStyle(el, pseudo) is not implemented in jsdom (throws), and jsdom's CSS parser doesn't resolve inheritance, gradients, or var() at the property-setter level. This library's own test suite works around these with explicit stubs where needed (see comments in packages/core/tests/dom/pseudo-element.test.ts); real browsers do not have these limitations.

16. Extending rules

Implement AccessibilityRule (see §9) and pass it via customRules. A rule can also provide getSuggestions(context) to override the default suggestion engine for its own findings. This is the intended extension point for APCA, WCAG 3, brand-specific, or platform-specific (e.g. Apple HIG) policies without modifying core.

17. API reference

createColorAccessibilityAnalyzer(config?: AnalyzerConfig): ColorAccessibilityAnalyzer

class ColorAccessibilityAnalyzer {
  analyze(root?: Element | Document): AnalysisResult;
  analyzeSelector(selector: string, root?: ParentNode): AnalysisResult;
  analyzeElement(element: Element): AnalysisResult;
  getViolations(): ColorViolation[];
  getAggregatedFindings(): AggregatedFinding[];
  getSuggestions(violation: ColorViolation, options?: SuggestOptions): ColorSuggestion[];
  generateReport(options?: ReportOptions): AnalysisResult | string;
  observe(root: Element, options?: ObserveOptions): ObserveHandle;
}

Tree-shakable leaf exports (no analyzer instance required): parseColor, contrastRatio, evaluateContrast, matchPalette, suggest, getFix, toJSON, toHTML, formatConsoleReport. Full type definitions ship as .d.ts — see packages/core/src/types/ for the source of truth (ColorInfo, ColorSource, ContrastResult, PaletteMatchResult, ElementColorAnalysis, ColorViolation, AggregatedFinding, AnalyzerConfig, AccessibilityRule).

18. Limitations (summary)

This is a static, read-only analyzer of rendered DOM state. It does not: modify your DOM/CSS, simulate interactive states, see through images/canvas/video, resolve full CSS cascade specificity, or guarantee semantic-role accuracy. Where certainty isn't possible, it reports lower confidence instead of asserting a wrong answer — see §15 for the full list.

Development

npm install
npm run build          # builds every package
npm run test           # runs @colorlint/core's Jest suite (88 tests)

License

MIT