mirror of
https://github.com/Manavarya09/design-extract.git
synced 2026-09-19 02:41:14 +08:00
ed257b9a65
Bug fixes: - Shadow DOM traversal for web components (#12) - Cross-origin stylesheet parsing via fetch fallback (#11) - CSS Color Level 4: oklch(), oklab(), color-mix(), modern hsl() - Natural-break clustering for spacing/borders - Full border-radius shorthand + border-width/style extraction - History path via os.homedir() + auto-pruning at 50 snapshots Extraction quality: - Shadows: full component parsing, visual weight classification, text-shadow - Animations: delay/iteration/fill-mode, timing pattern classification - Variables: dependency graph, semantic token detection - Gradients: nested rgb() fix, all angle units, vendor prefix normalization - Components: 5 new types (tabs, accordions, tooltips, progress, switches) and button variant detection New features: - --json / --json-pretty for CI/CD pipelines (#14) - --ignore <selectors> to exclude third-party widgets (#15) - .designlangrc config file support - WordPress theme.json v3 export (#17) - Vue (Vuetify 3) and Svelte theme formatters (#13) - designlang export command (JSON/CSV) Formatter upgrades: - Tailwind: 50-950 color scales, animation/transition/container config - Figma: semantic collections, light/dark modes, typography variables - Preview HTML: copy-to-clipboard, dark/light toggle, responsive - React theme: TypeScript types, MUI v5 export, interactive state tokens Quality: - 182 tests (utils, extractors, formatters, CLI) - URL/numeric input validation on all commands - Retry logic (3x backoff) for page loads - Graceful extractor degradation with warnings - --quiet mode, timing info, score delta display - Node >= 20 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
414 lines
13 KiB
JavaScript
414 lines
13 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);
|
|
});
|
|
});
|
|
|
|
// ── 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);
|
|
});
|
|
});
|
|
|
|
// ── 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);
|
|
});
|
|
});
|