feat: release Colour Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
colourHarmony,
|
||||
deltaE,
|
||||
exportPalette,
|
||||
formatColour,
|
||||
normaliseTokenName,
|
||||
parsePaletteList,
|
||||
shades,
|
||||
simulateColourVision,
|
||||
simulateColourVisionSet,
|
||||
tints,
|
||||
tones,
|
||||
} from "../../src/colour";
|
||||
|
||||
describe("comparison and colour-vision tools", () => {
|
||||
it("provides standard Delta-E methods", () => {
|
||||
expect(deltaE("#f8f5ee", "#f8f5ee", "2000")).toBe(0);
|
||||
expect(deltaE("red", "blue", "76")).toBeGreaterThan(100);
|
||||
expect(deltaE("#777", "#787878", "ok")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("simulates four deficiencies while preserving alpha", () => {
|
||||
const original = "rgba(200, 40, 20, .4)";
|
||||
const unchanged = simulateColourVision(original, "deuteranopia", {
|
||||
severity: 0,
|
||||
});
|
||||
expect(formatColour(unchanged, "hex8")).toBe("#c8281466");
|
||||
const simulated = simulateColourVision(original, "deuteranopia");
|
||||
expect(simulated.alpha).toBeCloseTo(0.4);
|
||||
expect(formatColour(simulated, "hex")).not.toBe("#c82814");
|
||||
expect(Object.keys(simulateColourVisionSet(original))).toEqual([
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"achromatopsia",
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns achromatopsia output neutral", () => {
|
||||
const simulated = simulateColourVision("#ff8000", "achromatopsia");
|
||||
expect(simulated.coords[0]).toBeCloseTo(simulated.coords[1]!, 8);
|
||||
expect(simulated.coords[1]).toBeCloseTo(simulated.coords[2]!, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("palette construction and export", () => {
|
||||
it("builds useful hue harmonies", () => {
|
||||
expect(colourHarmony("oklch(60% .15 20)", "complementary")).toHaveLength(2);
|
||||
expect(colourHarmony("oklch(60% .15 20)", "analogous")).toHaveLength(3);
|
||||
expect(colourHarmony("oklch(60% .15 20)", "square")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("builds deterministic tint, shade and tone scales", () => {
|
||||
expect(tints("#336699", 3)).toHaveLength(3);
|
||||
expect(shades("#336699", 3, { includeBase: true })).toHaveLength(4);
|
||||
expect(tones("#336699", 2, { includeEndpoint: true })).toHaveLength(2);
|
||||
expect(
|
||||
formatColour(tints("black", 1, { includeEndpoint: true })[0]!, "hex"),
|
||||
).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("parses named text, CSS variables, arrays and token JSON", () => {
|
||||
const text = parsePaletteList("Primary: #f8f5ee\n--accent: rgb(20 40 60)");
|
||||
expect(text.map((entry) => entry.name)).toEqual(["Primary", "accent"]);
|
||||
expect(parsePaletteList('["red", "blue"]')).toHaveLength(2);
|
||||
const tokens = parsePaletteList(
|
||||
'{"brand":{"$type":"color","$value":"#123456"}}',
|
||||
);
|
||||
expect(formatColour(tokens[0]!.colour, "hex")).toBe("#123456");
|
||||
const nested = parsePaletteList(
|
||||
'{"brand":{"light":{"$type":"color","$value":"#eee"},"dark":{"$value":"#111"}}}',
|
||||
);
|
||||
expect(nested.map((entry) => entry.name)).toEqual([
|
||||
"brand-light",
|
||||
"brand-dark",
|
||||
]);
|
||||
expect(
|
||||
parsePaletteList(":root { --paper: #f8f5ee; --ink: #111; }"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("normalises and de-duplicates portable token names", () => {
|
||||
expect(normaliseTokenName(" Crème brûlée / 100 ")).toBe(
|
||||
"creme-brulee-100",
|
||||
);
|
||||
const entries = parsePaletteList("Brand: red\nBrand: blue");
|
||||
expect(exportPalette(entries, "css", { prefix: "App" })).toContain(
|
||||
"--app-brand-2: #0000ff;",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports CSS, Sass, JSON, DTCG, Tailwind and CSV", () => {
|
||||
const entries = parsePaletteList("paper: #f8f5ee\nink: #111");
|
||||
expect(exportPalette(entries, "css")).toContain("--colour-paper: #f8f5ee;");
|
||||
expect(exportPalette(entries, "scss")).toContain("$colour-ink: #111111;");
|
||||
expect(JSON.parse(exportPalette(entries, "json"))).toEqual({
|
||||
paper: "#f8f5ee",
|
||||
ink: "#111111",
|
||||
});
|
||||
expect(JSON.parse(exportPalette(entries, "tokens")).paper).toEqual({
|
||||
$type: "color",
|
||||
$value: "#f8f5ee",
|
||||
});
|
||||
expect(exportPalette(entries, "tailwind")).toMatch(/^export default /);
|
||||
expect(exportPalette(entries, "csv")).toBe(
|
||||
"name,value\npaper,#f8f5ee\nink,#111111",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compositeLayers, compositeSourceOver } from "../../src/colour";
|
||||
import type { BlendMode } from "../../src/colour";
|
||||
|
||||
describe("source-over compositing", () => {
|
||||
it("matches the canonical translucent-red-over-white result", () => {
|
||||
const result = compositeSourceOver("rgba(255, 0, 0, .5)", "white");
|
||||
expect(result.colour.coords).toEqual([1, 0.5, 0.5]);
|
||||
expect(result.colour.alpha).toBe(1);
|
||||
expect(result.hex).toBe("#ff8080");
|
||||
});
|
||||
|
||||
it("composites an arbitrary RGBA reference with the source-over equation", () => {
|
||||
const result = compositeSourceOver(
|
||||
"rgba(0, 129, 255, .42)",
|
||||
"rgb(255, 255, 255)",
|
||||
);
|
||||
expect(result.colour.coords[0]).toBeCloseTo(0.58, 12);
|
||||
expect(result.colour.coords[1]).toBeCloseTo(0.7924705882352941, 12);
|
||||
expect(result.colour.coords[2]).toBe(1);
|
||||
});
|
||||
|
||||
it("supports N layers and an explicit stack order", () => {
|
||||
const layers = [
|
||||
{ colour: "white" },
|
||||
{ colour: "rgba(255,0,0,.5)" },
|
||||
{ colour: "rgba(0,0,255,.5)" },
|
||||
];
|
||||
expect(compositeLayers(layers).hex).toBe("#8040bf");
|
||||
expect(
|
||||
compositeLayers([...layers].reverse(), { order: "top-to-bottom" }).hex,
|
||||
).toBe("#8040bf");
|
||||
});
|
||||
|
||||
it("offers visibly different sRGB and linear-light workflows", () => {
|
||||
const srgb = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", {
|
||||
space: "srgb",
|
||||
});
|
||||
const linear = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", {
|
||||
space: "linear-srgb",
|
||||
});
|
||||
expect(srgb.hex).toBe("#808000");
|
||||
expect(linear.hex).toBe("#bcbc00");
|
||||
});
|
||||
|
||||
it("implements opacity and common blend modes", () => {
|
||||
expect(
|
||||
compositeSourceOver("#ff0000", "#808080", { blendMode: "multiply" }).hex,
|
||||
).toBe("#800000");
|
||||
expect(
|
||||
compositeSourceOver("#ff0000", "#808080", { blendMode: "screen" }).hex,
|
||||
).toBe("#ff8080");
|
||||
expect(compositeSourceOver("#ff0000", "#ffffff", { opacity: 0 }).hex).toBe(
|
||||
"#ffffff",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every exposed blend mode renderable", () => {
|
||||
const modes: BlendMode[] = [
|
||||
"normal",
|
||||
"multiply",
|
||||
"screen",
|
||||
"overlay",
|
||||
"darken",
|
||||
"lighten",
|
||||
"color-dodge",
|
||||
"color-burn",
|
||||
"hard-light",
|
||||
"soft-light",
|
||||
"difference",
|
||||
"exclusion",
|
||||
"hue",
|
||||
"saturation",
|
||||
"color",
|
||||
"luminosity",
|
||||
];
|
||||
for (const blendMode of modes) {
|
||||
const result = compositeSourceOver("rgba(230,30,80,.7)", "#2878c8", {
|
||||
blendMode,
|
||||
});
|
||||
expect(result.colour.coords.every(Number.isFinite), blendMode).toBe(true);
|
||||
expect(result.hex, blendMode).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps arbitrary composites bounded and alpha-correct", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.tuple(
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
),
|
||||
fc.tuple(
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
),
|
||||
(foreground, background) => {
|
||||
const result = compositeSourceOver(
|
||||
{
|
||||
space: "srgb",
|
||||
coords: foreground.slice(0, 3) as [number, number, number],
|
||||
alpha: foreground[3],
|
||||
},
|
||||
{
|
||||
space: "srgb",
|
||||
coords: background.slice(0, 3) as [number, number, number],
|
||||
alpha: background[3],
|
||||
},
|
||||
).colour;
|
||||
expect(
|
||||
result.coords.every((channel) => channel >= 0 && channel <= 1),
|
||||
).toBe(true);
|
||||
expect(result.alpha).toBeGreaterThanOrEqual(0);
|
||||
expect(result.alpha).toBeLessThanOrEqual(1);
|
||||
expect(result.alpha).toBeCloseTo(
|
||||
foreground[3] + background[3] * (1 - foreground[3]),
|
||||
12,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
contrastRatio,
|
||||
contrastReport,
|
||||
formatColour,
|
||||
gamutReport,
|
||||
interpolateColourStops,
|
||||
interpolateStops,
|
||||
mapToGamut,
|
||||
nearestPassingColour,
|
||||
relativeLuminance,
|
||||
} from "../../src/colour";
|
||||
|
||||
describe("multi-stop interpolation", () => {
|
||||
it("interpolates across positioned stops", () => {
|
||||
const stops = [
|
||||
{ colour: "#ff0000", position: 0 },
|
||||
{ colour: "#00ff00", position: 0.5 },
|
||||
{ colour: "#0000ff", position: 1 },
|
||||
];
|
||||
expect(
|
||||
formatColour(
|
||||
interpolateColourStops(stops, 0.25, { space: "srgb" }),
|
||||
"hex",
|
||||
),
|
||||
).toBe("#808000");
|
||||
expect(
|
||||
formatColour(
|
||||
interpolateColourStops(stops, 0.75, { space: "srgb" }),
|
||||
"hex",
|
||||
),
|
||||
).toBe("#008080");
|
||||
});
|
||||
|
||||
it("distributes omitted positions and includes exact endpoints", () => {
|
||||
const steps = interpolateStops(
|
||||
[{ colour: "black" }, { colour: "red" }, { colour: "white" }],
|
||||
5,
|
||||
{
|
||||
space: "srgb",
|
||||
},
|
||||
);
|
||||
expect(steps.map((step) => step.hex)).toEqual([
|
||||
"#000000",
|
||||
"#800000",
|
||||
"#ff0000",
|
||||
"#ff8080",
|
||||
"#ffffff",
|
||||
]);
|
||||
expect(steps.map((step) => step.position)).toEqual([0, 0.25, 0.5, 0.75, 1]);
|
||||
});
|
||||
|
||||
it("takes the shorter hue path across zero", () => {
|
||||
const middle = interpolateColourStops(
|
||||
[{ colour: "hsl(350 100% 50%)" }, { colour: "hsl(10 100% 50%)" }],
|
||||
0.5,
|
||||
{ space: "hsl", hue: "shorter" },
|
||||
);
|
||||
expect(formatColour(middle, "hex")).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("supports deterministic easing", () => {
|
||||
const linear = interpolateColourStops(
|
||||
[{ colour: "black" }, { colour: "white" }],
|
||||
0.5,
|
||||
{ space: "srgb" },
|
||||
);
|
||||
const eased = interpolateColourStops(
|
||||
[{ colour: "black" }, { colour: "white" }],
|
||||
0.5,
|
||||
{
|
||||
space: "srgb",
|
||||
easing: "ease-in",
|
||||
},
|
||||
);
|
||||
expect(linear.coords[0]).toBeCloseTo(0.5);
|
||||
expect(eased.coords[0]).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it("premultiplies alpha by default to avoid transparent colour fringes", () => {
|
||||
const premultiplied = interpolateColourStops(
|
||||
[{ colour: "rgba(255, 0, 0, 0)" }, { colour: "rgba(0, 0, 255, 1)" }],
|
||||
0.5,
|
||||
{ space: "srgb" },
|
||||
);
|
||||
const straight = interpolateColourStops(
|
||||
[{ colour: "rgba(255, 0, 0, 0)" }, { colour: "rgba(0, 0, 255, 1)" }],
|
||||
0.5,
|
||||
{ space: "srgb", premultiplied: false },
|
||||
);
|
||||
|
||||
expect(premultiplied.alpha).toBeCloseTo(0.5);
|
||||
expect(premultiplied.coords).toEqual([0, 0, 1]);
|
||||
expect(straight.coords).toEqual([0.5, 0, 0.5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gamut and accessibility", () => {
|
||||
it("reports and maps wide-gamut colours", () => {
|
||||
const report = gamutReport("color(display-p3 1 0 0)");
|
||||
expect(report.spaces.find((space) => space.space === "srgb")?.inGamut).toBe(
|
||||
false,
|
||||
);
|
||||
expect(report.spaces.find((space) => space.space === "p3")?.inGamut).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
report.spaces.find((space) => space.space === "p3")?.mappedCss,
|
||||
).toMatch(/^color\(display-p3 /);
|
||||
const mapped = mapToGamut("color(display-p3 1 0 0)");
|
||||
expect(
|
||||
mapped.coords.every((channel) => channel >= -1e-9 && channel <= 1 + 1e-9),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses WCAG relative luminance and contrast thresholds", () => {
|
||||
expect(relativeLuminance("black")).toBe(0);
|
||||
expect(relativeLuminance("white")).toBe(1);
|
||||
expect(contrastRatio("black", "white")).toBe(21);
|
||||
const report = contrastReport("#777", "white");
|
||||
expect(report.ratio).toBeCloseTo(4.478089, 5);
|
||||
expect(report.passes.aaNormal).toBe(false);
|
||||
expect(report.passes.aaLarge).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens alpha against the visible background", () => {
|
||||
expect(contrastRatio("rgba(0,0,0,.5)", "white")).toBeCloseTo(3.976653, 5);
|
||||
expect(
|
||||
contrastReport("black", "rgba(255,255,255,.5)", { canvas: "black" })
|
||||
.ratio,
|
||||
).toBeCloseTo(5.280823, 4);
|
||||
});
|
||||
|
||||
it("finds a nearby foreground that passes a requested target", () => {
|
||||
const suggestion = nearestPassingColour("#777", "white", 4.5);
|
||||
expect(suggestion).not.toBeNull();
|
||||
expect(suggestion?.direction).toBe("darker");
|
||||
expect(suggestion?.ratio).toBeGreaterThanOrEqual(4.5);
|
||||
expect(suggestion?.deltaEOK).toBeLessThan(0.02);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
conversionRows,
|
||||
formatColour,
|
||||
parseColour,
|
||||
parseColourList,
|
||||
toSrgbPreview,
|
||||
tryParseColour,
|
||||
} from "../../src/colour";
|
||||
|
||||
describe("colour parsing and conversion", () => {
|
||||
it("converts the RapidTables reference colour", () => {
|
||||
const colour = parseColour("#f8f5ee");
|
||||
expect(colour.space).toBe("srgb");
|
||||
expect(colour.coords).toEqual([248 / 255, 245 / 255, 238 / 255]);
|
||||
expect(formatColour(colour, "hex")).toBe("#f8f5ee");
|
||||
expect(formatColour(colour, "rgb")).toBe("rgb(248, 245, 238)");
|
||||
expect(formatColour(colour, "hsl")).toBe("hsl(42 41.6667% 95.2941%)");
|
||||
});
|
||||
|
||||
it("accepts CSS Color 4, named, bare hex and numeric RGB input", () => {
|
||||
expect(formatColour("rebeccapurple", "hex")).toBe("#663399");
|
||||
expect(formatColour("f8f5ee", "hex")).toBe("#f8f5ee");
|
||||
expect(formatColour("0xff000080", "hex8")).toBe("#ff000080");
|
||||
expect(formatColour("248, 245, 238", "hex")).toBe("#f8f5ee");
|
||||
const p3 = parseColour("color(display-p3 1 0.2 0.1 / 75%)");
|
||||
expect(p3.space).toBe("p3");
|
||||
expect(p3.alpha).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it("parses custom HSV/HSB with angles and alpha", () => {
|
||||
expect(formatColour("hsv(120 50% 60% / 25%)", "rgba")).toBe(
|
||||
"rgba(77, 153, 77, 0.25)",
|
||||
);
|
||||
expect(formatColour("hsb(.333333turn, .5, .6)", "hex")).toBe("#4d994d");
|
||||
expect(parseColour("hsv(-30deg 100% 100%)").coords[0]).toBe(330);
|
||||
});
|
||||
|
||||
it("parses custom CMYK and converts it deterministically", () => {
|
||||
expect(formatColour("cmyk(0% 100% 100% 0% / .5)", "hex8")).toBe(
|
||||
"#ff000080",
|
||||
);
|
||||
expect(formatColour("cmyk(100%, 0%, 100%, 25%)", "rgb")).toBe(
|
||||
"rgb(0, 191, 0)",
|
||||
);
|
||||
expect(formatColour("#000", "cmyk")).toBe("cmyk(0% 0% 0% 100%)");
|
||||
});
|
||||
|
||||
it("returns structured failures without throwing", () => {
|
||||
expect(tryParseColour("")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "empty" },
|
||||
});
|
||||
expect(tryParseColour("hsv(20 200% 20%)")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "out-of-range" },
|
||||
});
|
||||
expect(tryParseColour("definitely-not-a-colour").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("splits lists only at top-level delimiters", () => {
|
||||
const colours = parseColourList("rgb(1, 2, 3), hsl(20 30% 40%);\n#fff");
|
||||
expect(colours).toHaveLength(3);
|
||||
expect(formatColour(colours[0]!, "rgb")).toBe("rgb(1, 2, 3)");
|
||||
expect(parseColourList('["#f00", "#0f0"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("offers copy-ready conversion rows and a safe preview", () => {
|
||||
const rows = conversionRows("#f8f5ee", ["hex", "rgb", "oklch"]);
|
||||
expect(rows.map((row) => row.label)).toEqual(["HEX", "RGB", "OKLCH"]);
|
||||
expect(rows[0]?.copyValue).toBe("#f8f5ee");
|
||||
const preview = toSrgbPreview("color(display-p3 1 0 0)");
|
||||
expect(preview.wasMapped).toBe(true);
|
||||
expect(preview.css).toMatch(/^rgb\(/);
|
||||
expect(preview.rgb.every((channel) => channel >= 0 && channel <= 1)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips arbitrary 8-bit RGB colours through HEX", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
(red, green, blue) => {
|
||||
const value = {
|
||||
space: "srgb",
|
||||
coords: [red / 255, green / 255, blue / 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
alpha: 1,
|
||||
};
|
||||
expect(parseColour(formatColour(value, "hex")).coords).toEqual(
|
||||
value.coords,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user