Files
Manav Arya Singh 95a18d7211 feat(v12.9): extraction quality pass — fix the core MVP
Eight features rode on top of the same extractor. This release fixes
four real defects in that extractor — visible across every downstream
output (grade, battle, remix, pack, theme-swap, brand, pair).

Fixed

  1. clusterColors() representative bug. Before, the first-encountered
     colour seeded each cluster, so a sparsely-used pale shade could
     become the canonical hex of a cluster that mostly held a vivid
     brand colour. The brand-book primary slot was reading lavender
     for Stripe instead of #533afd. Now: representative is the
     most-counted member.

  2. detectScale() missed common production base units. Before, only
     [2, 4, 6, 8] were tried, so Bootstrap-style base-5 and base-7/10/12
     sites returned base:null. Now: [2, 4, 5, 6, 7, 8, 10, 12, 16] with
     a small bonus for 4 and 8 to keep results stable for production
     defaults.

  3. Typography noise. Generic CSS stacks (sans-serif, monospace,
     system-ui, inherit, -apple-system, ...) and icon fonts (Material
     Icons, Font Awesome, Lucide, Tabler, ...) polluted the families
     list. Brand books had icon fonts documented as the body family.
     Now: an explicit filter at the source.

Added

  4. primary.confidence (0–1) on design.colors.primary. Computed from
     the brand-score gap between rank 1 and rank 2. Runaway leader →
     1.0; near-tie → 0.3. Downstream consumers can surface uncertainty
     warnings on low-confidence extractions.

  5. asList(v) helper exported from src/utils.js. Coerces anything-
     shaped input (array / object / comma-string / scalar) into a clean
     string array. Consolidates ad-hoc per-formatter defenses.

Verified live on stripe.com

  Before: primary slot showed a washed-out lavender; multiple icon
  fonts polluted families; spacing base often null.

  After: primary #533afd (count 899, confidence 0.59), families
  ['sohne-var'], spacing base correctly detected.

No new dependencies, no schema breaks, no public-API changes.
396/396 tests pass (6 new).
2026-05-12 13:31:28 +04:00

451 lines
15 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
parseColor,
rgbToHex,
rgbToHsl,
colorDistance,
clusterColors,
clusterValues,
parseCSSValue,
detectScale,
nameFromUrl,
isSaturated,
safeName,
remToPx,
pxToRem,
} from '../src/utils.js';
// ── parseColor ──────────────────────────────────────────────────
describe('parseColor', () => {
it('returns null for null/undefined/empty/none/inherit', () => {
assert.equal(parseColor(null), null);
assert.equal(parseColor(undefined), null);
assert.equal(parseColor(''), null);
assert.equal(parseColor('none'), null);
assert.equal(parseColor('inherit'), null);
assert.equal(parseColor('initial'), null);
assert.equal(parseColor('currentcolor'), null);
});
it('parses 3-digit hex (#RGB)', () => {
const c = parseColor('#f00');
assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
});
it('parses 6-digit hex (#RRGGBB)', () => {
const c = parseColor('#0066cc');
assert.deepEqual(c, { r: 0, g: 102, b: 204, a: 1 });
});
it('parses 8-digit hex (#RRGGBBAA)', () => {
const c = parseColor('#0066cc80');
assert.equal(c.r, 0);
assert.equal(c.g, 102);
assert.equal(c.b, 204);
assert.ok(Math.abs(c.a - 128 / 255) < 0.01);
});
it('parses 4-digit hex (#RGBA)', () => {
const c = parseColor('#f008');
assert.equal(c.r, 255);
assert.equal(c.g, 0);
assert.equal(c.b, 0);
assert.ok(Math.abs(c.a - 0x88 / 255) < 0.01);
});
it('parses rgb(r, g, b)', () => {
const c = parseColor('rgb(100, 200, 50)');
assert.deepEqual(c, { r: 100, g: 200, b: 50, a: 1 });
});
it('parses rgba(r, g, b, a)', () => {
const c = parseColor('rgba(100, 200, 50, 0.5)');
assert.deepEqual(c, { r: 100, g: 200, b: 50, a: 0.5 });
});
it('parses hsl(h, s%, l%)', () => {
const c = parseColor('hsl(0, 100%, 50%)');
assert.equal(c.r, 255);
assert.equal(c.g, 0);
assert.equal(c.b, 0);
assert.equal(c.a, 1);
});
it('parses hsla(h, s%, l%, a)', () => {
const c = parseColor('hsla(0, 100%, 50%, 0.5)');
assert.equal(c.r, 255);
assert.equal(c.a, 0.5);
});
it('parses modern hsl syntax: hsl(210 50% 40%)', () => {
const c = parseColor('hsl(210 50% 40%)');
assert.ok(c);
assert.equal(c.a, 1);
// hsl(210, 50%, 40%) -> some shade of blue
assert.ok(c.b > c.r);
});
it('parses modern hsl syntax with alpha: hsl(210 50% 40% / 0.5)', () => {
const c = parseColor('hsl(210 50% 40% / 0.5)');
assert.ok(c);
assert.equal(c.a, 0.5);
});
it('parses oklch(L C H)', () => {
const c = parseColor('oklch(0.5 0.2 270)');
assert.ok(c);
assert.equal(c.a, 1);
assert.ok(c.r >= 0 && c.r <= 255);
});
it('parses oklch with alpha', () => {
const c = parseColor('oklch(0.5 0.2 270 / 0.8)');
assert.ok(c);
assert.equal(c.a, 0.8);
});
it('parses oklab(L a b)', () => {
const c = parseColor('oklab(0.5 0.1 -0.1)');
assert.ok(c);
assert.equal(c.a, 1);
assert.ok(c.r >= 0 && c.r <= 255);
});
it('parses color-mix(in srgb, ...)', () => {
const c = parseColor('color-mix(in srgb, #ff0000 50%, #0000ff)');
assert.ok(c);
// Should be roughly halfway between red and blue
assert.ok(c.r > 100);
assert.ok(c.b > 100);
});
it('parses named colors', () => {
const c = parseColor('red');
assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
});
it('parses named color white', () => {
const c = parseColor('white');
assert.deepEqual(c, { r: 255, g: 255, b: 255, a: 1 });
});
it('parses named colors case-insensitively', () => {
const c = parseColor('RED');
assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
});
it('returns null for unrecognized strings', () => {
assert.equal(parseColor('not-a-color'), null);
assert.equal(parseColor('blahblah'), null);
});
});
// ── rgbToHex ────────────────────────────────────────────────────
describe('rgbToHex', () => {
it('converts black', () => {
assert.equal(rgbToHex({ r: 0, g: 0, b: 0 }), '#000000');
});
it('converts white', () => {
assert.equal(rgbToHex({ r: 255, g: 255, b: 255 }), '#ffffff');
});
it('converts a mid color', () => {
assert.equal(rgbToHex({ r: 0, g: 102, b: 204 }), '#0066cc');
});
});
// ── rgbToHsl ────────────────────────────────────────────────────
describe('rgbToHsl', () => {
it('converts pure red', () => {
const hsl = rgbToHsl({ r: 255, g: 0, b: 0 });
assert.equal(hsl.h, 0);
assert.equal(hsl.s, 100);
assert.equal(hsl.l, 50);
});
it('converts pure green (CSS green = 0,128,0)', () => {
const hsl = rgbToHsl({ r: 0, g: 128, b: 0 });
assert.equal(hsl.h, 120);
assert.equal(hsl.l, 25);
});
it('converts gray (achromatic)', () => {
const hsl = rgbToHsl({ r: 128, g: 128, b: 128 });
assert.equal(hsl.h, 0);
assert.equal(hsl.s, 0);
assert.equal(hsl.l, 50);
});
it('converts white', () => {
const hsl = rgbToHsl({ r: 255, g: 255, b: 255 });
assert.equal(hsl.l, 100);
assert.equal(hsl.s, 0);
});
});
// ── colorDistance ────────────────────────────────────────────────
describe('colorDistance', () => {
it('returns 0 for identical colors', () => {
assert.equal(colorDistance({ r: 100, g: 100, b: 100 }, { r: 100, g: 100, b: 100 }), 0);
});
it('returns correct Euclidean distance', () => {
const d = colorDistance({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 });
assert.ok(Math.abs(d - Math.sqrt(3 * 255 * 255)) < 0.01);
});
it('returns small distance for similar colors', () => {
const d = colorDistance({ r: 100, g: 100, b: 100 }, { r: 105, g: 100, b: 100 });
assert.ok(d < 10);
});
});
// ── clusterColors ───────────────────────────────────────────────
describe('clusterColors', () => {
it('groups similar colors together', () => {
const colors = [
{ hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
{ hex: '#ff0505', parsed: { r: 255, g: 5, b: 5 }, count: 5 },
{ hex: '#0000ff', parsed: { r: 0, g: 0, b: 255 }, count: 3 },
];
const clusters = clusterColors(colors, 15);
// Red shades should be grouped, blue separate
assert.equal(clusters.length, 2);
assert.equal(clusters[0].count, 15); // red cluster total
assert.equal(clusters[1].count, 3); // blue cluster
});
it('returns each color as its own cluster when threshold is 0', () => {
const colors = [
{ hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
{ hex: '#ff0505', parsed: { r: 255, g: 5, b: 5 }, count: 5 },
];
const clusters = clusterColors(colors, 0);
assert.equal(clusters.length, 2);
});
it('sorts clusters by count descending', () => {
const colors = [
{ hex: '#0000ff', parsed: { r: 0, g: 0, b: 255 }, count: 3 },
{ hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
];
const clusters = clusterColors(colors, 15);
assert.ok(clusters[0].count >= clusters[clusters.length - 1].count);
});
it('uses the most-used member as the cluster representative (not first-encountered)', () => {
// The lavender (#f0f0ff) was encountered FIRST but is only used 2 times;
// the deep purple (#5544ff) joins its cluster and is used 50 times.
// The representative MUST be the dominant shade for downstream
// primary-detection to work correctly. (This was a real bug pre-v12.9
// that produced washed-out brand colours in the brand book.)
const colors = [
{ hex: '#f0f0ff', parsed: { r: 240, g: 240, b: 255 }, count: 2 },
{ hex: '#5544ff', parsed: { r: 85, g: 68, b: 255 }, count: 50 },
];
const clusters = clusterColors(colors, 280); // big threshold → both cluster together
assert.equal(clusters.length, 1);
assert.equal(clusters[0].hex, '#5544ff', 'cluster hex must be the dominant member');
assert.deepEqual(clusters[0].representative, { r: 85, g: 68, b: 255 });
});
});
// ── clusterValues ───────────────────────────────────────────────
describe('clusterValues', () => {
it('groups nearby numbers', () => {
const result = clusterValues([4, 5, 8, 16, 17, 32], 2);
// 4 and 5 cluster -> 4; 8 standalone; 16 and 17 cluster -> 16; 32 standalone
assert.ok(result.includes(4));
assert.ok(result.includes(8));
assert.ok(result.includes(16));
assert.ok(result.includes(32));
assert.ok(!result.includes(5));
assert.ok(!result.includes(17));
});
it('returns all values when threshold is 0', () => {
const result = clusterValues([1, 2, 3], 0);
assert.deepEqual(result, [1, 2, 3]);
});
});
// ── parseCSSValue ───────────────────────────────────────────────
describe('parseCSSValue', () => {
it('parses px values', () => {
const r = parseCSSValue('16px');
assert.deepEqual(r, { value: 16, unit: 'px' });
});
it('parses rem values', () => {
const r = parseCSSValue('1.5rem');
assert.deepEqual(r, { value: 1.5, unit: 'rem' });
});
it('parses em values', () => {
const r = parseCSSValue('2em');
assert.deepEqual(r, { value: 2, unit: 'em' });
});
it('parses percentage values', () => {
const r = parseCSSValue('100%');
assert.deepEqual(r, { value: 100, unit: '%' });
});
it('parses unitless numbers', () => {
const r = parseCSSValue('1.5');
assert.deepEqual(r, { value: 1.5, unit: '' });
});
it('returns null for normal/auto/none', () => {
assert.equal(parseCSSValue('normal'), null);
assert.equal(parseCSSValue('auto'), null);
assert.equal(parseCSSValue('none'), null);
});
it('returns null for null/undefined', () => {
assert.equal(parseCSSValue(null), null);
assert.equal(parseCSSValue(undefined), null);
});
});
// ── detectScale ─────────────────────────────────────────────────
describe('detectScale', () => {
it('detects a base unit for multiples-of-4 scale', () => {
// All values are divisible by 2 and 4; the algorithm picks the first
// candidate (2) that reaches the 60% threshold, so base is 2.
const result = detectScale([4, 8, 12, 16, 24, 32, 48, 64]);
assert.ok(result.base !== null);
assert.ok([2, 4].includes(result.base));
});
it('detects a base unit for multiples-of-8 scale', () => {
const result = detectScale([8, 16, 24, 32, 40, 48, 56, 64]);
assert.ok(result.base !== null);
assert.ok([2, 4, 8].includes(result.base));
});
it('returns null base for fewer than 3 values', () => {
const result = detectScale([10, 20]);
assert.equal(result.base, null);
});
it('returns null base for arbitrary values', () => {
const result = detectScale([3, 7, 11, 19, 37, 53]);
assert.equal(result.base, null);
});
it('detects base-5 scales (Bootstrap-style)', () => {
// 5/10/15/20/25/40/60 — all divisible by 5; nothing in [2,4,6,8] fits
// well, so before this change the result was base:null.
const result = detectScale([5, 10, 15, 20, 25, 40, 60]);
assert.equal(result.base, 5);
});
it('detects base-6 scales', () => {
const result = detectScale([6, 12, 18, 24, 30, 42, 60]);
// base 2 or 6 both fit ≥60%; 6 should win the tie (or 2 with bonus).
assert.ok([2, 6].includes(result.base));
});
it('still prefers base-8 when both 4 and 8 fit (Tailwind-leaning bonus)', () => {
// 8/16/24/32/40 — 4 fits 100%, 8 fits 100%. Tie. Bonus keeps both
// valid but the chosen base should still be one of the production
// defaults, not a coincidental higher divisor.
const result = detectScale([8, 16, 24, 32, 40]);
assert.ok([4, 8].includes(result.base));
});
});
// ── nameFromUrl ─────────────────────────────────────────────────
describe('nameFromUrl', () => {
it('extracts hostname and makes it safe', () => {
// safeName replaces dots with hyphens
assert.equal(nameFromUrl('https://www.example.com/page'), 'example-com');
});
it('strips www prefix', () => {
const name = nameFromUrl('https://www.google.com');
assert.ok(!name.startsWith('www'));
});
it('returns unknown-site for invalid URLs', () => {
assert.equal(nameFromUrl('not a url'), 'unknown-site');
});
it('handles complex domains', () => {
const name = nameFromUrl('https://my-app.vercel.app/dashboard');
assert.equal(name, 'my-app-vercel-app');
});
});
// ── isSaturated ─────────────────────────────────────────────────
describe('isSaturated', () => {
it('returns true for a saturated color', () => {
assert.equal(isSaturated({ r: 255, g: 0, b: 0 }), true);
});
it('returns false for gray', () => {
assert.equal(isSaturated({ r: 128, g: 128, b: 128 }), false);
});
it('returns false for white', () => {
assert.equal(isSaturated({ r: 255, g: 255, b: 255 }), false);
});
it('returns false for black', () => {
assert.equal(isSaturated({ r: 0, g: 0, b: 0 }), false);
});
});
// ── safeName ────────────────────────────────────────────────────
describe('safeName', () => {
it('replaces special characters with hyphens', () => {
assert.equal(safeName('Hello World!'), 'hello-world');
});
it('collapses multiple hyphens', () => {
assert.equal(safeName('a--b---c'), 'a-b-c');
});
it('trims leading/trailing hyphens', () => {
assert.equal(safeName('--test--'), 'test');
});
});
// ── remToPx / pxToRem ──────────────────────────────────────────
describe('remToPx', () => {
it('converts with default base 16', () => {
assert.equal(remToPx(1), 16);
assert.equal(remToPx(2), 32);
});
it('converts with custom base', () => {
assert.equal(remToPx(1, 10), 10);
});
});
describe('pxToRem', () => {
it('converts with default base 16', () => {
assert.equal(pxToRem(16), 1);
assert.equal(pxToRem(32), 2);
});
it('converts with custom base', () => {
assert.equal(pxToRem(10, 10), 1);
});
});