npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@instruments/colorscope

v7.2.0

Published

Color analysis toolkit for Node.js and browser-safe subpath imports. Colorscope extracts dominant colors, names and classifies colors with OKLab/OKLCH geometry, builds 128D palette embeddings, and exposes deterministic palette analysis utilities.

Readme

@instruments/colorscope

Color analysis toolkit for Node.js and browser-safe subpath imports. Colorscope extracts dominant colors, names and classifies colors with OKLab/OKLCH geometry, builds 128D palette embeddings, and exposes deterministic palette analysis utilities.

Install

pnpm add @instruments/colorscope

sharp is an optional peer dependency: math, conversion, naming, analysis, and embedding subpaths work without it, so browser and math-only consumers skip the native install entirely. Image extraction (extraction/, border/, and the root barrel, which re-exports them) requires it — add it alongside colorscope in Node/server code:

pnpm add @instruments/colorscope sharp

Breaking change (since sharp became optional): importing the root barrel without sharp installed now fails at import time. Either install sharp, or switch to subpath imports (@instruments/colorscope/math, /convert, /naming, …) if you don't use extraction.

Use the root import from Node/server code, and use subpath imports from browser or React client components.

Quick Start

import { extractColorsFromUrl, withColorNames } from "@instruments/colorscope";
import { analyzePalette } from "@instruments/colorscope/analysis";
import { paletteToEmbedding } from "@instruments/colorscope/embedding";

const { colors } = await extractColorsFromUrl("https://example.com/product.jpg");
const named = withColorNames(colors);

const analysis = analyzePalette(named);
const embedding = paletteToEmbedding(named);

Client-Safe Imports

import type { QuantizedColor } from "@instruments/colorscope/types";
import { analyzePalette } from "@instruments/colorscope/analysis";
import { hexToOklab } from "@instruments/colorscope/math";
import { getColorName, getColorAttributes } from "@instruments/colorscope/naming";
import { paletteToEmbedding } from "@instruments/colorscope/embedding";
import { generateHarmony } from "@instruments/colorscope/harmony";

Server-only catalogue helpers live under @instruments/colorscope/naming/server because they load vendored library data from the package.

import { createColorResolver } from "@instruments/colorscope/naming";
import { getLibrary } from "@instruments/colorscope/naming/server";

const resolver = createColorResolver({
  libraries: [getLibrary("core"), getLibrary("farrow-ball")].filter(Boolean),
});

Published Subpaths

Every subpath below is a separate entry point — importing one pulls only its own graph. The Runtime deps column is measured from the built dist/, not declared by hand, so it tells you exactly what a given import costs you.

  • Client-safe subpaths reach no Node builtin and no native module. Safe in a browser bundle, a service worker, or an edge runtime.
  • Server-only subpaths need sharp (native) or node:fs. They will break a browser build.
  • Nothing but colorscope in the deps column means the module is a leaf: it brings no third-party code with it at all. If you have a rule about dependency-free modules, these satisfy it.

| Subpath | Runtime deps | Client-safe | Purpose | | ------------------------------------------- | ----------------------------------------------------- | ----------- | --------------------------------------------------------------------------- | | @instruments/colorscope | sharp, color-name-list, fuse.js, node net stack | No | Full Node/server barrel, including extraction | | @instruments/colorscope/extraction | sharp, node net stack | No | Sharp-backed image color and spectrum extraction | | @instruments/colorscope/border | sharp, node net stack | No | Uniform border detection for product imagery | | @instruments/colorscope/naming/semantic | fuse.js, node:fs, node:path | No | Hybrid lexical and semantic text resolver; reads baked vectors from disk | | @instruments/colorscope/extraction/pixels | none | Yes | Browser extraction from canvas ImageData, with on-image anchors | | @instruments/colorscope/math | none | Yes | HSL, RGB, OKLab math and perceptual distance | | @instruments/colorscope/convert | none | Yes | OKLab, OKLCH, RGB, HSL, and hex conversion | | @instruments/colorscope/analysis | none | Yes | Palette temperature, vibrancy, brightness, mood, clustering, and comparison | | @instruments/colorscope/embedding | none | Yes | 128D palette and region embeddings | | @instruments/colorscope/taxonomy | none | Yes | Deterministic taxonomy descriptions, family ownership, and rule metadata | | @instruments/colorscope/accessibility | none | Yes | WCAG contrast and color-blindness simulation | | @instruments/colorscope/format | none | Yes | CSS, Tailwind, and SCSS palette exports | | @instruments/colorscope/harmony | none | Yes | Color harmonies and tonal palettes | | @instruments/colorscope/palette | none | Yes | Palette grids, anchor palettes, and distribution distance | | @instruments/colorscope/sorting | none | Yes | Palette sorting utilities | | @instruments/colorscope/spectrum | none | Yes | Client-safe OKLCH spectrum model | | @instruments/colorscope/mood | none | Yes | Mood presets and OKLab region helpers | | @instruments/colorscope/temperature | none | Yes | Warm/cool and light/dark appearance shifts | | @instruments/colorscope/tolerance | none | Yes | UI tolerance slider to OKLab threshold mapping | | @instruments/colorscope/types | none | Yes | Shared TypeScript data shapes | | @instruments/colorscope/naming/human | color-name-list | Yes | Human-facing color naming | | @instruments/colorscope/naming | color-name-list, fuse.js | Yes | Canonical taxonomy, lexical attributes, and resolver core | | @instruments/colorscope/naming/server | color-name-list, fuse.js | Yes | Built-in catalogue libraries for server runtimes | | @instruments/colorscope/match | color-name-list, fuse.js | Yes | Match targets for color, gradient, image, name, and palette queries | | @instruments/colorscope/profile | color-name-list, fuse.js | Yes | Color intent profiles and palette intent scoring | | @instruments/colorscope/text-seed | color-name-list, fuse.js | Yes | Deterministic colors and palettes from stable text identifiers |

What /math and /convert already give you

Check here before writing a colour helper. Three separate reimplementations of hslToHex were found in one consumer — and one of them imported this package's version on line 1 and exported its own on line 72. The canonical thing being one import away is not enough if nobody can see the list.

Both subpaths are leaves: zero external runtime dependencies.

@instruments/colorscope/convert — format conversion, all null on unparseable input:

hexToRgb · hexToHsl · hexToOklch · hexToOklabTriple · rgbToHex · oklabToHex · oklabToRgb · oklabToHsl · oklabToOklch · oklchToOklab · oklabToLinearRgb · linearToSrgb · isValidHex · normalizeHex · toOklabTriple · fromOklabTriple

@instruments/colorscope/math — colour maths and perceptual measures:

hexToOklab · hslToHex · hslToRgb · hslToOklab · rgbToHsl · rgbToOklab · linearRgbToOklab · srgbToLinear · oklabDistance · oklabDistanceSq · oklabChroma · getOklabLightness · hexLightness · hexChroma · hexHue · hexWarmth · weightedLightness · weightedChroma · weightedWarmth · quantizeHsl

Note the split: hexToOklab is in /math, hexToOklch is in /convert. That is not obvious and has cost a consumer a cycle. If you reach for one and get a module-not-found, try the other subpath before assuming it doesn't exist.

What the hex parsers accept

Every hex-taking function in /convert and /mathhexToRgb, hexToHsl, hexToOklab, hexToOklch, hexToOklabTriple, isValidHex — accepts all four of these, and returns null on anything else:

| Input | Accepted | | ----------- | -------- | | "#a3522b" | Yes | | "a3522b" | Yes | | "#abc" | Yes | | "abc" | Yes |

You do not need to normalize before calling. In particular you need neither a withHash-style prepender nor a .replace("#", "")-style stripper — both are unnecessary, and consumers have written both, in the same codebase, each believing the opposite about this API. If you want an explicitly normalized string for storage or display, normalizeHex is exported for that purpose; it is not a precondition of parsing.

What the numeric inputs accept

Three consumers independently wrote a normalize-and-delegate wrapper around the HSL entry points, each believing colorscope required in-range input. As of 3.17.0 none of those wrappers is needed. This table is the answer to "which functions assume in-range input", so that nobody writes a fourth.

| Function | Out-of-range input | Non-finite input | | ----------------------------------------------- | ------------------------- | ---------------- | | hslToRgb(h, s, l) | Normalized | Throws | | hslToHex · hslToOklab · getOklabLightness | Normalized (delegate) | Throws | | getFamilyOwnership(oklch) | — | Throws | | scoreFromDistance(distance, ceiling) | Clamped at 0 | Throws | | hex parsers (see table above) | — | Returns null | | srgbToLinear · rgbToOklab · rgbToHsl | Extrapolates | Propagates NaN | | linearRgbToOklab · oklabDistance | Extrapolates | Propagates NaN |

Normalized means hue wraps into 0–360 (so -30 and 400 behave as 330 and 40) and saturation/lightness clamp to 0–100. Fixed in hslToRgb itself rather than at each exit, so everything that delegates to it is covered.

Extrapolates means the function computes faithfully outside its documented domain and returns an out-of-gamut result rather than a wrong one. Measured:

srgbToLinear(-10)                    -0.00303      finite, not NaN
srgbToLinear(300)                     1.4495       finite, not NaN
linearRgbToOklab(-0.5, 0.2, 0.2)     { L: 0.1997, a: -1.5532, b: -0.1381 }

Math.cbrt is used rather than ** (1/3) precisely so negative intermediates stay finite — the latter returns NaN for a negative base, and NaN in a stored OKLab triple serialises to null and cannot be caught by an equality check.

One round-trip hazard. rgbToHsl does not clamp its output:

rgbToHsl(-20, 0, 0)                  { h: 180, s: -100, l: -4 }

So an rgb→hsl→rgb round trip can feed out-of-range HSL back in without the caller ever passing bad HSL themselves. On 3.17.0 hslToRgb absorbs it; below 3.17.0 that is a live path into the wrong-colour bug that no amount of validating your own inputs would have prevented.

That distinction is the whole reason HSL needed the fix and these do not. The HSL algorithm branches on the input range — a chain of < 60, < 120 comparisons that an unwrapped 400 falls straight through — so out-of-range input selected the wrong branch and returned a confidently wrong colour. The linear transforms have no such branch; they extrapolate smoothly. Range bugs live where the algorithm has range-dependent branching, which is worth knowing before wrapping anything defensively.

Below 3.17.0, hslToHex(400, 100, 50) returns a magenta and hslToHex(120, -50, 50) a purple, both well-formed and both wrong. On 3.17.0:

hslToHex(400, 100, 50)               "#ffaa00"    identical to hslToHex(40, …)
hslToHex(120, -50, 50)               "#808080"    grey, as clamping implies

If you are pinned below 3.17.0 and passing unvalidated HSL, that is a live defect and the fix is the bump, not a wrapper.

Canonical HSL→RGB rounding

Independent reimplementations of HSL→RGB agree in real arithmetic and disagree by ±1 on one channel for roughly 0.03–0.08% of in-range colours, because three algebraically equivalent formulations land on opposite sides of a rounding boundary. Measured across 3.67M inputs by a consumer: 1,097 and 3,050 disagreements against two of their own implementations.

There is no external authority that makes one of these correct. CSS Color 4 defines HSL→RGB in real numbers; quantisation to 8-bit is implementation-defined. So this is a ruling on what is canonical, not a claim about what is right — anyone matching it gets identical bytes, which is the property that actually matters.

The canonical formulation is what hslToRgb does, and it is fully specified by four rules:

  1. Work in the 0–1 domain throughout. Hue is normalized to 0–360, saturation and lightness to 0–1.
  2. Chroma/hue-sector decomposition: c = (1 − |2L − 1|) × S, x = c × (1 − |((h ÷ 60) mod 2) − 1|), m = L − c ÷ 2, with the sector chosen by h < 60, < 120, < 180, < 240, < 300, else.
  3. Round exactly once, at the end: Math.round((channel + m) × 255). Add m before scaling; never round an intermediate, and never round channel and m separately before adding.
  4. Ties round up. JavaScript's Math.round breaks halves toward +∞, and every value here is non-negative, so x.5 always goes up.

Rule 3 is where the reimplementations diverge. Scaling to 255 before adding m, or rounding the sector value independently, changes which side of .5 a channel lands on — invisibly, on a small minority of inputs, in a direction no test built from consensus fixtures can detect.

This will not change without a major version. A rounding change is treated as breaking rather than as a patch, precisely because the consumers most likely to be affected are the ones whose fixtures cannot see it: assertions written on values every implementation agrees about, like (0, 0, 50) and (120, 100, 50), stay green through a change that shifts thousands of other colours.

Index-building cost

The four subpaths carrying fuse.js (/naming, /match, /profile, /text-seed) build a fuzzy index over the full lexicon on first use. That index is large and its construction is not free — it is worth reaching for deliberately, on a typeahead path or behind a warm cache, rather than incidentally from a request handler that only needed one conversion. If all you need is a conversion or a distance, /convert and /math cost nothing.

"node net stack"

dns, net, http, and https, reached through the SSRF guards that validate remote image URLs before extraction fetches them. They come with extraction, not with colour maths.

Embeddings

paletteToEmbedding() returns a fixed 128D L2-normalized vector. Store EMBEDDING_VERSION next to persisted vectors and rebuild indexes when it changes.

import {
  EMBEDDING_VERSION,
  embeddingSimilarity,
  paletteToEmbedding,
} from "@instruments/colorscope/embedding";

const a = paletteToEmbedding(["#c67a5c", "#8a9e82"]);
const b = paletteToEmbedding(["#bf7458", "#809775"]);

console.log(EMBEDDING_VERSION); // "oklch-anchor-quantiles-v11"
console.log(embeddingSimilarity(a, b));

Semantic Naming

Semantic color resolution uses baked openai/text-embedding-3-small concept vectors and a caller-provided encoder.

import { createHybridColorResolver } from "@instruments/colorscope/naming/semantic";

const resolver = createHybridColorResolver({
  encode: (text) => embedWithYourProvider(text),
});

await resolver.resolveColorName("warm limestone");

Generate the package and docs with:

pnpm --filter @instruments/colorscope build
pnpm --filter @instruments/colorscope generate:api-docs