Skip to content

Perceptual color analysis: report perceptually-distinct color count + sprawl ratio #613

@Atroci

Description

@Atroci

Problem

values.colors.unique is keyed on the color string, so #3a7bd5, #3b7cd6 and rgb(58, 123, 213) are reported as three unique colors even though they're perceptually identical. There's currently no signal for how many perceptually distinct colors a stylesheet actually uses.

For design-system auditing that distinction is the whole story: "47 unique colors" is noise if 40 are accidental near-duplicates. "47 declared → 9 perceptually distinct (5.2× sprawl)" is actionable.

(Related to #522 on wide-gamut detection — adjacent and complementary: that's syntactic, this is perceptual.)

Proposal

An additive perceptual block under values.colors:

"perceptual": {
  "totalUnique": 9,     // perceptually-distinct colors (analyzable subset)
  "analyzable": 44,     // unique colors parseable to sRGB (hex + rgb/rgba in v1)
  "unanalyzable": 3,    // hsl/lab/oklch/named/system — reported, not hidden
  "sprawlRatio": 4.89   // analyzable / totalUnique; 1.0 = all distinct, >1 = near-dup redundancy
}

Implementation: CIELAB ΔE76 single-linkage clustering, inlined with zero new dependencies (~120 LOC) — colors converted sRGB→Lab and grouped under a ΔE76 ≈ 2.5 just-noticeable-difference threshold. v1 scopes to hex + rgb()/rgba(); hsl()/lab()/oklch()/named/system are counted as unanalyzable (honest about coverage) and are easy follow-ups once the core lands. Clustering is O(n²) in the number of unique analyzable colors (low hundreds in practice), run once per analyze().

Questions before I open a PR

  1. Would you prefer this wired into the analyze() output (changes the colors snapshot), or exposed as a standalone export to keep the output surface stable? Happy either way.
  2. ΔE76 (simple, zero-dep) vs ΔE2000 (more perceptually uniform, heavier)? I'd start with ΔE76 at a JND threshold for dedup-grade clustering, but will follow your call.

I have a working implementation + vitest tests ready to PR once you point me at the shape you'd prefer. Thanks for the project! 🙏

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions