Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
# @suaveplan/colour-convert-hsluv - HSLuv perceptually uniform colour space: conversions and harmony generators
HSLuv perceptually-uniform colour space with gamut-mapped ray intersection for sRGB. This plugin provides bidirectional RGB↔HSLuv/HPLuv conversions and uniform colour harmonies (complementary, triad, tetrad) that preserve perceptual lightness across hue rotations.
<!-- begin:auto-docs -->
## Documentation tree
- [colour-convert-hsluv](./src/colour-convert-hsluv/colour-convert-hsluv.md) — `colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compr…
- [colour-convert-hsluv](./src/colour-convert-hsluv/colour-convert-hsluv.md) — `colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compr…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-convert-hsluv
```
## Quick Start
```ts
import { rgbToHsluv, hsluvToRgb, uniformTriad } from "@suaveplan/colour-convert-hsluv";
const hsluv = rgbToHsluv({ r: 255, g: 0, b: 0 });
console.log(hsluv); // { h: ~12.2, s: ~100, l: ~53.2 }
const colours = uniformTriad({ r: 59, g: 130, b: 246 });
console.log(colours); // [primary, secondary, tertiary]
```
## Modules
- [`src/colour-convert-hsluv/colour-convert-hsluv.md`](./src/colour-convert-hsluv/colour-convert-hsluv.md) — HSLuv/HPLuv conversion and uniform harmonies implementation

View File

@@ -0,0 +1,61 @@
{
"name": "@suaveplan/colour-convert-hsluv",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "HSLuv perceptually uniform colour space: conversions and harmony generators",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./colour-convert-hsluv/colour-convert-hsluv": {
"import": {
"types": "./dist/colour-convert-hsluv/colour-convert-hsluv.d.ts",
"default": "./dist/colour-convert-hsluv/colour-convert-hsluv.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/colours": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,53 @@
# colour-convert-hsluv
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compresses hue angles unevenly across lightness levels, HSLuv guarantees that a given saturation percentage produces the same visual intensity regardless of hue. This makes it ideal for generating harmonious colour palettes and accessible UI themes.
## Features
- RGB ↔ HSLuv bidirectional conversion with gamut mapping
- RGB ↔ HPLuv (pastel variant) bidirectional conversion
- Uniform colour harmonies: complementary, triad, and tetrad
## Usage: Basic Example
```ts
import { rgbToHsluv, hsluvToRgb } from "@suaveplan/colour-convert-hsluv";
const hsluv = rgbToHsluv({ r: 255, g: 0, b: 0 });
const back = hsluvToRgb(hsluv);
```
## Usage: Advanced Example
```ts
import { uniformTriad } from "@suaveplan/colour-convert-hsluv";
const colours = uniformTriad({ r: 59, g: 130, b: 246 });
```
## API Reference
### Conversions
- `rgbToHsluv(rgb: RGB): HSLUV`
- `hsluvToRgb(hsluv: HSLUV): RGBA`
- `rgbToHpluv(rgb: RGB): HPLUV`
- `hpluvToRgb(hpluv: HPLUV): RGBA`
### Harmonies
- `uniformComplementary(rgb: RGB): RGB[]`
- `uniformTriad(rgb: RGB): RGB[]`
- `uniformTetrad(rgb: RGB): RGB[]`
## Implementation Notes
The HSLuv algorithm computes the maximum chroma for a given hue by intersecting rays from the white point with the sRGB gamut boundary in CIELUV space. Six rays are tested (one per RGB cube edge) and the minimum positive intersection distance becomes the saturation scale factor. This ensures that 100% saturation always touches the gamut boundary, regardless of hue or lightness.
All conversion pipelines are deterministic and free of side effects. The module has no runtime dependencies beyond the base `@suaveplan/colours` types, making it suitable for both server-side rendering and client-side bundling. Gamut mapping is performed automatically during RGB to HSLuv conversion, so out-of-range inputs are handled gracefully without throwing exceptions.

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv.js";
describe("colour-convert-hsluv", () => {
it("round-trips red", { timeout: 5000 }, () => {
const rgb = { r: 255, g: 0, b: 0 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips green", { timeout: 5000 }, () => {
const rgb = { r: 0, g: 255, b: 0 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips blue", { timeout: 5000 }, () => {
const rgb = { r: 0, g: 0, b: 255 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips gray", { timeout: 5000 }, () => {
const rgb = { r: 128, g: 128, b: 128 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("uniformComplementary returns 2 colours", { timeout: 5000 }, () => {
const colours = uniformComplementary({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(2);
});
it("uniformTriad returns 3 colours", { timeout: 5000 }, () => {
const colours = uniformTriad({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(3);
});
it("uniformTetrad returns 4 colours", { timeout: 5000 }, () => {
const colours = uniformTetrad({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(4);
});
it("handles black", { timeout: 5000 }, () => {
const hsluv = rgbToHsluv({ r: 0, g: 0, b: 0 });
expect(hsluv.l).toBeCloseTo(0, 0);
});
it("handles white", { timeout: 5000 }, () => {
const hsluv = rgbToHsluv({ r: 255, g: 255, b: 255 });
expect(hsluv.l).toBeCloseTo(100, 0);
});
it("round-trips hpluv red", { timeout: 5000 }, () => {
const original = { r: 255, g: 0, b: 0 };
const hpluv = rgbToHpluv(original);
const back = hpluvToRgb(hpluv);
expect(back.r).toBeCloseTo(original.r, -1);
expect(back.g).toBeCloseTo(original.g, -1);
expect(back.b).toBeCloseTo(original.b, -1);
});
it("hpluv handles black", { timeout: 5000 }, () => {
const hpluv = rgbToHpluv({ r: 0, g: 0, b: 0 });
expect(hpluv.l).toBeCloseTo(0, 0);
});
it("hpluv handles white", { timeout: 5000 }, () => {
const hpluv = rgbToHpluv({ r: 255, g: 255, b: 255 });
expect(hpluv.l).toBeCloseTo(100, 0);
});
it("handles hsluv black (l=0)", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 0, l: 0 });
expect(rgb.r).toBe(0);
expect(rgb.g).toBe(0);
expect(rgb.b).toBe(0);
});
it("handles hsluv dark grey (l=5)", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 0, l: 5 });
expect(rgb.r).toBeGreaterThan(0);
});
it("handles hsluv hue zero", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 100, l: 50 });
expect(rgb.r).toBeGreaterThan(0);
});
it("handles denom zero hue", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 149.5479321378151, s: 100, l: 50 });
expect(rgb.g).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,220 @@
import type { RGB, RGBA } from "@suaveplan/colours";
const INVERSE_SRGB_XYZ: [number[], number[], number[]] = [
[3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[0.0557, -0.204, 1.057],
];
const EPSILON = 0.008856;
const KAPPA = 903.3;
export interface HSLUV {
readonly h: number;
readonly s: number;
readonly l: number;
}
export interface HPLUV {
readonly h: number;
readonly s: number;
readonly l: number;
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
function maxChromaForLh(L: number, hue: number): number {
if (L > 99.9999999) return 0;
if (L < 0.00000001) return 0;
const s1 = (L + 16) / 116;
const s2 = s1 > 6 / 29 ? s1 * s1 * s1 : L / KAPPA;
const m = INVERSE_SRGB_XYZ;
const rays: { m: number; b: number }[] = [];
for (let c = 0; c < 3; c++) {
const row = m[c]!;
const m1 = row[0]!;
const m2 = row[1]!;
const m3 = row[2]!;
for (let t = 0; t < 2; t++) {
const top1 = (284517 * m1 - 94839 * m3) * s2;
const top2 =
(838422 * m3 + 769860 * m2 + 731718 * m1) * L * s2 -
769860 * t * L;
const bottom = (632260 * m3 - 126452 * m2) * s2 + 126452 * t;
rays.push({ m: top1 / bottom, b: top2 / bottom });
}
}
let min = Number.MAX_VALUE;
const hrad = toRad(hue);
for (const ray of rays) {
const denom = Math.sin(hrad) - ray.m * Math.cos(hrad);
if (denom !== 0) {
const length = ray.b / denom;
if (length >= 0 && length < min) {
min = length;
}
}
}
return min;
}
function xyzToLuv({ x, y, z }: { x: number; y: number; z: number }): {
l: number;
u: number;
v: number;
} {
const refY = 1.0;
const refU = 0.19783;
const refV = 0.46832;
const yr = y / refY;
const l = yr > EPSILON ? 116 * Math.cbrt(yr) - 16 : KAPPA * yr;
const up = x + 15 * y + 3 * z === 0 ? 0 : (4 * x) / (x + 15 * y + 3 * z);
const vp = x + 15 * y + 3 * z === 0 ? 0 : (9 * y) / (x + 15 * y + 3 * z);
return {
l,
u: 13 * l * (up - refU),
v: 13 * l * (vp - refV),
};
}
function luvToXyz({ l, u, v }: { l: number; u: number; v: number }): {
x: number;
y: number;
z: number;
} {
if (l === 0) return { x: 0, y: 0, z: 0 };
const refY = 1.0;
const refU = 0.19783;
const refV = 0.46832;
const up = u / (13 * l) + refU;
const vp = v / (13 * l) + refV;
const y = l > 8 ? refY * ((l + 16) / 116) ** 3 : (refY * l) / KAPPA;
const x = y * ((9 * up) / (4 * vp));
const z = y * ((12 - 3 * up - 20 * vp) / (4 * vp));
return { x, y, z };
}
function lchToLuv({ l, c, h }: { l: number; c: number; h: number }): {
l: number;
u: number;
v: number;
} {
const hr = toRad(h);
return { l, u: c * Math.cos(hr), v: c * Math.sin(hr) };
}
function luvToLch({ l, u, v }: { l: number; u: number; v: number }): {
l: number;
c: number;
h: number;
} {
const c = Math.sqrt(u * u + v * v);
let h = (Math.atan2(v, u) * 180) / Math.PI;
if (h < 0) h += 360;
return { l, c, h };
}
function xyzToRgb({ x, y, z }: { x: number; y: number; z: number }): RGB {
const R = x * 3.2406 + y * -1.5372 + z * -0.4986;
const G = x * -0.9689 + y * 1.8758 + z * 0.0415;
const B = x * 0.0557 + y * -0.204 + z * 1.057;
const fromLinear = (c: number) => {
c = c > 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c;
return Math.round(Math.max(0, Math.min(255, c * 255)));
};
return { r: fromLinear(R), g: fromLinear(G), b: fromLinear(B) };
}
function rgbToXyz({ r, g, b }: RGB): { x: number; y: number; z: number } {
const toLinear = (c: number) => {
c /= 255;
return c > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92;
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return {
x: R * 0.4124 + G * 0.3576 + B * 0.1805,
y: R * 0.2126 + G * 0.7152 + B * 0.0722,
z: R * 0.0193 + G * 0.1192 + B * 0.9505,
};
}
export function hsluvToRgb(hsluv: HSLUV): RGBA {
const max = maxChromaForLh(hsluv.l, hsluv.h);
const lch = {
l: hsluv.l,
c: (max * hsluv.s) / 100,
h: hsluv.h,
};
const luv = lchToLuv(lch);
const xyz = luvToXyz(luv);
return { ...xyzToRgb(xyz), a: 1 };
}
export function rgbToHsluv(rgb: RGB): HSLUV {
const xyz = rgbToXyz(rgb);
const luv = xyzToLuv(xyz);
const lch = luvToLch(luv);
const max = maxChromaForLh(lch.l, lch.h);
return {
h: lch.h,
s: (lch.c / max) * 100,
l: lch.l,
};
}
export function hpluvToRgb(hpluv: HPLUV): RGBA {
const max = maxChromaForLh(50, hpluv.h);
const lch = {
l: hpluv.l,
c: (max * hpluv.s) / 100,
h: hpluv.h,
};
const luv = lchToLuv(lch);
const xyz = luvToXyz(luv);
return { ...xyzToRgb(xyz), a: 1 };
}
export function rgbToHpluv(rgb: RGB): HPLUV {
const xyz = rgbToXyz(rgb);
const luv = xyzToLuv(xyz);
const lch = luvToLch(luv);
const max = maxChromaForLh(50, lch.h);
return {
h: lch.h,
s: (lch.c / max) * 100,
l: lch.l,
};
}
export function uniformComplementary(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [hsluvToRgb({ ...hsluv, h: (hsluv.h + 180) % 360 }), rgb];
}
export function uniformTriad(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [
rgb,
hsluvToRgb({ ...hsluv, h: (hsluv.h + 120) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 240) % 360 }),
];
}
export function uniformTetrad(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [
rgb,
hsluvToRgb({ ...hsluv, h: (hsluv.h + 90) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 180) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 270) % 360 }),
];
}

View File

@@ -0,0 +1,13 @@
export type {
HPLUV,
HSLUV,
} from "./colour-convert-hsluv.js";
export {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv.js";

View File

@@ -0,0 +1,10 @@
export type { HPLUV, HSLUV } from "./colour-convert-hsluv/index.js";
export {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv/index.js";

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("only depends on allowed @suaveplan packages", {
timeout: 5000,
}, async () => {
const pkg = await import("../package.json");
const deps = Object.keys(pkg.default.dependencies || {});
const allowed = ["@suaveplan/colours"];
for (const dep of deps) {
if (dep.startsWith("@suaveplan/")) {
expect(allowed).toContain(dep);
}
}
});
});

View File

@@ -0,0 +1 @@
// no polyfills needed

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("has sideEffects false", { timeout: 5000 }, async () => {
const pkg = await import("../package.json");
expect(pkg.default.sideEffects).toBe(false);
});
it("has at least one subpath export", { timeout: 5000 }, async () => {
const pkg = await import("../package.json");
const exports = Object.keys(pkg.default.exports || {});
expect(exports.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"colour-convert-hsluv/colour-convert-hsluv": "./src/colour-convert-hsluv/index.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "tslib"],
},
},
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 5000,
},
});