diff --git a/packages/cli/src/linter/model/color-parser.ts b/packages/cli/src/linter/model/color-parser.ts index f7127e15..78167cb9 100644 --- a/packages/cli/src/linter/model/color-parser.ts +++ b/packages/cli/src/linter/model/color-parser.ts @@ -306,10 +306,10 @@ function parseHue(s: string): number { let val = 0; if (lower.endsWith('deg')) { val = parseFloat(lower.slice(0, -3)); - } else if (lower.endsWith('rad')) { - val = (parseFloat(lower.slice(0, -3)) * 180) / Math.PI; } else if (lower.endsWith('grad')) { val = (parseFloat(lower.slice(0, -4)) * 360) / 400; + } else if (lower.endsWith('rad')) { + val = (parseFloat(lower.slice(0, -3)) * 180) / Math.PI; } else if (lower.endsWith('turn')) { val = parseFloat(lower.slice(0, -4)) * 360; } else { @@ -437,19 +437,17 @@ function parseColorWithWeight(subArg: string): { colorStr: string; weight: numbe return { colorStr: parts[0]!, weight: null }; } - // Identify which item is the percentage + // CSS color-mix weights are percentages only; a bare number is not a valid weight. const p0 = parts[0]!; const p1 = parts[1]!; - const isP0Weight = p0.endsWith('%') || (!isNaN(Number(p0)) && p0 !== ''); - const isP1Weight = p1.endsWith('%') || (!isNaN(Number(p1)) && p1 !== ''); + const isP0Weight = p0.endsWith('%'); + const isP1Weight = p1.endsWith('%'); if (isP0Weight && !isP1Weight) { - const w = p0.endsWith('%') ? parseFloat(p0.slice(0, -1)) : parseFloat(p0) * 100; - return { colorStr: p1, weight: w }; + return { colorStr: p1, weight: parseFloat(p0.slice(0, -1)) }; } else if (isP1Weight && !isP0Weight) { - const w = p1.endsWith('%') ? parseFloat(p1.slice(0, -1)) : parseFloat(p1) * 100; - return { colorStr: p0, weight: w }; + return { colorStr: p0, weight: parseFloat(p1.slice(0, -1)) }; } return null; diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index 191e31ae..15a49fc6 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -218,6 +218,25 @@ describe('ModelHandler', () => { expect(mix2?.g).toBe(128); expect(mix2?.b).toBe(128); }); + + it('parses grad hue units correctly (100grad === 90deg)', () => { + const result = handler.execute(makeParsed({ + colors: { grad: 'hsl(100grad 100% 50%)', deg: 'hsl(90deg 100% 50%)' }, + })); + expect(result.findings.length).toBe(0); + const grad = result.designSystem.colors.get('grad'); + expect(grad?.hex).toBe('#80ff00'); + expect(grad?.hex).toBe(result.designSystem.colors.get('deg')?.hex); + }); + + it('rejects color-mix with bare-number (non-percentage) weights', () => { + const result = handler.execute(makeParsed({ + colors: { bad: 'color-mix(in srgb, red 20, blue)' }, + })); + // CSS color-mix weights are percentages only; a bare number is invalid. + expect(result.designSystem.colors.has('bad')).toBe(false); + expect(result.findings.some(f => f.path === 'colors.bad' && f.severity === 'error')).toBe(true); + }); }); // ── Cycle 10: Resolve single-level token reference ────────────────