Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/design-tokens-to-design-md.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/core": patch
"@open-codesign/shared": patch
---

Prepare the design-token import. Core exports `mapTokensToDesignFields`, which turns scanned tokens into DESIGN.md colors, typography, rounded and spacing fields, with font sizes, line heights and shadows as Markdown sections. It converts `rgb()`, `hsl()` and `oklch()` colors to sRGB hex (a clamped out-of-gamut color is reported with its original value), replaces `var()` with the scanned token it names, leaves other `[data-theme]` values out, and returns what it could not map instead of logging it. Shared exports `serializeDesignMd`. Tailwind v4 `--font-*` variables are now read as font families, and a CSS variable declared in a `[data-theme]` block records that block.
208 changes: 208 additions & 0 deletions packages/core/src/brand/color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import { cssColorToHex } from './color.js';

const ok = (hex: string, clamped = false) => ({ ok: true, hex, clamped });

describe('cssColorToHex()', () => {
describe('hex', () => {
it.each([
['#fff', '#ffffff'],
['#ABC', '#aabbcc'],
['#FFAA00', '#ffaa00'],
['#f00f', '#ff0000'],
['#ff0000ff', '#ff0000'],
])('%s -> %s', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex));
});

it.each(['#f008', '#ff000080'])('reports alpha for %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'alpha' });
});

it.each(['#ggg', '#12345', '#', 'fff'])('reports the format of %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' });
});
});

describe('rgb()', () => {
it.each([
['rgb(255, 0, 0)', '#ff0000'],
['rgb(255 128 0)', '#ff8000'],
['rgba(255, 0, 0, 1)', '#ff0000'],
['rgb(255 0 0 / 100%)', '#ff0000'],
['rgb(100% 50% 0%)', '#ff8000'],
['RGB(0,0,0)', '#000000'],
])('%s -> %s', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex));
});

it.each([
'rgb(255 0 0 / 50%)',
'rgba(0, 0, 0, .5)',
'rgb(0 0 0 / 0)',
])('reports alpha for %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'alpha' });
});

it('clamps a channel outside 0-255 and says so', () => {
expect(cssColorToHex('rgb(300 -4 0)')).toEqual(ok('#ff0000', true));
});

it.each([
'rgb(1 2)',
'rgb(1 2 3 4 5)',
'rgb(1 2 3 4)',
'rgb(1, 2, 3, 4, 5)',
'rgb(1 2 3 / 0.5 / 0.2)',
'rgb()',
'rgb(1deg 2 3)',
'rgb(red)',
])('reports the format of %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' });
});
});

describe('hsl()', () => {
it.each([
['hsl(0 100% 50%)', '#ff0000'],
['hsl(120, 100%, 25%)', '#008000'],
['hsl(210 50% 40%)', '#336699'],
['hsl(30deg 80% 60%)', '#eb9947'],
['hsl(-120 100% 50%)', '#0000ff'],
['hsl(0.5turn 100% 50%)', '#00ffff'],
['hsl(200grad 100% 50%)', '#00ffff'],
['hsla(0, 0%, 100%, 1)', '#ffffff'],
['hsl(359 100% 50%)', '#ff0004'],
])('%s -> %s', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex));
});

it('reports alpha', () => {
expect(cssColorToHex('hsl(0 100% 50% / 0.5)')).toEqual({ ok: false, reason: 'alpha' });
});

it.each([
'hsl(0 100 50)',
'hsl(0 100%)',
'hsl(red 100% 50%)',
])('reports the format of %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' });
});
});

// Expected values come from a separate implementation: the CSS Color 4 reference route
// (OKLab -> LMS -> XYZ D65 -> linear sRGB), not the matrices used in color.ts. The Tailwind
// v4 entries match the hex values Tailwind documents for those palette steps.
describe('oklch()', () => {
it.each([
['oklch(1 0 0)', '#ffffff'],
['oklch(0 0 0)', '#000000'],
['oklch(0.5 0 0)', '#636363'],
['oklch(0.627955 0.257683 29.2339)', '#ff0000'],
['oklch(0.452014 0.313214 264.052)', '#0000ff'],
['oklch(0.86644 0.294827 142.495)', '#00ff00'],
['oklch(0.7 0.15 250)', '#4ba3f7'],
['oklch(0.5 0.1 0)', '#904961'],
['oklch(0.75 0.12 120)', '#a4b95e'],
['oklch(0.637 0.237 25.331)', '#fb2c36'],
['oklch(0.551 0.027 264.364)', '#6a7282'],
['oklch(0.97 0.014 254.604)', '#eff6ff'],
])('in gamut: %s -> %s', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex));
});

it.each([
['oklch(0.7 0.4 150)', '#00d600'],
['oklch(0.9 0.35 200)', '#00ffff'],
['oklch(0.3 0.3 30)', '#8f0000'],
])('out of gamut: %s is clamped to %s and says so', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex, true));
});

// Only a clamp that changes a channel by a full step counts: the hex is the same as the
// rounded unclamped color, so a channel 0.06 steps outside sRGB is not reported.
it.each([
['oklch(0.627955 0.2577 29.2339)', false],
['oklch(0.627955 0.258 29.2339)', true],
['oklch(1.001 0 0)', false],
['oklch(1.002 0 0)', true],
['oklch(1.01 0 0)', true],
])('reports %s as clamped: %s', (input, clamped) => {
const result = cssColorToHex(input);
expect(result).toMatchObject({ ok: true, clamped });
expect(result).toHaveProperty('hex', input.startsWith('oklch(0.6') ? '#ff0000' : '#ffffff');
});

it('treats an achromatic color as a gray of the same lightness', () => {
expect(cssColorToHex('oklch(0.5 0 0)')).toEqual(cssColorToHex('oklch(0.5 0 217)'));
expect(cssColorToHex('oklch(0.5 none none)')).toEqual(ok('#636363'));
});

it.each([
['oklch(50% 0 0)', '#636363'],
['OKLCH(0.5 0% 0deg)', '#636363'],
['oklch(0.75 30% 120)', '#a4b95e'],
['oklch(0.75 0.12 0.3333333333turn)', '#a4b95e'],
['oklch(0.75 0.12 133.33333grad)', '#a4b95e'],
['oklch(0.75 0.12 2.0943951rad)', '#a4b95e'],
['oklch(0.5 0.1 0 / 1)', '#904961'],
['oklch(0.5 0.1 0 / 100%)', '#904961'],
])('accepts the notation %s', (input, hex) => {
expect(cssColorToHex(input)).toEqual(ok(hex));
});

it('reports alpha', () => {
expect(cssColorToHex('oklch(0.5 0.1 0 / 50%)')).toEqual({ ok: false, reason: 'alpha' });
});

it.each([
'oklch(0.5 0.1)',
'oklch(0.5 0.1 0 0)',
'oklch(0.5, 0.1, 0)',
'oklch(from red l c h)',
'oklch(0.5 0.1 0px)',
'oklch(0.5deg 0.1 0)',
])('reports the format of %s', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' });
});
});

it.each([
'red',
'transparent',
'currentColor',
'var(--brand)',
'lab(50 20 20)',
'color-mix(in srgb, red, blue)',
'',
])('reports the format of %j', (input) => {
expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' });
});
// The input is CSS from a folder the user picked, which can be someone else's repository.
describe('long input', () => {
const LONG = 100_000;
const fast = (value: string) => {
const start = performance.now();
const result = cssColorToHex(value);
expect(performance.now() - start).toBeLessThan(250);
return result;
};

it.each([
['whitespace inside the parentheses', `rgb(${'\t'.repeat(LONG)}(`],
['whitespace before the closing parenthesis', `rgb(1${' '.repeat(LONG)}x)`],
['digits before a bad suffix', `rgb(${'1'.repeat(LONG)}x 0 0)`],
['an exponent without digits', `rgb(1${'e'.repeat(LONG)} 0 0)`],
])('is rejected in linear time: %s', (_name, input) => {
expect(fast(input)).toEqual({ ok: false, reason: 'format' });
});

it('still converts a long run of whitespace between valid parts', () => {
expect(
fast(
`rgb(${' '.repeat(LONG)}255${' '.repeat(LONG)}0${' '.repeat(LONG)}0${' '.repeat(LONG)})`,
),
).toEqual(ok('#ff0000'));
});
});
});
181 changes: 181 additions & 0 deletions packages/core/src/brand/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Converts CSS color values to the sRGB hex that DESIGN.md colors require.
*
* Handles hex, `rgb()`/`rgba()`, `hsl()`/`hsla()` and `oklch()`. A color with an alpha
* below 1 or in another format is reported, never approximated. `oklch()` goes
* OKLab → linear sRGB → gamma; a channel outside sRGB is clamped to [0, 1] and the
* result says so, so the caller can show what changed.
*/

export type ColorConversion =
| {
ok: true;
/** Lowercase `#rrggbb`. */
hex: string;
/** True when a channel was outside sRGB and was clamped, so the hex is not the exact color. */
clamped: boolean;
}
| { ok: false; reason: 'alpha' | 'format' };

const FORMAT: ColorConversion = { ok: false, reason: 'format' };
const NUMBER = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?';
const COMPONENT_RE = new RegExp(`^(${NUMBER})(%|deg|grad|rad|turn)?$`, 'i');
// The body is matched greedily and split later, so whitespace has one way to match.
const FUNCTION_RE = /^(rgba?|hsla?|oklch)\(([^()]*)\)$/i;
const HEX_RE = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;

interface Component {
value: number;
unit: string;
}

const isNumber = (value: number | null | undefined): value is number => typeof value === 'number';

// `none` is the missing-component keyword; it converts as 0.
function parseComponent(raw: string): Component | null {
if (raw.toLowerCase() === 'none') return { value: 0, unit: '' };
const match = COMPONENT_RE.exec(raw);
if (match === null) return null;
return { value: Number(match[1]), unit: (match[2] ?? '').toLowerCase() };
}

function hueDegrees({ value, unit }: Component): number | null {
if (unit === '' || unit === 'deg') return value;
if (unit === 'grad') return value * 0.9;
if (unit === 'rad') return (value * 180) / Math.PI;
if (unit === 'turn') return value * 360;
return null;
}

function alphaValue({ value, unit }: Component): number | null {
if (unit === '') return value;
return unit === '%' ? value / 100 : null;
}

interface ColorArguments {
channels: string[];
alpha?: string;
/** The legacy comma syntax, where a fourth value is the alpha; `oklch()` has no such syntax. */
comma: boolean;
}

/** Splits `a b c / d` and the legacy `a, b, c, d` into the channels and the optional alpha. */
function splitArguments(body: string): ColorArguments | null {
if (body.includes(',')) {
const parts = body.split(',').map((part) => part.trim());
const alpha = parts.length === 4 ? parts[3] : undefined;
const channels = alpha === undefined ? parts : parts.slice(0, 3);
return alpha === undefined ? { channels, comma: true } : { channels, alpha, comma: true };
}
const sections = body.split('/').map((part) => part.trim());
if (sections.length > 2) return null;
const [channelText = '', alpha] = sections;
const channels = channelText.split(/\s+/).filter((part) => part !== '');
return alpha === undefined ? { channels, comma: false } : { channels, alpha, comma: false };
}

function hex(channels: [number, number, number]): ColorConversion {
const clamped = channels.some((channel) => channel < 0 || channel > 255);
const digits = channels
.map((channel) => Math.max(0, Math.min(255, channel)).toString(16).padStart(2, '0'))
.join('');
return { ok: true, hex: `#${digits}`, clamped };
}

const eightBit = (unit: number): number => Math.round(unit * 255);

function fromHex(digits: string): ColorConversion {
const full = digits.length <= 4 ? [...digits].map((digit) => digit + digit).join('') : digits;
if (full.length === 8 && Number.parseInt(full.slice(6), 16) < 255) {
return { ok: false, reason: 'alpha' };
}
return { ok: true, hex: `#${full.slice(0, 6).toLowerCase()}`, clamped: false };
}

function srgbChannel({ value, unit }: Component): number | null {
if (unit === '%') return eightBit(value / 100);
return unit === '' ? Math.round(value) : null;
}

function fromRgb(components: Component[]): ColorConversion {
const [r, g, b] = components.map(srgbChannel);
if (components.length !== 3 || !isNumber(r) || !isNumber(g) || !isNumber(b)) return FORMAT;
return hex([r, g, b]);
}

function fromHsl(components: Component[]): ColorConversion {
const [h, s, l] = components;
if (components.length !== 3 || h === undefined || s?.unit !== '%' || l?.unit !== '%') {
return FORMAT;
}
const degrees = hueDegrees(h);
if (degrees === null) return FORMAT;
const saturation = Math.max(0, Math.min(1, s.value / 100));
const lightness = Math.max(0, Math.min(1, l.value / 100));
const spread = saturation * Math.min(lightness, 1 - lightness);
const channel = (offset: number): number => {
const k = (((offset + degrees / 30) % 12) + 12) % 12;
return lightness - spread * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return hex([eightBit(channel(0)), eightBit(channel(8)), eightBit(channel(4))]);
}

function gamma(linear: number): number {
const magnitude = Math.abs(linear);
const encoded =
magnitude <= 0.0031308 ? 12.92 * magnitude : 1.055 * magnitude ** (1 / 2.4) - 0.055;
return Math.sign(linear) * encoded;
}

function fromOklch(components: Component[]): ColorConversion {
const [lightness, chroma, hue] = components;
if (
components.length !== 3 ||
lightness === undefined ||
chroma === undefined ||
hue === undefined
) {
return FORMAT;
}
const degrees = hueDegrees(hue);
const percentOrNumber = (c: Component) => c.unit === '' || c.unit === '%';
if (degrees === null || !percentOrNumber(lightness) || !percentOrNumber(chroma)) return FORMAT;
const L = lightness.unit === '%' ? lightness.value / 100 : lightness.value;
const C = chroma.unit === '%' ? (chroma.value / 100) * 0.4 : chroma.value;
const a = C * Math.cos((degrees * Math.PI) / 180);
const b = C * Math.sin((degrees * Math.PI) / 180);
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return hex([
eightBit(gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)),
eightBit(gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)),
eightBit(gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)),
]);
}

export function cssColorToHex(value: string): ColorConversion {
const text = value.trim();
const hexMatch = HEX_RE.exec(text);
if (hexMatch !== null) return fromHex(hexMatch[1] ?? '');

const fn = FUNCTION_RE.exec(text);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (fn === null) return FORMAT;
const name = (fn[1] ?? '').toLowerCase();
const args = splitArguments(fn[2] ?? '');
if (args === null || (args.comma && name === 'oklch')) return FORMAT;
const { channels, alpha } = args;
const components = channels.map(parseComponent).filter((c): c is Component => c !== null);
if (components.length !== channels.length) return FORMAT;

if (alpha !== undefined) {
const parsed = parseComponent(alpha);
const opacity = parsed === null ? null : alphaValue(parsed);
if (opacity === null) return FORMAT;
if (opacity < 1) return { ok: false, reason: 'alpha' };
}

if (name.startsWith('rgb')) return fromRgb(components);
if (name.startsWith('hsl')) return fromHsl(components);
return fromOklch(components);
}
Loading
Loading