@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/colorscopesharp 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 sharpBreaking change (since sharp became optional): importing the root barrel without
sharpinstalled now fails at import time. Either installsharp, 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) ornode: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 /math — hexToRgb, 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 impliesIf 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:
- Work in the 0–1 domain throughout. Hue is normalized to 0–360, saturation and lightness to 0–1.
- 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 byh < 60,< 120,< 180,< 240,< 300, else. - Round exactly once, at the end:
Math.round((channel + m) × 255). Addmbefore scaling; never round an intermediate, and never roundchannelandmseparately before adding. - Ties round up. JavaScript's
Math.roundbreaks halves toward+∞, and every value here is non-negative, sox.5always 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