Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/design-system.mjs
1import fs from 'node:fs';2import path from 'node:path';34import { finding } from './findings.mjs';5import { GENERIC_FONTS } from './shared/constants.mjs';6import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';78const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];9const FALLBACK_DIRS = ['.agents/context', 'docs'];10const COLOR_CHANNEL_TOLERANCE = 6;11const RADIUS_TOLERANCE_PX = 0.5;1213const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;14const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;15const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;16const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;17const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;18const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;19const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);2021function firstExisting(dir, names) {22for (const name of names) {23const abs = path.join(dir, name);24if (fs.existsSync(abs)) return abs;25}26return null;27}2829function resolveDesignMdPath(cwd = process.cwd()) {30const root = firstExisting(cwd, DESIGN_NAMES);31if (root) return { path: root, contextDir: cwd };3233for (const rel of FALLBACK_DIRS) {34const dir = path.resolve(cwd, rel);35const found = firstExisting(dir, DESIGN_NAMES);36if (found) return { path: found, contextDir: dir };37}3839return null;40}4142function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {43const candidates = [44path.join(cwd, '.impeccable', 'design.json'),45path.join(cwd, 'DESIGN.json'),46path.join(contextDir, 'DESIGN.json'),47];48return candidates.find((candidate, index) =>49candidates.indexOf(candidate) === index && fs.existsSync(candidate)50) || null;51}5253function parseFrontmatter(md) {54const lines = String(md || '').split(/\r?\n/);55if (lines[0]?.trim() !== '---') return null;56let end = -1;57for (let i = 1; i < lines.length; i++) {58if (lines[i].trim() === '---') { end = i; break; }59}60if (end === -1) return null;61try {62return parseYamlSubset(lines.slice(1, end).join('\n'));63} catch {64return null;65}66}6768function parseYamlSubset(yaml) {69const root = {};70const stack = [{ indent: -1, obj: root }];7172for (const raw of String(yaml || '').split(/\r?\n/)) {73if (!raw.trim() || /^\s*#/.test(raw)) continue;74const indent = raw.match(/^\s*/)[0].length;75const content = raw.slice(indent);76const colonIdx = findTopLevelColon(content);77if (colonIdx === -1) continue;7879while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();8081const key = unquoteYamlKey(content.slice(0, colonIdx).trim());82const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());83const parent = stack[stack.length - 1].obj;8485if (rest === '') {86const obj = {};87parent[key] = obj;88stack.push({ indent, obj });89} else {90parent[key] = parseScalar(rest);91}92}9394return root;95}9697function findTopLevelColon(s) {98let inQuote = null;99for (let i = 0; i < s.length; i++) {100const ch = s[i];101if (inQuote) {102if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;103} else if (ch === '"' || ch === "'") {104inQuote = ch;105} else if (ch === ':') {106return i;107}108}109return -1;110}111112function unquoteYamlKey(key) {113if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {114return key.slice(1, -1);115}116return key;117}118119function stripInlineYamlComment(s) {120let inQuote = null;121for (let i = 0; i < s.length; i++) {122const ch = s[i];123if (inQuote) {124if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;125} else if (ch === '"' || ch === "'") {126inQuote = ch;127} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {128return s.slice(0, i).trimEnd();129}130}131return s;132}133134function parseScalar(raw) {135const s = raw.trim();136if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {137return s.slice(1, -1);138}139if (s === 'true') return true;140if (s === 'false') return false;141if (s === 'null' || s === '~') return null;142if (/^-?\d+$/.test(s)) return Number(s);143if (/^-?\d*\.\d+$/.test(s)) return Number(s);144return s;145}146147function safeReadJson(filePath) {148if (!filePath) return null;149try {150return JSON.parse(fs.readFileSync(filePath, 'utf-8'));151} catch {152return null;153}154}155156function normalizeFontName(value) {157return String(value || '')158.trim()159.replace(/\s*!important\s*$/i, '')160.trim()161.replace(/^["']|["']$/g, '')162.replace(/\+/g, ' ')163.replace(/\s+/g, ' ')164.toLowerCase();165}166167function splitFontStack(stack) {168return String(stack || '')169.replace(/\s*!important\s*$/i, '')170.split(',')171.map(normalizeFontName)172.filter(Boolean);173}174175function primaryFont(stack) {176if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';177return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';178}179180function isLiteralFontStack(stack) {181const text = String(stack || '');182return !/[$`{}]|\s\+\s|\|\|/.test(text);183}184185function cssColorLabel(raw) {186return String(raw || '').trim().replace(/\s+/g, ' ');187}188189function colorKey(color) {190if (!color) return '';191return `${color.r},${color.g},${color.b}`;192}193194function colorsClose(a, b) {195if (!a || !b) return false;196return Math.max(197Math.abs(a.r - b.r),198Math.abs(a.g - b.g),199Math.abs(a.b - b.b),200) <= COLOR_CHANNEL_TOLERANCE;201}202203function hslToRgb(H, S, L, alpha = 1) {204const h = (((H % 360) + 360) % 360) / 360;205const s = Math.max(0, Math.min(1, S));206const l = Math.max(0, Math.min(1, L));207const hue2rgb = (p, q, t) => {208if (t < 0) t += 1;209if (t > 1) t -= 1;210if (t < 1 / 6) return p + (q - p) * 6 * t;211if (t < 1 / 2) return q;212if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;213return p;214};215const q = l < 0.5 ? l * (1 + s) : l + s - l * s;216const p = 2 * l - q;217return {218r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),219g: Math.round(hue2rgb(p, q, h) * 255),220b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),221a: alpha,222};223}224225function parseDesignColor(value) {226const text = String(value || '').trim();227const parsed = parseAnyColor(text);228if (parsed) return parsed;229const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);230if (hsl) {231return hslToRgb(232parseFloat(hsl[1]),233parseFloat(hsl[2]) / 100,234parseFloat(hsl[3]) / 100,235hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,236);237}238return null;239}240241function addDesignColor(out, value, label) {242const parsed = parseDesignColor(value);243if (!parsed) return;244const key = colorKey(parsed);245if (!out.allowedColorKeys.has(key)) {246out.allowedColorKeys.set(key, { color: parsed, labels: [] });247}248out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));249}250251function addColorObject(out, colors, prefix = 'colors') {252if (!colors || typeof colors !== 'object') return;253for (const [name, value] of Object.entries(colors)) {254if (typeof value === 'string') {255addDesignColor(out, value, `${prefix}.${name}`);256}257}258}259260function addSidecarColors(out, sidecar) {261const colorMeta = sidecar?.extensions?.colorMeta;262if (!colorMeta || typeof colorMeta !== 'object') return;263264for (const [name, meta] of Object.entries(colorMeta)) {265if (!meta || typeof meta !== 'object') continue;266if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);267if (Array.isArray(meta.tonalRamp)) {268for (const [index, value] of meta.tonalRamp.entries()) {269if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);270}271}272}273}274275function addTypographyFonts(out, typography) {276if (!typography || typeof typography !== 'object') return;277for (const role of Object.values(typography)) {278if (!role || typeof role !== 'object') continue;279if (typeof role.fontFamily !== 'string') continue;280for (const font of splitFontStack(role.fontFamily)) {281if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);282}283}284}285286function addRoundedScale(out, rounded) {287if (!rounded || typeof rounded !== 'object') return;288for (const [rawName, value] of Object.entries(rounded)) {289const name = unquoteYamlKey(rawName).toLowerCase();290addRoundedToken(out, name, value);291}292}293294function addRoundedToken(out, name, value) {295if (typeof value !== 'string' && typeof value !== 'number') return;296const raw = String(value).trim();297if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;298const px = resolveLengthPx(raw, 16);299if (px == null || !Number.isFinite(px)) return;300out.allowedRadii.push({ name, value: raw, px });301if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;302}303304function addSidecarRadii(out, sidecar) {305const roundedMeta = sidecar?.extensions?.roundedMeta;306if (!roundedMeta || typeof roundedMeta !== 'object') return;307308for (const [rawName, meta] of Object.entries(roundedMeta)) {309const name = unquoteYamlKey(rawName).toLowerCase();310if (typeof meta === 'string' || typeof meta === 'number') {311addRoundedToken(out, `sidecar.${name}`, meta);312continue;313}314if (!meta || typeof meta !== 'object') continue;315for (const key of ['canonical', 'value']) {316if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {317addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);318}319}320for (const key of ['values', 'aliases']) {321if (!Array.isArray(meta[key])) continue;322for (const [index, value] of meta[key].entries()) {323addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);324}325}326if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {327out.hasPillRadius = true;328}329}330}331332function normalizeDesignSystem(input = {}) {333const frontmatter = input.frontmatter || {};334const sidecar = input.sidecar || null;335const out = {336present: true,337sourcePath: input.sourcePath || null,338sidecarPath: input.sidecarPath || null,339mdNewerThanJson: input.mdNewerThanJson === true,340allowedFonts: new Set(),341allowedColorKeys: new Map(),342allowedRadii: [],343hasPillRadius: false,344};345346addTypographyFonts(out, frontmatter.typography);347addColorObject(out, frontmatter.colors);348addSidecarColors(out, sidecar);349addRoundedScale(out, frontmatter.rounded);350addSidecarRadii(out, sidecar);351352out.hasFonts = out.allowedFonts.size > 0;353out.hasColors = out.allowedColorKeys.size > 0;354out.hasRadii = out.allowedRadii.length > 0;355return out;356}357358function loadDesignSystemForCwd(cwd = process.cwd()) {359const md = resolveDesignMdPath(cwd);360if (!md) return null;361362let frontmatter = null;363let mdStat = null;364try {365mdStat = fs.statSync(md.path);366frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));367} catch {368return null;369}370if (!frontmatter || typeof frontmatter !== 'object') return null;371372const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);373const sidecar = safeReadJson(sidecarPath);374let sidecarStat = null;375try {376if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);377} catch {378sidecarStat = null;379}380381return normalizeDesignSystem({382frontmatter,383sidecar,384sourcePath: md.path,385sidecarPath,386mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),387});388}389390function isAllowedFont(font, designSystem) {391if (!font || GENERIC_FONTS.has(font)) return true;392if (!designSystem?.hasFonts) return true;393return designSystem.allowedFonts.has(font);394}395396function isAllowedColorRaw(raw, designSystem) {397if (!designSystem?.hasColors) return true;398const text = String(raw || '').trim().toLowerCase();399if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;400if (text.includes('var(')) return true;401const parsed = parseDesignColor(text);402if (!parsed) return true;403if ((parsed.a ?? 1) <= 0.05) return true;404for (const entry of designSystem.allowedColorKeys.values()) {405if (colorsClose(parsed, entry.color)) return true;406}407return false;408}409410function isAllowedRadiusRaw(raw, designSystem) {411if (!designSystem?.hasRadii) return true;412const text = String(raw || '').trim().toLowerCase();413if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;414if (text.includes('var(') || text.includes('%')) return true;415const px = resolveLengthPx(text, 16);416if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;417if (designSystem.hasPillRadius && px >= 99) return true;418return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);419}420421function lineLooksCommented(line) {422const trimmed = String(line || '').trim();423return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');424}425426function isProbablyColorLiteral(line, match) {427const raw = match?.[0] || '';428const index = match.index ?? -1;429if (index < 0) return false;430if (isInsideCssAttributeSelector(line, index)) return false;431432const before = line.slice(0, index);433const after = line.slice(index + raw.length);434435if (raw.startsWith('#')) {436if (before.endsWith('&')) return false; // HTML numeric entity, e.g. ↔437438const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';439const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';440if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155441}442443const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);444const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);445const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);446447return styleContext || cssFunctionContext || jsColorKeyContext;448}449450function isInsideCssAttributeSelector(line, index) {451if (index < 0) return false;452const before = line.slice(0, index);453const lastOpen = before.lastIndexOf('[');454if (lastOpen === -1) return false;455const lastClose = before.lastIndexOf(']');456if (lastClose > lastOpen) return false;457const after = line.slice(index);458const close = after.indexOf(']');459const block = after.indexOf('{');460return close !== -1 && (block === -1 || close < block);461}462463function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {464return { ...finding(id, filePath, snippet, line), ...extras };465}466467function decodeGoogleFamily(value) {468const family = String(value || '').split(':')[0].replace(/\+/g, ' ');469try {470return decodeURIComponent(family);471} catch {472return family;473}474}475476function checkFontStack(stack, filePath, line, designSystem, context) {477const primary = primaryFont(stack);478if (!primary || isAllowedFont(primary, designSystem)) return [];479const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());480return [makeDesignFinding(481'design-system-font',482filePath,483`${context}: ${display} is not declared in DESIGN.md typography`,484line,485{ ignoreValue: display },486)];487}488489function extractRadiusTokens(value) {490return String(value || '')491.replace(/\s*\/\s*/g, ' ')492.split(/\s+/)493.map(token => token.trim())494.filter(Boolean);495}496497function checkRadiusValue(value, filePath, line, designSystem, context) {498const findings = [];499for (const token of extractRadiusTokens(value)) {500if (isAllowedRadiusRaw(token, designSystem)) continue;501findings.push(makeDesignFinding(502'design-system-radius',503filePath,504`${context}: ${token} is outside the DESIGN.md rounded scale`,505line,506{ ignoreValue: token },507));508}509return findings;510}511512function checkSourceDesignSystem(content, filePath, options = {}) {513const designSystem = options.designSystem;514if (!designSystem?.present) return [];515516const findings = [];517const lines = String(content || '').split('\n');518for (let i = 0; i < lines.length; i++) {519const line = lines[i];520const lineNum = i + 1;521if (lineLooksCommented(line)) continue;522523if (designSystem.hasFonts) {524for (const match of line.matchAll(FONT_DECL_RE)) {525findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));526}527for (const match of line.matchAll(FONT_JS_RE)) {528findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));529}530for (const match of line.matchAll(GOOGLE_FONT_RE)) {531const url = match[0];532for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {533const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));534if (!font || isAllowedFont(font, designSystem)) continue;535const display = decodeGoogleFamily(familyMatch[1]);536findings.push(makeDesignFinding(537'design-system-font',538filePath,539`Google Fonts: ${display} is not declared in DESIGN.md typography`,540lineNum,541{ ignoreValue: display },542));543}544}545}546547if (designSystem.hasColors) {548for (const match of line.matchAll(CSS_COLOR_RE)) {549if (!isProbablyColorLiteral(line, match)) continue;550const raw = cssColorLabel(match[0]);551if (isAllowedColorRaw(raw, designSystem)) continue;552findings.push(makeDesignFinding(553'design-system-color',554filePath,555`Undocumented color ${raw} is outside DESIGN.md colors`,556lineNum,557{ ignoreValue: raw },558));559}560}561562if (designSystem.hasRadii) {563for (const match of line.matchAll(BORDER_RADIUS_RE)) {564findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));565}566for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {567findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));568}569}570}571572return dedupeDesignFindings(findings);573}574575function hasDirectText(el) {576return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);577}578579function sampleText(el) {580const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();581return text ? ` "${text.slice(0, 40)}"` : '';582}583584function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {585if (!designSystem?.present) return [];586const findings = [];587const seenFonts = new Set();588const seenColors = new Set();589const seenRadii = new Set();590591for (const el of document.querySelectorAll('*')) {592if (shouldSkipStaticDesignElement(el, window)) continue;593const tag = el.tagName?.toLowerCase?.() || 'unknown';594const style = window.getComputedStyle(el);595596if (designSystem.hasFonts && hasDirectText(el)) {597const font = primaryFont(style.fontFamily || '');598if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {599seenFonts.add(font);600findings.push(makeDesignFinding(601'design-system-font',602filePath,603`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,6040,605{ ignoreValue: font },606));607}608}609610if (designSystem.hasColors) {611const colorChecks = [];612if (hasDirectText(el)) colorChecks.push(['text color', style.color]);613if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);614for (const side of ['Top', 'Right', 'Bottom', 'Left']) {615if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {616colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);617}618}619if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);620621for (const [kind, raw] of colorChecks) {622const label = cssColorLabel(raw);623if (isAllowedColorRaw(label, designSystem)) continue;624const key = `${kind}:${label}`;625if (seenColors.has(key)) continue;626seenColors.add(key);627findings.push(makeDesignFinding(628'design-system-color',629filePath,630`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,6310,632{ ignoreValue: label },633));634}635}636637if (designSystem.hasRadii) {638const rawRadius = String(style.borderRadius || '').trim();639if (!rawRadius) continue;640for (const token of extractRadiusTokens(rawRadius)) {641if (isAllowedRadiusRaw(token, designSystem)) continue;642if (seenRadii.has(token)) continue;643seenRadii.add(token);644findings.push(makeDesignFinding(645'design-system-radius',646filePath,647`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,6480,649{ ignoreValue: token },650));651}652}653}654655return findings;656}657658function shouldSkipStaticDesignElement(el, window) {659const tag = el.tagName?.toLowerCase?.() || '';660if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;661662let current = el;663while (current) {664if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;665const style = window.getComputedStyle(current);666const display = String(style.display || '').toLowerCase();667const visibility = String(style.visibility || '').toLowerCase();668if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;669current = current.parentElement;670}671return false;672}673674function isTransparentCss(value) {675const text = String(value || '').trim().toLowerCase();676if (!text || text === 'transparent') return true;677const parsed = parseDesignColor(text);678return parsed ? (parsed.a ?? 1) <= 0.05 : false;679}680681function canonicalDesignFindingKey(item) {682if (!item?.antipattern?.startsWith?.('design-system-')) return null;683const value = item.ignoreValue || item.value || '';684if (item.antipattern === 'design-system-font') {685const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';686const font = normalizeFontName(value);687return font ? `${item.antipattern}:${context}:${font}` : null;688}689if (item.antipattern === 'design-system-color') {690const parsed = parseDesignColor(value);691if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;692const label = cssColorLabel(value).toLowerCase();693return label ? `${item.antipattern}:color:${label}` : null;694}695if (item.antipattern === 'design-system-radius') {696const px = resolveLengthPx(String(value || '').trim(), 16);697if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;698const label = String(value || '').trim().toLowerCase();699return label ? `${item.antipattern}:radius:${label}` : null;700}701return null;702}703704function mergeDesignSystemFindings(...groups) {705const out = [];706const seen = new Map();707for (const group of groups) {708for (const item of group || []) {709const key = canonicalDesignFindingKey(item);710if (key) {711if (seen.has(key)) {712const existing = out[seen.get(key)];713if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;714continue;715}716seen.set(key, out.length);717}718out.push(item);719}720}721return out;722}723724function dedupeDesignFindings(findings) {725const out = [];726const seen = new Set();727for (const item of findings) {728const key = [729item.antipattern,730item.line || 0,731normalizeFontName(item.ignoreValue || item.snippet || ''),732].join('\0');733if (seen.has(key)) continue;734seen.add(key);735out.push(item);736}737return out;738}739740export {741parseFrontmatter,742normalizeDesignSystem,743loadDesignSystemForCwd,744isAllowedFont,745isAllowedColorRaw,746isAllowedRadiusRaw,747checkSourceDesignSystem,748collectStaticDesignSystemFindings,749mergeDesignSystemFindings,750};751