Skip to content

Commit 1614035

Browse files
Manavarya09claude
andcommitted
feat(v10.4): icon system + background patterns + stack intel
- icon-system.js fingerprints the icon library from stroke/fill dominance, stroke width, grid size, and rounded-caps presence. Recognises Lucide, Heroicons (outline+solid), Phosphor, Tabler, Feather, Remix, Material. - background-patterns.js classifies noise/dot-grid/line-grid/ gradient-mesh/svg-pattern/plain from background-image values. - stack-intel.js extends stack-fingerprint with 12 CMS, 13 analytics, and 7 experimentation platform detectors. - Bin now reads its version from package.json — no more drift. - New outputs: *-icon-system.json, *-stack-intel.json. - background-patterns merged into *-visual-dna.json. - 313/313 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33e5cd1 commit 1614035

8 files changed

Lines changed: 359 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [10.4.0] — 2026-04-22
4+
5+
**Identification trio: icon system, background patterns, stack intel.**
6+
7+
### Added
8+
9+
- **`src/extractors/icon-system.js`** — fingerprints the icon library (Lucide / Heroicons outline+solid / Phosphor / Tabler / Feather / Remix / Material) from stroke vs fill dominance, stroke width, grid size, and rounded-caps presence. Emits per-icon hints agents can act on.
10+
- **`src/extractors/background-patterns.js`** — classifies noise / dot-grid / line-grid / gradient-mesh / svg-pattern / plain from computed `background-image` values. Merged into `*-visual-dna.json`.
11+
- **`src/extractors/stack-intel.js`** — extends the existing stack-fingerprint with 12 CMSs (Webflow, Framer, Shopify, Ghost, Sanity, Contentful, Wix, Squarespace, WordPress, Hashnode, Notion, Bubble), 13 analytics platforms, and 7 experimentation platforms.
12+
- Bin reads its own version from `package.json` — no more per-release version drift in the CLI.
13+
- New outputs: `*-icon-system.json`, `*-stack-intel.json`.
14+
315
## [10.3.0] — 2026-04-22
416

517
**Perf + SEO.** designlang now doubles as a lightweight auditor.

bin/design-extract.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
#!/usr/bin/env node
22

33
import { Command } from 'commander';
4-
import { mkdirSync, writeFileSync } from 'fs';
5-
import { resolve, join } from 'path';
4+
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
5+
import { resolve, join, dirname } from 'path';
6+
import { fileURLToPath } from 'url';
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const PKG_VERSION = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')).version;
610
import chalk from 'chalk';
711
import ora from 'ora';
812
import { extractDesignLanguage } from '../src/index.js';
@@ -56,7 +60,7 @@ const program = new Command();
5660
program
5761
.name('designlang')
5862
.description('Extract the complete design language from any website')
59-
.version('10.3.0');
63+
.version(PKG_VERSION);
6064

6165
// ── Main command: extract ──────────────────────────────────────
6266
program
@@ -343,7 +347,7 @@ program
343347

344348
// v10: page intent + section roles + visual DNA + component library + multi-page + prompt pack.
345349
files.push({ name: `${prefix}-intent.json`, content: JSON.stringify({ pageIntent: design.pageIntent, sectionRoles: design.sectionRoles }, null, 2), label: 'Page Intent + Section Roles' });
346-
files.push({ name: `${prefix}-visual-dna.json`, content: JSON.stringify({ materialLanguage: design.materialLanguage, imageryStyle: design.imageryStyle }, null, 2), label: 'Visual DNA' });
350+
files.push({ name: `${prefix}-visual-dna.json`, content: JSON.stringify({ materialLanguage: design.materialLanguage, imageryStyle: design.imageryStyle, backgroundPatterns: design.backgroundPatterns }, null, 2), label: 'Visual DNA' });
347351
files.push({ name: `${prefix}-library.json`, content: JSON.stringify(design.componentLibrary || {}, null, 2), label: 'Component Library Detection' });
348352
if (design.logo && design.logo.found) {
349353
files.push({ name: `${prefix}-logo.json`, content: JSON.stringify(design.logo, null, 2), label: 'Logo Metadata' });
@@ -366,6 +370,12 @@ program
366370
if (design.perf && !design.perf.error) {
367371
files.push({ name: `${prefix}-perf.json`, content: JSON.stringify(design.perf, null, 2), label: 'Perf + Bundle' });
368372
}
373+
if (design.iconSystem && (design.iconSystem.icons || []).length) {
374+
files.push({ name: `${prefix}-icon-system.json`, content: JSON.stringify(design.iconSystem, null, 2), label: 'Icon System' });
375+
}
376+
if (design.stackIntel) {
377+
files.push({ name: `${prefix}-stack-intel.json`, content: JSON.stringify(design.stackIntel, null, 2), label: 'Stack Intel (CMS/analytics/experimentation)' });
378+
}
369379
if (merged.prompts !== false) {
370380
const pack = buildPromptPack(design);
371381
const promptsDir = join(outDir, `${prefix}-prompts`);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "designlang",
3-
"version": "10.3.0",
3+
"version": "10.4.0",
44
"description": "Extract the complete design language from any website — colors, typography, spacing, shadows, motion, component anatomy, brand voice, page intent, section roles, material language, component library, imagery style, and logo. Outputs AI-optimized markdown, W3C design tokens, motion tokens, typed component stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
55
"type": "module",
66
"bin": {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// v10.4 — Background Patterns
2+
//
3+
// Classifies the visual backgrounds on a site from computed-style evidence:
4+
// noise (repeated grain PNG/SVG), dot-grid, line-grid, gradient-mesh (multiple
5+
// radial gradients), chequer, diagonal stripes, SVG patterns, or plain.
6+
//
7+
// Pure function — reads `rawData.light.computedStyles`, which every extractor
8+
// already has access to, plus the `modernColors` and any collected svgs.
9+
10+
function looksLikeDotGrid(image) {
11+
return /radial-gradient\(.*\)/i.test(image) && /repeat/i.test(image) && /(\d+px\s*\d+px)/.test(image);
12+
}
13+
14+
function looksLikeLineGrid(image) {
15+
// repeating-linear-gradient with a narrow colored band.
16+
return /repeating-linear-gradient/i.test(image);
17+
}
18+
19+
function looksLikeNoise(image) {
20+
// data URI SVG with feTurbulence filter, or a well-known noise png path.
21+
return /feTurbulence|data:image\/svg.+fractalNoise/i.test(image) || /noise\.(png|svg|webp)/i.test(image);
22+
}
23+
24+
function countRadialGradients(image) {
25+
return (image.match(/radial-gradient\(/gi) || []).length;
26+
}
27+
28+
function countLinearGradients(image) {
29+
return (image.match(/linear-gradient\(/gi) || []).length;
30+
}
31+
32+
function detectSvgPattern(image) {
33+
return /url\("data:image\/svg/i.test(image) && !looksLikeNoise(image);
34+
}
35+
36+
export function extractBackgroundPatterns(rawData = {}) {
37+
const styles = (rawData.light?.computedStyles) || [];
38+
let dotGrid = 0, lineGrid = 0, noise = 0, svgPattern = 0, radialSum = 0, linearSum = 0, meshCount = 0, plain = 0;
39+
const samples = [];
40+
41+
for (const s of styles) {
42+
const bg = s.backgroundImage || s['background-image'] || '';
43+
if (!bg || bg === 'none') { plain++; continue; }
44+
const radial = countRadialGradients(bg);
45+
const linear = countLinearGradients(bg);
46+
radialSum += radial;
47+
linearSum += linear;
48+
let tag = null;
49+
if (looksLikeNoise(bg)) { noise++; tag = 'noise'; }
50+
else if (looksLikeDotGrid(bg)) { dotGrid++; tag = 'dot-grid'; }
51+
else if (looksLikeLineGrid(bg)) { lineGrid++; tag = 'line-grid'; }
52+
else if (radial >= 2) { meshCount++; tag = 'gradient-mesh'; }
53+
else if (detectSvgPattern(bg)) { svgPattern++; tag = 'svg-pattern'; }
54+
if (tag && samples.length < 8) samples.push({ tag, value: bg.slice(0, 200) });
55+
}
56+
57+
const total = styles.length || 1;
58+
const labels = [];
59+
if (noise / total > 0.002) labels.push('noise');
60+
if (dotGrid / total > 0.002) labels.push('dot-grid');
61+
if (lineGrid / total > 0.002) labels.push('line-grid');
62+
if (meshCount > 0) labels.push('gradient-mesh');
63+
if (svgPattern > 0) labels.push('svg-pattern');
64+
if (!labels.length) labels.push('plain');
65+
66+
return {
67+
labels,
68+
counts: { noise, dotGrid, lineGrid, meshCount, svgPattern },
69+
gradientTotals: { radial: radialSum, linear: linearSum },
70+
samples,
71+
};
72+
}

src/extractors/icon-system.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// v10.4 — Icon System fingerprint
2+
//
3+
// Pure extractor — operates on the icon payload the crawler already collects.
4+
// We can't reliably match against Lucide/Phosphor/Heroicons path-data without
5+
// shipping the full libraries, so this extractor does the next-best thing:
6+
// infers the *system* an icon set came from (stroke vs fill, stroke width,
7+
// corner style, grid size, viewBox convention) and emits guidance any LLM can
8+
// act on ("use Lucide @ 1.5 stroke, 24px grid").
9+
10+
const LIBRARY_HINTS = [
11+
{ id: 'lucide', match: (ctx) => ctx.strokeDominant && ctx.avgWeight > 1.3 && ctx.avgWeight < 1.7 && ctx.grid24 && !ctx.roundedCaps, score: 0.8 },
12+
{ id: 'heroicons-outline', match: (ctx) => ctx.strokeDominant && ctx.avgWeight >= 1.8 && ctx.avgWeight <= 2.2 && ctx.grid24, score: 0.8 },
13+
{ id: 'heroicons-solid', match: (ctx) => ctx.fillDominant && ctx.grid24, score: 0.55 },
14+
{ id: 'phosphor', match: (ctx) => ctx.strokeDominant && ctx.roundedCaps && ctx.grid24, score: 0.7 },
15+
{ id: 'tabler', match: (ctx) => ctx.strokeDominant && ctx.avgWeight > 1.9 && ctx.grid24, score: 0.6 },
16+
{ id: 'feather', match: (ctx) => ctx.strokeDominant && ctx.avgWeight > 1.8 && ctx.roundedCaps && ctx.grid24, score: 0.7 },
17+
{ id: 'remix', match: (ctx) => ctx.mixedFillStroke && ctx.grid24, score: 0.45 },
18+
{ id: 'material', match: (ctx) => ctx.fillDominant && ctx.grid24, score: 0.4 },
19+
];
20+
21+
function parseStroke(v) {
22+
if (!v) return 0;
23+
const n = parseFloat(v);
24+
return Number.isFinite(n) ? n : 0;
25+
}
26+
27+
function viewBoxGrid(vb) {
28+
if (!vb) return null;
29+
const parts = vb.trim().split(/\s+/).map(Number);
30+
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null;
31+
const w = parts[2], h = parts[3];
32+
if (w === h && [16, 20, 24, 32, 48, 64].includes(w)) return w;
33+
return null;
34+
}
35+
36+
function detectRoundedCaps(svg) {
37+
// Look for `stroke-linecap="round"` or `stroke-linejoin="round"` as a
38+
// proxy for Phosphor/Feather-style rounded terminals.
39+
return /stroke-linecap="round"|stroke-linejoin="round"/i.test(svg || '');
40+
}
41+
42+
export function extractIconSystem(icons = []) {
43+
if (!icons.length) {
44+
return { library: 'unknown', confidence: 0, stats: {}, signals: [], icons: [] };
45+
}
46+
47+
let strokeCount = 0, fillCount = 0, mixed = 0, weights = [], gridHits = {};
48+
let rounded = 0;
49+
const perIconHints = [];
50+
51+
for (const icon of icons) {
52+
const svg = icon.svg || '';
53+
const stroke = icon.stroke || (svg.match(/stroke="([^"]+)"/i) || [])[1];
54+
const fill = icon.fill || (svg.match(/fill="([^"]+)"/i) || [])[1];
55+
const strokeWidthMatch = svg.match(/stroke-width="([0-9.]+)"/i);
56+
const sw = strokeWidthMatch ? parseStroke(strokeWidthMatch[1]) : 0;
57+
58+
const hasStroke = !!(stroke && stroke !== 'none');
59+
const hasFill = !!(fill && fill !== 'none');
60+
if (hasStroke && !hasFill) strokeCount++;
61+
else if (hasFill && !hasStroke) fillCount++;
62+
else if (hasStroke && hasFill) mixed++;
63+
if (sw > 0) weights.push(sw);
64+
const grid = viewBoxGrid(icon.viewBox);
65+
if (grid) gridHits[grid] = (gridHits[grid] || 0) + 1;
66+
if (detectRoundedCaps(svg)) rounded++;
67+
68+
perIconHints.push({
69+
class: (icon.classList || '').slice(0, 80),
70+
grid,
71+
strokeWidth: sw || null,
72+
style: hasStroke && !hasFill ? 'stroke' : hasFill && !hasStroke ? 'fill' : 'mixed',
73+
});
74+
}
75+
76+
const avgWeight = weights.length ? weights.reduce((a, b) => a + b, 0) / weights.length : 0;
77+
const total = icons.length;
78+
const ctx = {
79+
strokeDominant: strokeCount / total > 0.55,
80+
fillDominant: fillCount / total > 0.55,
81+
mixedFillStroke: mixed / total > 0.3,
82+
avgWeight,
83+
roundedCaps: rounded / total > 0.4,
84+
grid24: gridHits[24] ? gridHits[24] / total > 0.5 : false,
85+
};
86+
87+
const scored = LIBRARY_HINTS
88+
.map(lib => ({ id: lib.id, score: lib.match(ctx) ? lib.score : 0 }))
89+
.filter(x => x.score > 0)
90+
.sort((a, b) => b.score - a.score);
91+
92+
const primary = scored[0] || { id: 'unknown', score: 0 };
93+
94+
return {
95+
library: primary.id,
96+
confidence: Number(primary.score.toFixed(3)),
97+
alternates: scored.slice(1, 4),
98+
stats: {
99+
count: total,
100+
strokeOnly: strokeCount,
101+
fillOnly: fillCount,
102+
mixed,
103+
avgStrokeWidth: Number(avgWeight.toFixed(2)),
104+
gridDistribution: gridHits,
105+
roundedCapsFraction: Number((rounded / total).toFixed(2)),
106+
},
107+
signals: Object.entries(ctx).filter(([, v]) => v === true).map(([k]) => k),
108+
icons: perIconHints.slice(0, 30),
109+
};
110+
}

src/extractors/stack-intel.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// v10.4 — Stack Intel
2+
//
3+
// Extends stack-fingerprint.js with detectors for CMS platforms (Webflow,
4+
// Framer, Shopify, Ghost, Sanity, Contentful, Wix, Squarespace, WordPress),
5+
// analytics (GA, Segment, Mixpanel, PostHog, Amplitude, Heap), and
6+
// experimentation platforms (Optimizely, Statsig, GrowthBook, LaunchDarkly,
7+
// Split, Eppo). All signals come from script URLs + meta + known globals.
8+
9+
const CMS = [
10+
{ id: 'webflow', re: /webflow\.com|wf-|\.webflow\./i },
11+
{ id: 'framer', re: /framer\.(?:com|website)|__framer|framer-motion\b/i },
12+
{ id: 'shopify', re: /cdn\.shopify|shopify\.com|x-shopify/i },
13+
{ id: 'ghost', re: /ghost\.io|__ghost_|ghost-url/i },
14+
{ id: 'sanity', re: /cdn\.sanity\.io|sanity-studio/i },
15+
{ id: 'contentful', re: /cdn\.contentful\.com|ctfassets\.net/i },
16+
{ id: 'wix', re: /parastorage\.com|\.wix\.com/i },
17+
{ id: 'squarespace', re: /squarespace\.com|sqspcdn\.com|squarespace-cdn/i },
18+
{ id: 'wordpress', re: /wp-content|wp-includes|wordpress/i },
19+
{ id: 'hashnode', re: /hashnode\.com/i },
20+
{ id: 'notion', re: /notion\.so\/image|notion-static/i },
21+
{ id: 'bubble', re: /bubble\.io|bubble-cdn/i },
22+
];
23+
24+
const ANALYTICS = [
25+
{ id: 'google-analytics', re: /google-analytics\.com|googletagmanager\.com|gtag\(/ },
26+
{ id: 'segment', re: /segment\.com\/analytics|cdn\.segment\.io/i },
27+
{ id: 'mixpanel', re: /cdn\.mxpnl\.com|mixpanel\.com\/lib/i },
28+
{ id: 'amplitude', re: /amplitude\.com|cdn\.amplitude\.com/i },
29+
{ id: 'posthog', re: /posthog\.com|ph\.posthog\.com/i },
30+
{ id: 'heap', re: /heapanalytics\.com/i },
31+
{ id: 'fullstory', re: /fullstory\.com/i },
32+
{ id: 'hotjar', re: /static\.hotjar\.com|hj\.contentsquare/i },
33+
{ id: 'vercel-analytics', re: /_vercel\/insights|vercel\/analytics/i },
34+
{ id: 'plausible', re: /plausible\.io\/js|plausible\.io\/api/i },
35+
{ id: 'fathom', re: /usefathom\.com/i },
36+
{ id: 'sentry', re: /sentry\.io|sentry-cdn/i },
37+
{ id: 'datadog', re: /datadoghq\.com|datadog-rum/i },
38+
];
39+
40+
const EXPERIMENTATION = [
41+
{ id: 'optimizely', re: /optimizely\.com|cdn\.optimizely\./i },
42+
{ id: 'statsig', re: /statsig\.com/i },
43+
{ id: 'growthbook', re: /growthbook\.io/i },
44+
{ id: 'launchdarkly', re: /launchdarkly\.com/i },
45+
{ id: 'split', re: /split\.io|sdk\.split\.io/i },
46+
{ id: 'eppo', re: /eppo\.cloud/i },
47+
{ id: 'vercel-flags', re: /vercel\/flags|flags\.sdk/i },
48+
];
49+
50+
function fingerprint(haystack, list) {
51+
const hits = [];
52+
for (const entry of list) {
53+
if (entry.re.test(haystack)) hits.push(entry.id);
54+
}
55+
return hits;
56+
}
57+
58+
export function extractStackIntel(stack = {}) {
59+
const scripts = (stack.scripts || []).join(' \n');
60+
const metas = (stack.metas || []).map(m => `${m.name || ''} ${m.content || ''}`).join(' ');
61+
const classes = (stack.classNameSample || []).join(' ');
62+
const haystack = `${scripts}\n${metas}\n${classes}`;
63+
64+
return {
65+
cms: fingerprint(haystack, CMS),
66+
analytics: fingerprint(haystack, ANALYTICS),
67+
experimentation: fingerprint(haystack, EXPERIMENTATION),
68+
signals: {
69+
scriptCount: (stack.scripts || []).length,
70+
metaCount: (stack.metas || []).length,
71+
},
72+
};
73+
}

src/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import { extractComponentLibrary } from './extractors/component-library.js';
3434
import { extractMaterialLanguage } from './extractors/material-language.js';
3535
import { extractImageryStyle } from './extractors/imagery-style.js';
3636
import { extractSeo } from './extractors/seo.js';
37+
import { extractIconSystem } from './extractors/icon-system.js';
38+
import { extractBackgroundPatterns } from './extractors/background-patterns.js';
39+
import { extractStackIntel } from './extractors/stack-intel.js';
3740
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
3841
import { formatMotionTokens } from './formatters/motion-tokens.js';
3942

@@ -139,6 +142,9 @@ export async function extractDesignLanguage(url, options = {}) {
139142
design.materialLanguage = safeExtract(extractMaterialLanguage, design) || { label: 'flat', confidence: 0, signals: [], metrics: {} };
140143
design.imageryStyle = safeExtract(extractImageryStyle, rawData.light?.images || []) || { label: 'none', confidence: 0, counts: {}, signals: [] };
141144
design.seo = safeExtract(extractSeo, rawData) || { openGraph: {}, twitter: {}, structuredData: [], score: {} };
145+
design.iconSystem = safeExtract(extractIconSystem, rawData.light?.icons || []) || { library: 'unknown', confidence: 0, stats: {}, signals: [], icons: [] };
146+
design.backgroundPatterns = safeExtract(extractBackgroundPatterns, rawData) || { labels: ['plain'], counts: {}, gradientTotals: {}, samples: [] };
147+
design.stackIntel = safeExtract(extractStackIntel, rawData.light?.stack || {}) || { cms: [], analytics: [], experimentation: [] };
142148
// Stash raw crawler output so downstream orchestration (multipage, smart)
143149
// can rebuild the digest without re-crawling.
144150
design._raw = rawData;
@@ -209,6 +215,9 @@ export { pairDarkMode } from './extractors/dark-mode-pair.js';
209215
export { captureResponsiveScreenshots } from './extractors/responsive-screenshots.js';
210216
export { captureCoreWebVitals, extractFontLoading } from './extractors/perf.js';
211217
export { extractSeo } from './extractors/seo.js';
218+
export { extractIconSystem } from './extractors/icon-system.js';
219+
export { extractBackgroundPatterns } from './extractors/background-patterns.js';
220+
export { extractStackIntel } from './extractors/stack-intel.js';
212221
export { refineWithSmart } from './classifiers/smart.js';
213222
export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
214223
export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';

0 commit comments

Comments
 (0)