feat: release Colour Tools 0.1.0

This commit is contained in:
2026-08-31 21:21:35 +02:00
commit 0c1fc94b58
100 changed files with 17406 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import {
asColor,
clamp,
colorFrom,
lineariseSrgb,
srgbChannels,
toColourValue,
} from "./internal";
import { compositeSourceOver } from "./composite";
import { formatColour, toSrgbCss } from "./format";
import { mapToGamut } from "./gamut";
import { parseColour } from "./parse";
import type {
ColourInput,
ColourValue,
ContrastOptions,
ContrastReport,
ContrastSuggestion,
} from "./types";
const WHITE: ColourValue = { space: "srgb", coords: [1, 1, 1], alpha: 1 };
export function flattenColour(
foreground: ColourInput,
background: ColourInput,
): ColourValue {
// Browser alpha compositing for ordinary CSS sRGB colours is performed in the
// encoded colour space; contrast must inspect the pixels users actually see.
return compositeSourceOver(foreground, background, { space: "srgb" }).colour;
}
function opaqueCanvas(input: ColourInput | undefined): ColourValue {
const canvas = parseColour(input ?? WHITE);
return canvas.alpha >= 1 ? canvas : flattenColour(canvas, WHITE);
}
function flattenedPair(
foreground: ColourInput,
background: ColourInput,
options: ContrastOptions,
): { foreground: ColourValue; background: ColourValue } {
const canvas = opaqueCanvas(options.canvas);
const flattenedBackground = flattenColour(background, canvas);
const flattenedForeground = flattenColour(foreground, flattenedBackground);
return { foreground: flattenedForeground, background: flattenedBackground };
}
export function relativeLuminance(input: ColourInput): number {
const [red, green, blue] = srgbChannels(parseColour(input));
return (
0.2126 * lineariseSrgb(red) +
0.7152 * lineariseSrgb(green) +
0.0722 * lineariseSrgb(blue)
);
}
export function contrastRatio(
foreground: ColourInput,
background: ColourInput,
options: ContrastOptions = {},
): number {
const pair = flattenedPair(foreground, background, options);
const first = relativeLuminance(pair.foreground);
const second = relativeLuminance(pair.background);
const lighter = Math.max(first, second);
const darker = Math.min(first, second);
return (lighter + 0.05) / (darker + 0.05);
}
export function contrastReport(
foreground: ColourInput,
background: ColourInput,
options: ContrastOptions = {},
): ContrastReport {
const foregroundValue = parseColour(foreground);
const backgroundValue = parseColour(background);
const pair = flattenedPair(foregroundValue, backgroundValue, options);
const ratio = contrastRatio(foregroundValue, backgroundValue, options);
return {
ratio,
foreground: foregroundValue,
background: backgroundValue,
flattenedForeground: pair.foreground,
flattenedBackground: pair.background,
passes: {
aaLarge: ratio >= 3,
aaaLarge: ratio >= 4.5,
aaNormal: ratio >= 4.5,
aaaNormal: ratio >= 7,
nonText: ratio >= 3,
},
};
}
interface Candidate {
colour: ColourValue;
ratio: number;
difference: number;
lightness: number;
}
function candidateAtLightness(
original: ColourValue,
oklch: readonly number[],
lightness: number,
background: ColourInput,
target: number,
options: ContrastOptions,
): Candidate | undefined {
const candidate = mapToGamut(
toColourValue(
colorFrom(
"oklch",
[lightness, oklch[1] ?? 0, oklch[2] ?? 0],
original.alpha,
),
),
);
candidate.alpha = original.alpha;
const ratio = contrastRatio(candidate, background, options);
if (ratio + 1e-9 < target) return undefined;
return {
colour: candidate,
ratio,
difference: asColor(original).deltaEOK(asColor(candidate)),
lightness,
};
}
export function nearestPassingColour(
foreground: ColourInput,
background: ColourInput,
target = 4.5,
options: ContrastOptions = {},
): ContrastSuggestion | null {
if (!Number.isFinite(target) || target < 1 || target > 21) {
throw new RangeError("Contrast target must be between 1 and 21.");
}
const original = parseColour(foreground);
const originalRatio = contrastRatio(original, background, options);
const originalOklch = asColor(original)
.to("oklch")
.coords.map((coordinate) => Number(coordinate ?? 0));
const originalLightness = clamp(originalOklch[0] ?? 0);
if (originalRatio >= target) {
return {
colour: original,
css: toSrgbCss(original),
hex: formatColour(original, original.alpha < 1 ? "hex8" : "hex"),
ratio: originalRatio,
deltaEOK: 0,
direction: originalLightness >= 0.5 ? "lighter" : "darker",
};
}
let best: Candidate | undefined;
const consider = (candidate: Candidate | undefined): void => {
if (!candidate) return;
if (
!best ||
candidate.difference < best.difference - 1e-9 ||
(Math.abs(candidate.difference - best.difference) <= 1e-9 &&
candidate.ratio < best.ratio)
) {
best = candidate;
}
};
for (let index = 0; index <= 400; index += 1) {
consider(
candidateAtLightness(
original,
originalOklch,
index / 400,
background,
target,
options,
),
);
}
if (!best) return null;
const coarse = best as Candidate;
const lower = clamp(coarse.lightness - 1 / 400);
const upper = clamp(coarse.lightness + 1 / 400);
for (let index = 0; index <= 100; index += 1) {
const lightness = lower + ((upper - lower) * index) / 100;
consider(
candidateAtLightness(
original,
originalOklch,
lightness,
background,
target,
options,
),
);
}
const chosen = best as Candidate;
return {
colour: chosen.colour,
css: toSrgbCss(chosen.colour),
hex: formatColour(chosen.colour, chosen.colour.alpha < 1 ? "hex8" : "hex"),
ratio: chosen.ratio,
deltaEOK: chosen.difference,
direction: chosen.lightness >= originalLightness ? "lighter" : "darker",
};
}
+4
View File
@@ -0,0 +1,4 @@
declare module "colorjs.io/dist/color.js" {
export { default } from "colorjs.io";
export type * from "colorjs.io";
}
+115
View File
@@ -0,0 +1,115 @@
import {
asColor,
clamp,
encodeSrgb,
lineariseSrgb,
srgbChannels,
} from "./internal";
import { mapToGamut } from "./gamut";
import { parseColour } from "./parse";
import type {
ColourCoordinates,
ColourInput,
ColourValue,
ColourVisionDeficiency,
ColourVisionOptions,
DeltaEMethod,
} from "./types";
export function deltaE(
first: ColourInput,
second: ColourInput,
method: DeltaEMethod = "2000",
): number {
const left = asColor(parseColour(first));
const right = asColor(parseColour(second));
switch (method) {
case "76":
return left.deltaE76(right);
case "cmc":
return left.deltaECMC(right);
case "2000":
return left.deltaE2000(right);
case "ok":
return left.deltaEOK(right);
case "itp":
return left.deltaEITP(right);
case "jz":
return left.deltaEJz(right);
}
}
type Matrix = readonly [
readonly [number, number, number],
readonly [number, number, number],
readonly [number, number, number],
];
// Full-severity Machado et al. matrices, applied to linear-light sRGB.
const VISION_MATRICES: Record<ColourVisionDeficiency, Matrix> = {
protanopia: [
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
deuteranopia: [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.01182, 0.04294, 0.968881],
],
tritanopia: [
[1.255528, -0.076749, -0.178779],
[-0.078411, 0.930809, 0.147602],
[0.004733, 0.691367, 0.3039],
],
achromatopsia: [
[0.2126, 0.7152, 0.0722],
[0.2126, 0.7152, 0.0722],
[0.2126, 0.7152, 0.0722],
],
};
function multiply(
matrix: Matrix,
vector: ColourCoordinates,
): ColourCoordinates {
return matrix.map(
(row) => row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2],
) as ColourCoordinates;
}
export function simulateColourVision(
input: ColourInput,
deficiency: ColourVisionDeficiency,
options: ColourVisionOptions = {},
): ColourValue {
const source = parseColour(input);
const severity = clamp(options.severity ?? 1);
const encoded = srgbChannels(source);
const linear = encoded.map(lineariseSrgb) as ColourCoordinates;
const simulated = multiply(VISION_MATRICES[deficiency], linear);
const mixed = linear.map(
(channel, index) =>
channel + ((simulated[index] ?? channel) - channel) * severity,
);
const result: ColourValue = {
space: "srgb",
coords: mixed.map(encodeSrgb) as ColourCoordinates,
alpha: source.alpha,
};
return options.mapToSrgb === false
? result
: mapToGamut(result, { method: "clip" });
}
export function simulateColourVisionSet(
input: ColourInput,
options: ColourVisionOptions = {},
): Record<ColourVisionDeficiency, ColourValue> {
return {
protanopia: simulateColourVision(input, "protanopia", options),
deuteranopia: simulateColourVision(input, "deuteranopia", options),
tritanopia: simulateColourVision(input, "tritanopia", options),
achromatopsia: simulateColourVision(input, "achromatopsia", options),
};
}
+244
View File
@@ -0,0 +1,244 @@
import { clamp, encodeSrgb, lineariseSrgb, srgbChannels } from "./internal";
import { formatColour, toSrgbCss } from "./format";
import { parseColour } from "./parse";
import type {
BlendMode,
ColourCoordinates,
ColourInput,
ColourValue,
CompositeLayer,
CompositeOptions,
CompositeResult,
CompositingSpace,
SourceOverOptions,
} from "./types";
function luminosity([red, green, blue]: ColourCoordinates): number {
return 0.3 * red + 0.59 * green + 0.11 * blue;
}
function saturation(channels: ColourCoordinates): number {
return Math.max(...channels) - Math.min(...channels);
}
function clipColour(channels: ColourCoordinates): ColourCoordinates {
const lightness = luminosity(channels);
const minimum = Math.min(...channels);
const maximum = Math.max(...channels);
let output = [...channels] as ColourCoordinates;
if (minimum < 0) {
output = output.map(
(channel) =>
lightness + ((channel - lightness) * lightness) / (lightness - minimum),
) as ColourCoordinates;
}
if (maximum > 1) {
output = output.map(
(channel) =>
lightness +
((channel - lightness) * (1 - lightness)) / (maximum - lightness),
) as ColourCoordinates;
}
return output;
}
function setLuminosity(
channels: ColourCoordinates,
target: number,
): ColourCoordinates {
const difference = target - luminosity(channels);
return clipColour(
channels.map((channel) => channel + difference) as ColourCoordinates,
);
}
function setSaturation(
channels: ColourCoordinates,
target: number,
): ColourCoordinates {
const indices = [0, 1, 2] as const;
const sorted = [...indices].sort(
(first, second) => channels[first] - channels[second],
);
const minimumIndex = sorted[0] ?? 0;
const middleIndex = sorted[1] ?? 1;
const maximumIndex = sorted[2] ?? 2;
const minimum = channels[minimumIndex];
const maximum = channels[maximumIndex];
const output: ColourCoordinates = [0, 0, 0];
if (maximum > minimum) {
output[middleIndex] =
((channels[middleIndex] - minimum) * target) / (maximum - minimum);
output[maximumIndex] = target;
}
output[minimumIndex] = 0;
return output;
}
function softLight(backdrop: number, source: number): number {
if (source <= 0.5)
return backdrop - (1 - 2 * source) * backdrop * (1 - backdrop);
const d =
backdrop <= 0.25
? ((16 * backdrop - 12) * backdrop + 4) * backdrop
: Math.sqrt(backdrop);
return backdrop + (2 * source - 1) * (d - backdrop);
}
function blendChannel(
backdrop: number,
source: number,
mode: BlendMode,
): number {
switch (mode) {
case "multiply":
return backdrop * source;
case "screen":
return backdrop + source - backdrop * source;
case "overlay":
return backdrop <= 0.5
? 2 * backdrop * source
: 1 - 2 * (1 - backdrop) * (1 - source);
case "darken":
return Math.min(backdrop, source);
case "lighten":
return Math.max(backdrop, source);
case "color-dodge":
return source >= 1 ? 1 : Math.min(1, backdrop / (1 - source));
case "color-burn":
return source <= 0 ? 0 : 1 - Math.min(1, (1 - backdrop) / source);
case "hard-light":
return source <= 0.5
? 2 * backdrop * source
: 1 - 2 * (1 - backdrop) * (1 - source);
case "soft-light":
return softLight(backdrop, source);
case "difference":
return Math.abs(backdrop - source);
case "exclusion":
return backdrop + source - 2 * backdrop * source;
case "normal":
case "hue":
case "saturation":
case "color":
case "luminosity":
return source;
}
}
function blend(
backdrop: ColourCoordinates,
source: ColourCoordinates,
mode: BlendMode,
): ColourCoordinates {
if (["hue", "saturation", "color", "luminosity"].includes(mode)) {
const nonSeparable =
mode === "hue"
? setLuminosity(
setSaturation(source, saturation(backdrop)),
luminosity(backdrop),
)
: mode === "saturation"
? setLuminosity(
setSaturation(backdrop, saturation(source)),
luminosity(backdrop),
)
: mode === "color"
? setLuminosity(source, luminosity(backdrop))
: setLuminosity(backdrop, luminosity(source));
return nonSeparable.map((channel) => clamp(channel)) as ColourCoordinates;
}
return backdrop.map((channel, index) =>
clamp(blendChannel(channel, source[index] ?? 0, mode)),
) as ColourCoordinates;
}
function channelsFor(
input: ColourInput,
space: CompositingSpace,
): ColourCoordinates {
const channels = srgbChannels(parseColour(input));
return space === "linear-srgb"
? (channels.map(lineariseSrgb) as ColourCoordinates)
: channels;
}
function compositeValues(
source: ColourValue,
backdrop: ColourValue,
space: CompositingSpace,
mode: BlendMode,
opacity: number,
): ColourValue {
const sourceChannels = channelsFor(source, space);
const backdropChannels = channelsFor(backdrop, space);
const sourceAlpha = clamp(source.alpha * clamp(opacity));
const backdropAlpha = clamp(backdrop.alpha);
const outputAlpha = sourceAlpha + backdropAlpha * (1 - sourceAlpha);
if (outputAlpha <= 0) return { space: "srgb", coords: [0, 0, 0], alpha: 0 };
const blended = blend(backdropChannels, sourceChannels, mode);
const output = sourceChannels.map((sourceChannel, index) => {
const backdropChannel = backdropChannels[index] ?? 0;
const blendChannelValue = blended[index] ?? sourceChannel;
const premultiplied =
sourceAlpha * (1 - backdropAlpha) * sourceChannel +
sourceAlpha * backdropAlpha * blendChannelValue +
(1 - sourceAlpha) * backdropAlpha * backdropChannel;
return premultiplied / outputAlpha;
}) as ColourCoordinates;
const encoded =
space === "linear-srgb"
? (output.map(encodeSrgb) as ColourCoordinates)
: output;
return {
space: "srgb",
coords: encoded.map((channel) => clamp(channel)) as ColourCoordinates,
alpha: outputAlpha,
};
}
function result(colour: ColourValue): CompositeResult {
return {
colour,
css: toSrgbCss(colour),
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
};
}
export function compositeSourceOver(
foreground: ColourInput,
background: ColourInput,
options: SourceOverOptions = {},
): CompositeResult {
return result(
compositeValues(
parseColour(foreground),
parseColour(background),
options.space ?? "srgb",
options.blendMode ?? "normal",
options.opacity ?? 1,
),
);
}
export function compositeLayers(
layers: readonly CompositeLayer[],
options: CompositeOptions = {},
): CompositeResult {
if (layers.length === 0)
return result({ space: "srgb", coords: [0, 0, 0], alpha: 0 });
const ordered =
options.order === "top-to-bottom" ? [...layers].reverse() : [...layers];
let accumulator: ColourValue = { space: "srgb", coords: [0, 0, 0], alpha: 0 };
for (const layer of ordered) {
accumulator = compositeValues(
parseColour(layer.colour),
accumulator,
options.space ?? "srgb",
layer.blendMode ?? "normal",
layer.opacity ?? 1,
);
}
return result(accumulator);
}
+268
View File
@@ -0,0 +1,268 @@
import {
alphaText,
asColor,
clamp,
mappedColor,
numberText,
round,
srgbChannels,
toColourValue,
} from "./internal";
import { parseColour } from "./parse";
import type {
ColourFormat,
ColourInput,
ConversionRow,
FormatColourOptions,
SrgbPreview,
} from "./types";
const FORMAT_LABELS: Record<ColourFormat, string> = {
hex: "HEX",
hex8: "HEX + alpha",
rgb: "RGB",
rgba: "RGBA",
hsl: "HSL",
hsv: "HSV / HSB",
hwb: "HWB",
cmyk: "CMYK",
lab: "CIELAB",
lch: "CIELCH",
oklab: "OKLab",
oklch: "OKLCH",
p3: "Display P3",
rec2020: "Rec. 2020",
a98rgb: "Adobe RGB (1998)",
prophoto: "ProPhoto RGB",
"xyz-d50": "XYZ D50",
"xyz-d65": "XYZ D65",
css: "Original CSS",
};
export const DEFAULT_CONVERSION_FORMATS: readonly ColourFormat[] = [
"hex",
"hex8",
"rgb",
"hsl",
"hsv",
"hwb",
"cmyk",
"lab",
"lch",
"oklab",
"oklch",
"p3",
"rec2020",
] as const;
function byte(value: number): number {
return Math.round(clamp(value) * 255);
}
function byteHex(value: number): string {
return byte(value).toString(16).padStart(2, "0");
}
function asHex(input: ColourInput, includeAlpha: boolean): string {
const value = parseColour(input);
const channels = srgbChannels(value);
const rgb = channels.map(byteHex).join("");
return `#${rgb}${includeAlpha ? byteHex(value.alpha) : ""}`;
}
function percent(value: number, precision: number): string {
return `${numberText(value, precision)}%`;
}
function functional(
name: string,
coords: readonly string[],
alpha: number,
includeAlpha: boolean,
): string {
return `${name}(${coords.join(" ")}${includeAlpha && alpha < 1 ? ` / ${alphaText(alpha)}` : ""})`;
}
function serialiseColorFunction(
space: string,
coords: readonly number[],
alpha: number,
precision: number,
): string {
return `color(${space} ${coords.map((coordinate) => numberText(coordinate, precision)).join(" ")}${
alpha < 1 ? ` / ${alphaText(alpha)}` : ""
})`;
}
export function formatColour(
input: ColourInput,
format: ColourFormat = "css",
options: FormatColourOptions = {},
): string {
const precision = options.precision ?? 4;
const value = parseColour(input);
const alpha = value.alpha;
const includeAlpha = options.includeAlpha ?? alpha < 1;
if (format === "hex") return asHex(value, false);
if (format === "hex8") return asHex(value, true);
if (format === "rgb" || format === "rgba") {
const channels = srgbChannels(value, options.mapToSrgb ?? true).map(byte);
const forceAlpha = format === "rgba" || includeAlpha;
return forceAlpha
? `rgba(${channels.join(", ")}, ${alphaText(alpha)})`
: `rgb(${channels.join(", ")})`;
}
if (format === "cmyk") {
const [red, green, blue] = srgbChannels(value, options.mapToSrgb ?? true);
const black = 1 - Math.max(red, green, blue);
const denominator = 1 - black;
const cyan = denominator <= 1e-12 ? 0 : (1 - red - black) / denominator;
const magenta =
denominator <= 1e-12 ? 0 : (1 - green - black) / denominator;
const yellow = denominator <= 1e-12 ? 0 : (1 - blue - black) / denominator;
return functional(
"cmyk",
[cyan, magenta, yellow, black].map((channel) =>
percent(channel * 100, precision),
),
alpha,
includeAlpha,
);
}
const color = asColor(value);
if (format === "hsl" || format === "hsv" || format === "hwb") {
const converted = color.to(format);
const [hue, first, second] = converted.coords.map((coordinate) =>
Number(coordinate ?? 0),
);
return functional(
format,
[
numberText(hue ?? 0, precision),
percent(first ?? 0, precision),
percent(second ?? 0, precision),
],
alpha,
includeAlpha,
);
}
const space =
format === "p3"
? "p3"
: format === "a98rgb"
? "a98rgb"
: format === "prophoto"
? "prophoto"
: format;
if (format === "lab" || format === "lch") {
const converted = color.to(space);
const coords = converted.coords.map((coordinate) =>
Number(coordinate ?? 0),
);
return functional(
format,
[
percent(coords[0] ?? 0, precision),
numberText(coords[1] ?? 0, precision),
numberText(coords[2] ?? 0, precision),
],
alpha,
includeAlpha,
);
}
if (format === "oklab" || format === "oklch") {
const converted = color.to(space);
const coords = converted.coords.map((coordinate) =>
Number(coordinate ?? 0),
);
return functional(
format,
[
percent((coords[0] ?? 0) * 100, precision),
numberText(coords[1] ?? 0, precision),
numberText(coords[2] ?? 0, precision),
],
alpha,
includeAlpha,
);
}
if (
["p3", "rec2020", "a98rgb", "prophoto", "xyz-d50", "xyz-d65"].includes(
format,
)
) {
const converted = color.to(space);
const cssSpace =
format === "p3"
? "display-p3"
: format === "a98rgb"
? "a98-rgb"
: format === "prophoto"
? "prophoto-rgb"
: format;
return serialiseColorFunction(
cssSpace,
converted.coords.map((coordinate) =>
round(Number(coordinate ?? 0), precision),
),
alpha,
precision,
);
}
if (format === "css") {
return color.toString({ precision, inGamut: options.mapToSrgb === true });
}
// Exhaustiveness safeguard for callers compiled against older declarations.
return color.toString({ precision, inGamut: false });
}
export function conversionRows(
input: ColourInput,
formats: readonly ColourFormat[] = DEFAULT_CONVERSION_FORMATS,
): ConversionRow[] {
return formats.map((format) => {
const value = formatColour(input, format);
return {
id: format,
label: FORMAT_LABELS[format],
value,
copyValue: value,
};
});
}
export const formatRows = conversionRows;
export function toSrgbPreview(input: ColourInput): SrgbPreview {
const value = parseColour(input);
const original = asColor(value);
const mapped = mappedColor(value, "srgb");
const rgb = mapped.coords.map((coordinate) =>
clamp(Number(coordinate ?? 0)),
) as [number, number, number];
const integerChannels = rgb.map(byte);
return {
css:
value.alpha < 1
? `rgb(${integerChannels.join(" ")} / ${alphaText(value.alpha)})`
: `rgb(${integerChannels.join(" ")})`,
hex: formatColour(toColourValue(mapped), value.alpha < 1 ? "hex8" : "hex", {
includeAlpha: value.alpha < 1,
}),
rgb,
alpha: value.alpha,
wasMapped: !original.inGamut("srgb"),
};
}
export function toSrgbCss(input: ColourInput): string {
return toSrgbPreview(input).css;
}
+65
View File
@@ -0,0 +1,65 @@
import { asColor, toColourValue } from "./internal";
import { parseColour } from "./parse";
import { formatColour, toSrgbCss } from "./format";
import type {
ColourInput,
ColourValue,
GamutMappingOptions,
GamutReport,
GamutSpaceReport,
} from "./types";
const GAMUT_LABELS: Readonly<Record<string, string>> = {
srgb: "sRGB",
p3: "Display P3",
rec2020: "Rec. 2020",
a98rgb: "Adobe RGB (1998)",
prophoto: "ProPhoto RGB",
};
export const DEFAULT_GAMUT_SPACES = ["srgb", "p3", "rec2020"] as const;
export function mapToGamut(
input: ColourInput,
options: GamutMappingOptions = {},
): ColourValue {
const target = options.target ?? "srgb";
const method = options.method === "clip" ? "clip" : "oklch.c";
const converted = asColor(parseColour(input)).to(target);
const mapped = converted.inGamut(target)
? converted
: converted.toGamut({ space: target, method });
return toColourValue(mapped.to(target));
}
function reportSpace(input: ColourValue, space: string): GamutSpaceReport {
const source = asColor(input);
const inGamut = source.inGamut(space);
const mapped = mapToGamut(input, { target: space });
const format =
space === "p3"
? "p3"
: space === "rec2020"
? "rec2020"
: space === "a98rgb"
? "a98rgb"
: space === "prophoto"
? "prophoto"
: undefined;
return {
space,
label: GAMUT_LABELS[space] ?? space,
inGamut,
mapped,
mappedCss: format ? formatColour(mapped, format) : toSrgbCss(mapped),
deltaEOK: inGamut ? 0 : source.deltaEOK(asColor(mapped)),
};
}
export function gamutReport(
input: ColourInput,
spaces: readonly string[] = DEFAULT_GAMUT_SPACES,
): GamutReport {
const source = parseColour(input);
return { source, spaces: spaces.map((space) => reportSpace(source, space)) };
}
+48
View File
@@ -0,0 +1,48 @@
export {
ColourParseError,
parseColour,
parseColourList,
tryParseColour,
} from "./parse";
export {
DEFAULT_CONVERSION_FORMATS,
conversionRows,
formatColour,
formatRows,
toSrgbCss,
toSrgbPreview,
} from "./format";
export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut";
export { compositeLayers, compositeSourceOver } from "./composite";
export { interpolateColourStops, interpolateStops } from "./interpolate";
export {
contrastRatio,
contrastReport,
flattenColour,
nearestPassingColour,
relativeLuminance,
} from "./accessibility";
export {
deltaE,
simulateColourVision,
simulateColourVisionSet,
} from "./compare";
export {
colourHarmony,
exportCssVariables,
exportCsvPalette,
exportDesignTokens,
exportJsonPalette,
exportPalette,
exportScssVariables,
exportTailwindPalette,
generateHarmony,
normaliseTokenName,
parseNamedColourList,
parsePaletteList,
shades,
tints,
tones,
} from "./palette";
export type * from "./types";
+113
View File
@@ -0,0 +1,113 @@
import Color from "colorjs.io/dist/color.js";
import type { ColourCoordinates, ColourInput, ColourValue } from "./types";
export const EPSILON = 1e-9;
export function clamp(value: number, minimum = 0, maximum = 1): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function round(value: number, precision = 4): number {
const factor = 10 ** precision;
const result = Math.round((value + Number.EPSILON) * factor) / factor;
return Object.is(result, -0) ? 0 : result;
}
export function numberText(value: number, precision = 4): string {
return String(round(value, precision));
}
export function alphaText(alpha: number, precision = 3): string {
return numberText(clamp(alpha), precision);
}
export function finiteCoordinate(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
export function toColourValue(color: Color): ColourValue {
return {
space: color.spaceId,
coords: color.coords.map(finiteCoordinate) as ColourCoordinates,
alpha: clamp(Number.isFinite(color.alpha) ? color.alpha : 1),
};
}
export function isColourValue(value: unknown): value is ColourValue {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<ColourValue>;
return (
typeof candidate.space === "string" &&
Array.isArray(candidate.coords) &&
candidate.coords.length === 3 &&
candidate.coords.every(
(coordinate) =>
typeof coordinate === "number" && Number.isFinite(coordinate),
) &&
typeof candidate.alpha === "number" &&
Number.isFinite(candidate.alpha)
);
}
export function asColor(input: ColourInput): Color {
if (typeof input === "string") return new Color(input);
return new Color(input.space, [...input.coords], clamp(input.alpha));
}
export function colorFrom(
space: string,
coords: readonly number[],
alpha = 1,
): Color {
return new Color(
space,
coords.map(finiteCoordinate) as ColourCoordinates,
clamp(alpha),
);
}
export function copyColour(value: ColourValue): ColourValue {
return { space: value.space, coords: [...value.coords], alpha: value.alpha };
}
export function mappedColor(
input: ColourInput,
target = "srgb",
method: "oklch.c" | "clip" = "oklch.c",
): Color {
const converted = asColor(input).to(target);
return converted.inGamut(target)
? converted
: converted.toGamut({ space: target, method });
}
export function srgbChannels(
input: ColourInput,
map = true,
): ColourCoordinates {
const color = map ? mappedColor(input, "srgb") : asColor(input).to("srgb");
return color.coords.map((coordinate) =>
map ? clamp(finiteCoordinate(coordinate)) : finiteCoordinate(coordinate),
) as ColourCoordinates;
}
export function lineariseSrgb(channel: number): number {
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
}
export function encodeSrgb(channel: number): number {
return channel <= 0.0031308
? 12.92 * channel
: 1.055 * channel ** (1 / 2.4) - 0.055;
}
export function hueIndex(space: string): number | undefined {
if (["hsl", "hsv", "hwb", "okhsl", "okhsv"].includes(space)) return 0;
if (["lch", "oklch", "jzczhz", "hct"].includes(space)) return 2;
return undefined;
}
export { Color };
+170
View File
@@ -0,0 +1,170 @@
import { asColor, clamp, colorFrom, hueIndex, toColourValue } from "./internal";
import { formatColour, toSrgbCss } from "./format";
import { parseColour } from "./parse";
import type {
ColourCoordinates,
ColourStop,
ColourValue,
EasingName,
HueInterpolation,
InterpolationOptions,
InterpolationStep,
} from "./types";
interface PositionedStop {
colour: ColourValue;
position: number;
}
function ease(value: number, name: EasingName): number {
switch (name) {
case "ease-in":
return value * value;
case "ease-out":
return 1 - (1 - value) ** 2;
case "ease-in-out":
return value < 0.5 ? 2 * value * value : 1 - (-2 * value + 2) ** 2 / 2;
case "smoothstep":
return value * value * (3 - 2 * value);
case "linear":
return value;
}
}
function normaliseStops(
stops: readonly ColourStop[],
shouldClamp: boolean,
): PositionedStop[] {
if (stops.length < 2)
throw new RangeError("At least two colour stops are required.");
const positions = stops.map((stop) => {
if (stop.position === undefined) return undefined;
if (!Number.isFinite(stop.position))
throw new RangeError("Stop positions must be finite numbers.");
return shouldClamp ? clamp(stop.position) : stop.position;
});
positions[0] ??= 0;
positions[positions.length - 1] ??= 1;
let previousKnown = 0;
for (let index = 1; index < positions.length; index += 1) {
if (positions[index] === undefined) continue;
const start = positions[previousKnown] ?? 0;
const end = Math.max(start, positions[index] ?? start);
positions[index] = end;
const gap = index - previousKnown;
for (let missing = 1; missing < gap; missing += 1) {
positions[previousKnown + missing] =
start + ((end - start) * missing) / gap;
}
previousKnown = index;
}
return stops.map((stop, index) => ({
colour: parseColour(stop.colour),
position: positions[index] ?? 0,
}));
}
function hueDelta(from: number, to: number, method: HueInterpolation): number {
if (method === "raw") return to - from;
const increasing = (((to - from) % 360) + 360) % 360;
const decreasing = increasing === 0 ? 0 : increasing - 360;
if (method === "increasing") return increasing;
if (method === "decreasing") return decreasing;
if (method === "shorter") return increasing <= 180 ? increasing : decreasing;
if (increasing === 0) return 360;
return increasing <= 180 ? decreasing : increasing;
}
export function interpolateColourStops(
stops: readonly ColourStop[],
position: number,
options: InterpolationOptions = {},
): ColourValue {
if (!Number.isFinite(position))
throw new RangeError("Interpolation position must be finite.");
const shouldClamp = options.clamp ?? true;
const prepared = normaliseStops(stops, shouldClamp);
const sample = shouldClamp ? clamp(position) : position;
let left = prepared[0];
let right = prepared[prepared.length - 1];
if (!left || !right)
throw new RangeError("At least two colour stops are required.");
if (sample <= left.position) right = prepared[1] ?? right;
else if (sample >= right.position)
left = prepared[prepared.length - 2] ?? left;
else {
for (let index = 1; index < prepared.length; index += 1) {
const candidate = prepared[index];
if (candidate && sample <= candidate.position) {
left = prepared[index - 1] ?? left;
right = candidate;
break;
}
}
}
const range = right.position - left.position;
const local = range === 0 ? 1 : (sample - left.position) / range;
const progress = ease(
shouldClamp ? clamp(local) : local,
options.easing ?? "linear",
);
const space = options.space ?? "oklch";
const leftColor = asColor(left.colour).to(space);
const rightColor = asColor(right.colour).to(space);
const leftCoords = leftColor.coords.map((coordinate) =>
Number(coordinate ?? 0),
) as ColourCoordinates;
const rightCoords = rightColor.coords.map((coordinate) =>
Number(coordinate ?? 0),
) as ColourCoordinates;
const hueCoordinate = hueIndex(space);
const alpha =
left.colour.alpha + (right.colour.alpha - left.colour.alpha) * progress;
const premultiplied = options.premultiplied ?? true;
const coords = leftCoords.map((coordinate, index) => {
if (index === hueCoordinate) {
return (
coordinate +
hueDelta(
coordinate,
rightCoords[index] ?? coordinate,
options.hue ?? "shorter",
) *
progress
);
}
const rightCoordinate = rightCoords[index] ?? coordinate;
if (!premultiplied) {
return coordinate + (rightCoordinate - coordinate) * progress;
}
const mixed =
coordinate * left.colour.alpha * (1 - progress) +
rightCoordinate * right.colour.alpha * progress;
return alpha > 1e-12 ? mixed / alpha : 0;
}) as ColourCoordinates;
return toColourValue(colorFrom(space, coords, alpha));
}
export function interpolateStops(
stops: readonly ColourStop[],
count: number,
options: InterpolationOptions = {},
): InterpolationStep[] {
if (!Number.isInteger(count) || count < 2 || count > 10_000) {
throw new RangeError("Step count must be an integer from 2 to 10,000.");
}
return Array.from({ length: count }, (_, index) => {
const position = index / (count - 1);
const colour = interpolateColourStops(stops, position, options);
return {
position,
colour,
css: toSrgbCss(colour),
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
};
});
}
+332
View File
@@ -0,0 +1,332 @@
import { asColor, clamp, colorFrom, toColourValue } from "./internal";
import { formatColour } from "./format";
import { mapToGamut } from "./gamut";
import { interpolateColourStops } from "./interpolate";
import { parseColour, tryParseColour } from "./parse";
import type {
ColourFormat,
ColourInput,
ColourValue,
HarmonyType,
PaletteEntry,
PaletteExportFormat,
PaletteExportOptions,
PaletteScaleOptions,
} from "./types";
const HARMONY_OFFSETS: Record<HarmonyType, readonly number[]> = {
complementary: [0, 180],
analogous: [-30, 0, 30],
"split-complementary": [0, 150, 210],
triadic: [0, 120, 240],
tetradic: [0, 60, 180, 240],
square: [0, 90, 180, 270],
};
export function colourHarmony(
input: ColourInput,
type: HarmonyType,
): ColourValue[] {
const source = parseColour(input);
const converted = asColor(source).to("oklch");
const lightness = Number(converted.coords[0] ?? 0);
const chroma = Number(converted.coords[1] ?? 0);
const hue = Number(converted.coords[2] ?? 0);
return HARMONY_OFFSETS[type].map((offset) =>
mapToGamut(
toColourValue(
colorFrom(
"oklch",
[lightness, chroma, (hue + offset + 360) % 360],
source.alpha,
),
),
),
);
}
export const generateHarmony = colourHarmony;
function scale(
input: ColourInput,
endpoint: ColourInput,
count: number,
options: PaletteScaleOptions,
): ColourValue[] {
if (!Number.isInteger(count) || count < 1 || count > 1_000) {
throw new RangeError("Palette count must be an integer from 1 to 1,000.");
}
const source = parseColour(input);
const values: ColourValue[] = options.includeBase ? [source] : [];
const includeEndpoint = options.includeEndpoint ?? false;
const denominator = includeEndpoint ? count : count + 1;
for (let index = 1; index <= count; index += 1) {
values.push(
interpolateColourStops(
[{ colour: source }, { colour: endpoint }],
index / denominator,
{ space: options.space ?? "oklab" },
),
);
}
return values;
}
export function tints(
input: ColourInput,
count = 5,
options: PaletteScaleOptions = {},
): ColourValue[] {
return scale(input, "#ffffff", count, options);
}
export function shades(
input: ColourInput,
count = 5,
options: PaletteScaleOptions = {},
): ColourValue[] {
return scale(input, "#000000", count, options);
}
export function tones(
input: ColourInput,
count = 5,
options: PaletteScaleOptions = {},
): ColourValue[] {
const source = parseColour(input);
const lightness = clamp(Number(asColor(source).to("oklab").coords[0] ?? 0));
const neutral = toColourValue(
colorFrom("oklab", [lightness, 0, 0], source.alpha),
);
return scale(source, neutral, count, options);
}
function entryFromUnknown(
name: string,
value: unknown,
): PaletteEntry | undefined {
const candidate =
typeof value === "object" && value !== null
? ((value as Record<string, unknown>).$value ??
(value as Record<string, unknown>).value ??
(value as Record<string, unknown>).colour ??
(value as Record<string, unknown>).color)
: value;
if (typeof candidate !== "string") return undefined;
const result = tryParseColour(candidate);
return result.ok
? { name, colour: result.value, source: candidate }
: undefined;
}
function parseJsonPalette(input: string): PaletteEntry[] | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(input) as unknown;
} catch {
return undefined;
}
if (Array.isArray(parsed)) {
return parsed.map((item, index) => {
if (typeof item === "string")
return {
name: `colour-${index + 1}`,
colour: parseColour(item),
source: item,
};
if (typeof item !== "object" || item === null)
throw new TypeError(`Palette item ${index + 1} is invalid.`);
const record = item as Record<string, unknown>;
const name =
typeof record.name === "string" ? record.name : `colour-${index + 1}`;
const entry = entryFromUnknown(name, record);
if (!entry)
throw new TypeError(
`Palette item “${name}” does not contain a valid colour.`,
);
return entry;
});
}
if (typeof parsed === "object" && parsed !== null) {
const entries: PaletteEntry[] = [];
const visit = (record: Record<string, unknown>, path: string[]): void => {
for (const [name, value] of Object.entries(record)) {
if (name.startsWith("$")) continue;
const nextPath = [...path, name];
const entry = entryFromUnknown(nextPath.join("-"), value);
if (entry) entries.push(entry);
else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
visit(value as Record<string, unknown>, nextPath);
}
}
};
visit(parsed as Record<string, unknown>, []);
if (entries.length > 0) return entries;
}
return undefined;
}
export function parsePaletteList(input: string): PaletteEntry[] {
const trimmed = input.trim();
if (!trimmed) return [];
const json = parseJsonPalette(trimmed);
if (json) return json;
const cssBlock = trimmed.match(/^\s*(?::root|[^{}]+)\s*\{([\s\S]*)\}\s*$/);
const listText = cssBlock?.[1] ?? trimmed;
const lines = listText
.split(/[\n;]/)
.map((line) => line.trim().replace(/,$/, ""))
.filter(Boolean);
return lines.map((line, index) => {
const whole = tryParseColour(line);
if (whole.ok)
return { name: `colour-${index + 1}`, colour: whole.value, source: line };
const match = line.match(/^(--)?([^:=]+?)\s*[:=]\s*(.+)$/);
if (!match)
throw new TypeError(`Could not parse palette line ${index + 1}: ${line}`);
const name = (match[2] ?? `colour-${index + 1}`).trim();
const source = (match[3] ?? "").trim();
return { name, colour: parseColour(source), source };
});
}
export const parseNamedColourList = parsePaletteList;
export function normaliseTokenName(input: string): string {
const normalised = input
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
return normalised || "colour";
}
function outputValue(entry: PaletteEntry, format: ColourFormat): string {
return formatColour(
entry.colour,
format === "hex" && entry.colour.alpha < 1 ? "hex8" : format,
);
}
function uniqueNames(
entries: readonly PaletteEntry[],
): Array<{ name: string; entry: PaletteEntry }> {
const used = new Map<string, number>();
return entries.map((entry) => {
const base = normaliseTokenName(entry.name);
const occurrence = (used.get(base) ?? 0) + 1;
used.set(base, occurrence);
return { name: occurrence === 1 ? base : `${base}-${occurrence}`, entry };
});
}
export function exportCssVariables(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
const prefix = normaliseTokenName(options.prefix ?? "colour");
const lines = uniqueNames(entries).map(
({ name, entry }) =>
` --${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
);
return `:root {\n${lines.join("\n")}\n}`;
}
export function exportScssVariables(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
const prefix = normaliseTokenName(options.prefix ?? "colour");
return uniqueNames(entries)
.map(
({ name, entry }) =>
`$${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
)
.join("\n");
}
export function exportJsonPalette(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
const value = Object.fromEntries(
uniqueNames(entries).map(({ name, entry }) => [
name,
outputValue(entry, options.format ?? "hex"),
]),
);
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
}
export function exportDesignTokens(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
const value = Object.fromEntries(
uniqueNames(entries).map(({ name, entry }) => [
name,
{ $type: "color", $value: outputValue(entry, options.format ?? "hex") },
]),
);
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
}
export function exportTailwindPalette(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
const value = Object.fromEntries(
uniqueNames(entries).map(({ name, entry }) => [
name,
outputValue(entry, options.format ?? "hex"),
]),
);
return `export default ${JSON.stringify(value, null, options.pretty === false ? undefined : 2)};`;
}
function csvCell(value: string): string {
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
}
export function exportCsvPalette(
entries: readonly PaletteEntry[],
options: PaletteExportOptions = {},
): string {
return [
"name,value",
...uniqueNames(entries).map(
({ name, entry }) =>
`${csvCell(name)},${csvCell(outputValue(entry, options.format ?? "hex"))}`,
),
].join("\n");
}
export function exportPalette(
entries: readonly PaletteEntry[],
format: PaletteExportFormat = "css",
options: PaletteExportOptions = {},
): string {
switch (format) {
case "css":
return exportCssVariables(entries, options);
case "scss":
return exportScssVariables(entries, options);
case "json":
return exportJsonPalette(entries, options);
case "tokens":
return exportDesignTokens(entries, options);
case "tailwind":
return exportTailwindPalette(entries, options);
case "csv":
return exportCsvPalette(entries, options);
}
}
+320
View File
@@ -0,0 +1,320 @@
import {
Color,
clamp,
copyColour,
isColourValue,
toColourValue,
} from "./internal";
import type {
ColourInput,
ColourParseErrorCode,
ColourParseResult,
ColourValue,
} from "./types";
export class ColourParseError extends Error {
readonly code: ColourParseErrorCode;
readonly input: string;
constructor(code: ColourParseErrorCode, message: string, input: string) {
super(message);
this.name = "ColourParseError";
this.code = code;
this.input = input;
}
}
function parseNumber(token: string, label: string, input: string): number {
const value = Number(token);
if (!Number.isFinite(value)) {
throw new ColourParseError(
"non-finite",
`${label} must be a finite number.`,
input,
);
}
return value;
}
function parseUnitInterval(
token: string,
label: string,
input: string,
allowHundred = true,
): number {
const trimmed = token.trim();
const percentage = trimmed.endsWith("%");
const value = parseNumber(
percentage ? trimmed.slice(0, -1) : trimmed,
label,
input,
);
const normalised =
percentage || (allowHundred && value > 1) ? value / 100 : value;
if (normalised < 0 || normalised > 1) {
throw new ColourParseError(
"out-of-range",
`${label} must be between 0 and 100%.`,
input,
);
}
return normalised;
}
function parseAlpha(token: string | undefined, input: string): number {
return token === undefined
? 1
: parseUnitInterval(token, "Alpha", input, false);
}
function parseAngle(token: string, input: string): number {
const match = token
.trim()
.match(
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i,
);
if (!match)
throw new ColourParseError(
"invalid-syntax",
"Hue must be a valid CSS angle.",
input,
);
const value = parseNumber(match[1] ?? "", "Hue", input);
const unit = match[2]?.toLowerCase() ?? "deg";
const degrees =
unit === "turn"
? value * 360
: unit === "rad"
? (value * 180) / Math.PI
: unit === "grad"
? value * 0.9
: value;
return ((degrees % 360) + 360) % 360;
}
function functionParts(
body: string,
expectedChannels = 3,
): { channels: string[]; alpha?: string } {
const slash = body.split("/");
if (slash.length > 2) return { channels: [] };
const channelText = slash[0]?.trim() ?? "";
const commaParts = channelText.includes(",")
? channelText.split(",").map((part) => part.trim())
: channelText.split(/\s+/).filter(Boolean);
let alpha = slash[1]?.trim();
if (commaParts.length === expectedChannels + 1 && alpha === undefined)
alpha = commaParts.pop();
return { channels: commaParts, ...(alpha === undefined ? {} : { alpha }) };
}
function parseHsv(input: string): ColourValue | undefined {
const match = input.match(/^hs(?:v|b)a?\((.*)\)$/is);
if (!match) return undefined;
const parts = functionParts(match[1] ?? "");
if (parts.channels.length !== 3) {
throw new ColourParseError(
"invalid-syntax",
"HSV needs hue, saturation and value channels.",
input,
);
}
const hue = parseAngle(parts.channels[0] ?? "", input);
const saturation =
parseUnitInterval(parts.channels[1] ?? "", "Saturation", input) * 100;
const value =
parseUnitInterval(parts.channels[2] ?? "", "Value", input) * 100;
return {
space: "hsv",
coords: [hue, saturation, value],
alpha: parseAlpha(parts.alpha, input),
};
}
function parseCmyk(input: string): ColourValue | undefined {
const match = input.match(/^(?:device-)?cmyka?\((.*)\)$/is);
if (!match) return undefined;
const parts = functionParts(match[1] ?? "", 4);
if (parts.channels.length !== 4) {
throw new ColourParseError(
"invalid-syntax",
"CMYK needs cyan, magenta, yellow and black channels.",
input,
);
}
const [cyan, magenta, yellow, black] = parts.channels.map((part, index) =>
parseUnitInterval(
part,
["Cyan", "Magenta", "Yellow", "Black"][index] ?? "Channel",
input,
),
);
const c = cyan ?? 0;
const m = magenta ?? 0;
const y = yellow ?? 0;
const k = black ?? 0;
return {
space: "srgb",
coords: [(1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k)],
alpha: parseAlpha(parts.alpha, input),
};
}
function parseBareRgb(input: string): ColourValue | undefined {
if (!/^\s*[+-]?(?:\d|\.)/.test(input) || !input.includes(","))
return undefined;
const parts = input.split(",").map((part) => part.trim());
if (parts.length < 3 || parts.length > 4 || parts.some((part) => part === ""))
return undefined;
const channels = parts.slice(0, 3).map((part, index) => {
const value = parseNumber(
part,
["Red", "Green", "Blue"][index] ?? "Channel",
input,
);
if (value < 0 || value > 255) {
throw new ColourParseError(
"out-of-range",
"RGB channels must be between 0 and 255.",
input,
);
}
return value / 255;
});
return {
space: "srgb",
coords: channels as [number, number, number],
alpha: parseAlpha(parts[3], input),
};
}
function normaliseStringInput(input: string): string {
const trimmed = input.trim();
if (/^[\da-f]{3,8}$/i.test(trimmed) && [3, 4, 6, 8].includes(trimmed.length))
return `#${trimmed}`;
if (/^0x[\da-f]{6,8}$/i.test(trimmed)) return `#${trimmed.slice(2)}`;
return trimmed;
}
export function parseColour(input: ColourInput): ColourValue {
if (typeof input !== "string") {
if (!isColourValue(input)) {
throw new ColourParseError(
"invalid-syntax",
"Colour value is malformed.",
String(input),
);
}
if (input.alpha < 0 || input.alpha > 1) {
throw new ColourParseError(
"out-of-range",
"Alpha must be between 0 and 1.",
JSON.stringify(input),
);
}
try {
// Validate the public space identifier before returning a defensive copy.
new Color(input.space, [...input.coords], clamp(input.alpha));
return copyColour(input);
} catch (error) {
throw new ColourParseError(
"unsupported-space",
error instanceof Error ? error.message : "Unsupported colour space.",
JSON.stringify(input),
);
}
}
const text = normaliseStringInput(input);
if (text === "")
throw new ColourParseError("empty", "Enter a colour.", input);
const custom = parseHsv(text) ?? parseCmyk(text) ?? parseBareRgb(text);
if (custom) return custom;
try {
return toColourValue(new Color(text));
} catch (error) {
const detail =
error instanceof Error && error.message ? ` ${error.message}` : "";
throw new ColourParseError(
"invalid-syntax",
`Could not parse “${text}”.${detail}`,
input,
);
}
}
export function tryParseColour(input: ColourInput): ColourParseResult {
try {
return { ok: true, value: parseColour(input) };
} catch (error) {
if (error instanceof ColourParseError) {
return {
ok: false,
error: { code: error.code, message: error.message, input: error.input },
};
}
return {
ok: false,
error: {
code: "invalid-syntax",
message: "Could not parse this colour.",
input: String(input),
},
};
}
}
function splitTopLevel(input: string): string[] {
const output: string[] = [];
let start = 0;
let depth = 0;
let quote = "";
for (let index = 0; index < input.length; index += 1) {
const character = input[index] ?? "";
if (quote) {
if (character === quote && input[index - 1] !== "\\") quote = "";
continue;
}
if (character === '"' || character === "'") quote = character;
else if (character === "(") depth += 1;
else if (character === ")") depth = Math.max(0, depth - 1);
else if (
depth === 0 &&
(character === "\n" || character === ";" || character === ",")
) {
const part = input.slice(start, index).trim();
if (part) output.push(part);
start = index + 1;
}
}
const last = input.slice(start).trim();
if (last) output.push(last);
return output;
}
export function parseColourList(input: string): ColourValue[] {
const trimmed = input.trim();
if (!trimmed) return [];
const whole = tryParseColour(trimmed);
if (whole.ok) return [whole.value];
let values: unknown;
try {
values = JSON.parse(trimmed) as unknown;
} catch {
values = undefined;
}
if (Array.isArray(values)) {
return values.map((value) =>
parseColour(typeof value === "string" ? value : (value as ColourValue)),
);
}
const parts = splitTopLevel(trimmed);
if (parts.length <= 1)
throw new ColourParseError(whole.error.code, whole.error.message, input);
return parts.map(parseColour);
}
+249
View File
@@ -0,0 +1,249 @@
export type ColourCoordinates = [number, number, number];
/** A serialisable colour value. Coordinates use Color.js space conventions. */
export interface ColourValue {
space: string;
coords: ColourCoordinates;
alpha: number;
}
export type ColourInput = string | ColourValue;
export type ColourParseErrorCode =
| "empty"
| "invalid-syntax"
| "unsupported-space"
| "non-finite"
| "out-of-range";
export interface ColourParseFailure {
ok: false;
error: {
code: ColourParseErrorCode;
message: string;
input: string;
};
}
export interface ColourParseSuccess {
ok: true;
value: ColourValue;
}
export type ColourParseResult = ColourParseSuccess | ColourParseFailure;
export type ColourFormat =
| "hex"
| "hex8"
| "rgb"
| "rgba"
| "hsl"
| "hsv"
| "hwb"
| "cmyk"
| "lab"
| "lch"
| "oklab"
| "oklch"
| "p3"
| "rec2020"
| "a98rgb"
| "prophoto"
| "xyz-d50"
| "xyz-d65"
| "css";
export interface FormatColourOptions {
precision?: number;
mapToSrgb?: boolean;
includeAlpha?: boolean;
}
export interface ConversionRow {
id: ColourFormat;
label: string;
value: string;
copyValue: string;
}
export interface SrgbPreview {
css: string;
hex: string;
rgb: ColourCoordinates;
alpha: number;
wasMapped: boolean;
}
export type GamutMappingMethod = "oklch-chroma" | "clip";
export interface GamutMappingOptions {
target?: string;
method?: GamutMappingMethod;
}
export interface GamutSpaceReport {
space: string;
label: string;
inGamut: boolean;
mapped: ColourValue;
mappedCss: string;
deltaEOK: number;
}
export interface GamutReport {
source: ColourValue;
spaces: GamutSpaceReport[];
}
export type BlendMode =
| "normal"
| "multiply"
| "screen"
| "overlay"
| "darken"
| "lighten"
| "color-dodge"
| "color-burn"
| "hard-light"
| "soft-light"
| "difference"
| "exclusion"
| "hue"
| "saturation"
| "color"
| "luminosity";
export type CompositingSpace = "srgb" | "linear-srgb";
export interface CompositeLayer {
colour: ColourInput;
opacity?: number;
blendMode?: BlendMode;
name?: string;
}
export interface CompositeOptions {
space?: CompositingSpace;
/** Array order. The default models a paint stack from its background upwards. */
order?: "bottom-to-top" | "top-to-bottom";
}
export interface SourceOverOptions {
space?: CompositingSpace;
blendMode?: BlendMode;
/** Additional source opacity, multiplied with its own alpha. */
opacity?: number;
}
export interface CompositeResult {
colour: ColourValue;
css: string;
hex: string;
}
export interface ColourStop {
colour: ColourInput;
/** Normalised position. Missing positions are distributed like a CSS gradient. */
position?: number;
name?: string;
}
export type InterpolationSpace =
| "srgb"
| "srgb-linear"
| "hsl"
| "hsv"
| "lab"
| "lch"
| "oklab"
| "oklch"
| "p3";
export type HueInterpolation =
"shorter" | "longer" | "increasing" | "decreasing" | "raw";
export type EasingName =
"linear" | "ease-in" | "ease-out" | "ease-in-out" | "smoothstep";
export interface InterpolationOptions {
space?: InterpolationSpace;
hue?: HueInterpolation;
easing?: EasingName;
clamp?: boolean;
/** Interpolate premultiplied components to avoid transparent-colour fringes. Defaults to true. */
premultiplied?: boolean;
}
export interface InterpolationStep {
position: number;
colour: ColourValue;
css: string;
hex: string;
}
export interface ContrastOptions {
/** Canvas below a translucent background. Defaults to white. */
canvas?: ColourInput;
}
export interface ContrastReport {
ratio: number;
foreground: ColourValue;
background: ColourValue;
flattenedForeground: ColourValue;
flattenedBackground: ColourValue;
passes: {
aaLarge: boolean;
aaaLarge: boolean;
aaNormal: boolean;
aaaNormal: boolean;
nonText: boolean;
};
}
export interface ContrastSuggestion {
colour: ColourValue;
css: string;
hex: string;
ratio: number;
deltaEOK: number;
direction: "lighter" | "darker";
}
export type DeltaEMethod = "76" | "cmc" | "2000" | "ok" | "itp" | "jz";
export type ColourVisionDeficiency =
"protanopia" | "deuteranopia" | "tritanopia" | "achromatopsia";
export interface ColourVisionOptions {
severity?: number;
mapToSrgb?: boolean;
}
export type HarmonyType =
| "complementary"
| "analogous"
| "split-complementary"
| "triadic"
| "tetradic"
| "square";
export interface PaletteEntry {
name: string;
colour: ColourValue;
source?: string;
}
export type PaletteExportFormat =
"css" | "scss" | "json" | "tokens" | "tailwind" | "csv";
export interface PaletteExportOptions {
format?: ColourFormat;
prefix?: string;
pretty?: boolean;
}
export interface PaletteScaleOptions {
includeBase?: boolean;
includeEndpoint?: boolean;
space?: InterpolationSpace;
}