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
+201
View File
@@ -0,0 +1,201 @@
import { expect, test, type Page } from "@playwright/test";
const APP_ORIGIN = "http://127.0.0.1:4173";
const FOUR_COLOUR_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAE0lEQVR4nGP4z8DwHwwZGP6DAQBJyAn3FGMynQAAAABJRU5ErkJggg==";
async function keepNetworkLocal(page: Page): Promise<string[]> {
const externalRequests: string[] = [];
await page.route("**/*", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.origin !== APP_ORIGIN) {
externalRequests.push(requestUrl.href);
await route.abort();
return;
}
await route.continue();
});
return externalRequests;
}
function recordRuntimeErrors(page: Page): string[] {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => runtimeErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
return runtimeErrors;
}
test("converts, composites and interpolates without transient empty results", async ({
page,
}) => {
const runtimeErrors = recordRuntimeErrors(page);
const externalRequests = await keepNetworkLocal(page);
await page.goto("/deep/nested/colour/");
await expect(
page.getByRole("heading", { name: "Colour workbench" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Convert colours" }),
).toBeVisible();
await page.getByLabel("Colour", { exact: true }).fill("rgb(255 0 0 / 50%)");
const alphaHex = page
.locator(".conversion-row")
.filter({ hasText: "HEX + alpha" });
await expect(alphaHex).toContainText("#ff000080");
await page.getByLabel("Colour", { exact: true }).fill("rgb(255 0");
await expect(
page.getByText(
"Keeping the last valid conversion while you finish editing.",
),
).toBeVisible();
await expect(alphaHex).toContainText("#ff000080");
await page.getByRole("tab", { name: /Composite/u }).click();
await expect(
page.getByRole("heading", { name: "Composite translucent colours" }),
).toBeVisible();
await expect(page.locator(".layer-row")).toHaveCount(3);
await page.getByRole("button", { name: "+ Add layer" }).click();
await expect(page.locator(".layer-row")).toHaveCount(4);
await expect(page.locator(".result-values code").first()).not.toBeEmpty();
await page.getByRole("tab", { name: /Steps/u }).click();
await expect(
page.getByRole("heading", { name: "Step between colours" }),
).toBeVisible();
await expect(page.locator(".step-swatch")).toHaveCount(9);
await page.getByLabel(/Swatches/u).fill("5");
await expect(page.locator(".step-swatch")).toHaveCount(5);
expect(externalRequests).toEqual([]);
expect(runtimeErrors).toEqual([]);
});
test("keeps picking, accessibility analysis and palette data browser-local", async ({
page,
}) => {
const runtimeErrors = recordRuntimeErrors(page);
const externalRequests = await keepNetworkLocal(page);
await page.goto("/deep/nested/colour/#pick");
await expect(
page.getByRole("heading", { name: "Pick a colour" }),
).toBeVisible();
const picker = page.getByRole("application");
await expect(picker).toHaveAccessibleName(/Saturation 62 percent/u);
await picker.focus();
await picker.press("ArrowRight");
await expect(picker).toHaveAccessibleName(/Saturation 63 percent/u);
await page.getByLabel("Exact CSS colour").fill("oklch(70% 0.2 30)");
await page.getByRole("button", { name: "Add to palette" }).click();
await page.getByRole("tab", { name: /Contrast/u }).click();
await expect(
page.getByRole("heading", { name: "Contrast & compare" }),
).toBeVisible();
await page
.getByRole("textbox", { name: "Foreground", exact: true })
.fill("#000000");
await page
.getByRole("textbox", { name: "Background", exact: true })
.fill("#ffffff");
await expect(page.locator(".ratio-heading strong")).toHaveText("21.00");
await expect(
page.getByText("AAA normal text", { exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: /Palette/u }).click();
await expect(
page.getByRole("heading", { name: "Build a palette" }),
).toBeVisible();
await page
.getByLabel("Palette data")
.fill("primary: #123456\naccent: oklch(76% 0.17 87)");
await page.getByRole("button", { name: "Import colours" }).click();
await expect(page.getByRole("status")).toContainText("Added 2 colours");
await expect(page.locator(".saved-colour-list > li")).toHaveCount(3);
await expect(page.locator(".export-preview")).toContainText(
"--brand-primary",
);
await page.reload();
await page.getByRole("tab", { name: /Palette/u }).click();
await expect(page.locator(".saved-colour-list > li")).toHaveCount(3);
expect(externalRequests).toEqual([]);
expect(runtimeErrors).toEqual([]);
});
test("loads, samples and extracts a palette from an in-memory image", async ({
page,
}) => {
const runtimeErrors = recordRuntimeErrors(page);
const externalRequests = await keepNetworkLocal(page);
await page.goto("/deep/nested/colour/#image");
await expect(
page.getByRole("heading", { name: "Pick and extract colours" }),
).toBeVisible();
await page.locator('input[type="file"]').evaluate((element, encoded) => {
const bytes = Uint8Array.from(atob(encoded), (character) =>
character.charCodeAt(0),
);
const transfer = new DataTransfer();
transfer.items.add(
new File([bytes], "four-colours.png", { type: "image/png" }),
);
const input = element as HTMLInputElement;
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
}, FOUR_COLOUR_PNG);
await expect(page.locator(".image-picker-lab__status")).toContainText(
"four-colours.png loaded: 2 by 2 pixels",
);
await page.getByRole("button", { name: "Sample coordinate" }).click();
await expect(page.getByLabel("Sampled colour #FF0000")).toBeVisible();
await page.getByRole("button", { name: "Extract palette" }).click();
await expect(
page.getByRole("list", { name: "Extracted image palette" }),
).toBeVisible({ timeout: 30_000 });
expect(
await page
.getByRole("list", { name: "Extracted image palette" })
.getByRole("listitem")
.count(),
).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "Remove image" }).click();
await expect(page.locator(".image-picker-lab__status")).toContainText(
"Image removed",
);
expect(externalRequests).toEqual([]);
expect(runtimeErrors).toEqual([]);
});
test("serves a relocatable production artifact with hardened headers", async ({
request,
}) => {
const index = await request.get("/deep/nested/colour/");
expect(index.ok()).toBe(true);
expect(index.headers()["content-security-policy"]).toContain(
"default-src 'self'",
);
expect(index.headers()["x-content-type-options"]).toBe("nosniff");
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
const manifest = await request.get("/deep/nested/colour/toolbox-app.json");
expect(manifest.headers()["content-type"]).toContain("application/json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.colour-tools",
version: "0.1.0",
entry: "./",
icon: "./favicon.svg",
});
});
+111
View File
@@ -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",
);
});
});
+130
View File
@@ -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);
});
});
+106
View File
@@ -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 },
);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompositeLab } from "../../src/components/CompositeLab";
import { ConverterLab } from "../../src/components/ConverterLab";
import { PickerLab } from "../../src/components/PickerLab";
import { Workbench } from "../../src/components/Workbench";
describe("colour workbench", () => {
beforeEach(() => {
localStorage.clear();
history.replaceState(null, "", "/");
});
it("exposes all seven workspaces as accessible tabs", () => {
render(<Workbench />);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(7);
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
expect(
screen.getByRole("heading", { name: "Convert colours" }),
).toBeVisible();
});
it("supports keyboard navigation between workspaces", async () => {
render(<Workbench />);
const convert = screen.getByRole("tab", { name: /Convert/ });
convert.focus();
await userEvent.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: /Composite/ })).toHaveAttribute(
"aria-selected",
"true",
);
expect(
screen.getByRole("heading", { name: "Composite translucent colours" }),
).toBeVisible();
});
});
describe("interactive colour labs", () => {
it("keeps the last valid conversion visible while an input is incomplete", async () => {
render(<ConverterLab onAddColour={vi.fn()} />);
const source = screen.getByLabelText("Colour");
await userEvent.clear(source);
await userEvent.type(source, "oklch(");
expect(source).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/Keeping the last valid conversion/)).toBeVisible();
expect(screen.getByText("HEX + alpha")).toBeVisible();
});
it("produces an opaque composite over the enabled matte", () => {
render(<CompositeLab onAddColour={vi.fn()} />);
const result = screen.getByText("Result").closest("aside");
expect(result).not.toBeNull();
expect(within(result!).getByText(/^#[0-9a-f]{6}$/i)).toBeVisible();
expect(screen.getByText(/Produces an opaque RGB result/)).toBeVisible();
});
it("keeps the exact picker field in sync with visual controls", () => {
render(<PickerLab onAddColour={vi.fn()} />);
const hue = screen.getByLabelText(/Hue ·/);
fireEvent.change(hue, { target: { value: "0" } });
expect(screen.getByLabelText("Exact CSS colour")).toHaveValue("#d14f4f");
expect(screen.getByText("#d14f4f")).toBeVisible();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
getBoundedSampleRegion,
mapClientPointToPixel,
validateImageDimensions,
validateImageFile,
} from "../../src/palette/bounds";
import type { ImageLimits } from "../../src/palette/types";
const limits: ImageLimits = {
maxBytes: 1_000,
maxPixels: 100,
maxDimension: 20,
acceptedMimeTypes: ["image/png"],
};
describe("local image bounds", () => {
it("rejects empty, oversized, and unsupported files", () => {
expect(
validateImageFile(
{ name: "empty.png", size: 0, type: "image/png" },
limits,
).valid,
).toBe(false);
expect(
validateImageFile(
{ name: "large.png", size: 1_001, type: "image/png" },
limits,
).valid,
).toBe(false);
expect(
validateImageFile(
{ name: "vector.svg", size: 10, type: "image/svg+xml" },
limits,
).valid,
).toBe(false);
});
it("accepts a bounded raster file", () => {
expect(
validateImageFile(
{ name: "pixel.png", size: 999, type: "IMAGE/PNG" },
limits,
),
).toEqual({
valid: true,
});
expect(
validateImageFile({ name: "pixel.png", size: 999, type: "" }, limits)
.valid,
).toBe(true);
});
it("checks decoded dimensions and pixel area", () => {
expect(validateImageDimensions(10, 10, limits).valid).toBe(true);
expect(validateImageDimensions(21, 2, limits).valid).toBe(false);
expect(validateImageDimensions(11, 10, limits).valid).toBe(false);
expect(validateImageDimensions(Number.NaN, 10, limits).valid).toBe(false);
});
it("maps CSS-scaled canvas coordinates to bounded image pixels", () => {
const rect = { left: 100, top: 50, width: 200, height: 100 };
expect(mapClientPointToPixel(200, 100, rect, 1_000, 500)).toEqual({
x: 500,
y: 250,
});
expect(mapClientPointToPixel(500, -20, rect, 1_000, 500)).toEqual({
x: 999,
y: 0,
});
expect(
mapClientPointToPixel(200, 100, { ...rect, width: 0 }, 1_000, 500),
).toBeNull();
});
it("clips sampling regions at image edges", () => {
expect(getBoundedSampleRegion(0, 0, 2, 10, 8)).toEqual({
left: 0,
top: 0,
width: 3,
height: 3,
centerX: 0,
centerY: 0,
});
expect(getBoundedSampleRegion(Number.NaN, 0, 2, 10, 8)).toBeNull();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { decodeLocalImage } from "../../src/palette/decode";
const nativeUrl = globalThis.URL;
afterEach(() => {
vi.unstubAllGlobals();
});
const installImageElementDecoder = (loadImmediately: boolean) => {
const createObjectURL = vi.fn(() => "blob:local-image");
const revokeObjectURL = vi.fn();
class MockUrl extends nativeUrl {
static createObjectURL = createObjectURL;
static revokeObjectURL = revokeObjectURL;
}
class MockImage {
decoding = "auto";
naturalWidth = 320;
naturalHeight = 180;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private source = "";
set src(value: string) {
this.source = value;
if (value && loadImmediately) queueMicrotask(() => this.onload?.());
}
get src(): string {
return this.source;
}
}
vi.stubGlobal("URL", MockUrl);
vi.stubGlobal("Image", MockImage);
vi.stubGlobal("createImageBitmap", undefined);
return { createObjectURL, revokeObjectURL };
};
describe("local image decoding", () => {
it("revokes its object URL as soon as the image has decoded", async () => {
const spies = installImageElementDecoder(true);
const image = await decodeLocalImage(
new File(["image"], "sample.png", { type: "image/png" }),
);
expect(image).toMatchObject({ width: 320, height: 180 });
expect(spies.createObjectURL).toHaveBeenCalledOnce();
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
image.dispose();
});
it("revokes its object URL when decoding is cancelled", async () => {
const spies = installImageElementDecoder(false);
const controller = new AbortController();
const decoding = decodeLocalImage(
new File(["image"], "sample.png", { type: "image/png" }),
controller.signal,
);
controller.abort();
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from "vitest";
import { extractPalette } from "../../src/palette/extract";
import { extractPaletteSafely } from "../../src/palette/worker-client";
const makePixels = (
colours: readonly (readonly [number, number, number, number])[],
) => ({
width: colours.length,
height: 1,
data: new Uint8ClampedArray(colours.flat()),
});
describe("deterministic Oklab palette extraction", () => {
it("returns dominant colours in coverage order", () => {
const input = makePixels([
[255, 0, 0, 255],
[255, 0, 0, 255],
[255, 0, 0, 255],
[0, 0, 255, 255],
]);
const palette = extractPalette(input, { count: 2 });
expect(palette.map((colour) => colour.hex)).toEqual(["#FF0000", "#0000FF"]);
expect(palette[0]?.coverage).toBeCloseTo(0.75, 8);
expect(palette[1]?.coverage).toBeCloseTo(0.25, 8);
});
it("produces byte-for-byte stable results for repeated runs", () => {
const input = makePixels([
[248, 245, 238, 255],
[20, 80, 180, 255],
[245, 140, 20, 255],
[248, 245, 238, 255],
[25, 85, 175, 220],
[240, 145, 25, 255],
]);
const first = extractPalette(input, { count: 3 });
const second = extractPalette(input, { count: 3 });
expect(second).toEqual(first);
});
it("ignores fully transparent pixels and respects the alpha threshold", () => {
const input = makePixels([
[255, 0, 255, 0],
[255, 0, 0, 15],
[0, 255, 0, 255],
[0, 0, 255, 255],
]);
const palette = extractPalette(input, { count: 3, minimumAlpha: 16 });
expect(palette.map((colour) => colour.hex)).toEqual(["#0000FF", "#00FF00"]);
expect(
palette.reduce((sum, colour) => sum + colour.coverage, 0),
).toBeCloseTo(1, 8);
});
it("handles a fully transparent image without inventing colours", () => {
expect(extractPalette(makePixels([[10, 20, 30, 0]]), { count: 6 })).toEqual(
[],
);
});
it("rejects malformed pixel buffers", () => {
expect(() =>
extractPalette(
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
{ count: 2 },
),
).toThrow("Invalid pixel buffer");
});
it("falls back safely when Web Workers are unavailable", async () => {
const input = makePixels([
[255, 0, 0, 255],
[0, 0, 255, 255],
]);
vi.stubGlobal("Worker", undefined);
try {
await expect(
extractPaletteSafely(input, { count: 2 }),
).resolves.toHaveLength(2);
} finally {
vi.unstubAllGlobals();
}
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { samplePixels } from "../../src/palette/sample";
const pixels = (
width: number,
colours: readonly (readonly [number, number, number, number])[],
) => ({
width,
height: colours.length / width,
data: new Uint8ClampedArray(colours.flat()),
});
describe("pixel sampling", () => {
it("returns the exact pixel for a zero-radius sample", () => {
const result = samplePixels(
pixels(2, [
[12, 34, 56, 255],
[200, 100, 50, 128],
]),
1,
0,
0,
"average",
);
expect(result).toMatchObject({
r: 200,
g: 100,
b: 50,
a: 128,
hex: "#C8643280",
pixelCount: 1,
});
});
it("uses a circular neighbourhood and alpha-weighted RGB average", () => {
const data = pixels(3, [
[0, 0, 0, 0],
[255, 0, 0, 255],
[0, 0, 0, 0],
[0, 255, 0, 255],
[0, 0, 255, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[255, 255, 255, 255],
[0, 0, 0, 0],
]);
const result = samplePixels(data, 1, 1, 1, "average");
expect(result).toMatchObject({
r: 170,
g: 170,
b: 85,
a: 153,
pixelCount: 5,
});
});
it("calculates channel medians from visible samples", () => {
const result = samplePixels(
pixels(3, [
[10, 90, 200, 255],
[200, 50, 10, 128],
[100, 10, 90, 0],
]),
1,
0,
1,
"median",
);
expect(result).toMatchObject({
r: 105,
g: 70,
b: 105,
a: 128,
pixelCount: 3,
});
});
it("rejects invalid coordinates and truncated buffers", () => {
expect(
samplePixels(pixels(1, [[1, 2, 3, 4]]), 1, 0, 0, "average"),
).toBeNull();
expect(
samplePixels(
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
0,
0,
0,
"average",
),
).toBeNull();
});
});