designlang 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [10.1.0] — 2026-04-22
4
+
5
+ **Component screenshots.** The existing `--screenshots` flag now emits cluster-aware, retina (2×), multi-variant PNGs instead of five hardcoded selectors and a full-page image.
6
+
7
+ ### Added
8
+
9
+ - **`src/extractors/component-screenshots.js`** — queries the live DOM with the same candidate selector the crawler uses, groups matches by `kind + variantHint + sizeHint`, and captures up to three representatives per group. Falls back to the v9 hardcoded list when no clusters produced anything (auth / docs pages).
10
+ - Retina capture via a dedicated Playwright context at `deviceScaleFactor: 2`.
11
+ - **`*-screenshots.json`** — index file mapping every cropped PNG to its cluster name, variant, bounds, and fallback flag.
12
+ - Markdown formatter gains a **Component Screenshots** section listing the first 20 crops.
13
+
14
+ ### Behaviour
15
+
16
+ - No new CLI flags. `--screenshots` and `--full` continue to opt into capture.
17
+ - Backward compatible — when no clusters match, the v9 hardcoded selector set still fires.
18
+
19
+ ### Tests
20
+
21
+ 297 → **299** passing.
22
+
3
23
  ## [10.0.0] — 2026-04-22
4
24
 
5
25
  **The Intent Release.** v9 captured *how* a site looks; v10 captures *what it is* — the semantic layer LLM agents need to rebuild a site faithfully, not just restyle a generic scaffold. Six new extractors, a multi-page crawl orchestrator, an optional smart-classifier LLM fallback, and a ready-to-paste prompt pack. 297/297 tests passing.
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  <a href="https://github.com/Manavarya09/design-extract/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Manavarya09/design-extract?color=0A0908&labelColor=F3F1EA" alt="license"></a>
8
8
  <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/designlang?color=0A0908&labelColor=F3F1EA" alt="node version"></a>
9
9
  <a href="https://designlang.manavaryasingh.com/"><img src="https://img.shields.io/badge/website-live-FF4800?labelColor=F3F1EA" alt="website"></a>
10
+ [![SafeSkill 77/100](https://img.shields.io/badge/SafeSkill-77%2F100_Passes%20with%20Notes-yellow)](https://safeskill.dev/scan/manavarya09-design-extract)
10
11
  </p>
11
12
 
12
13
  ---
@@ -9,6 +9,7 @@ import { extractDesignLanguage } from '../src/index.js';
9
9
  import { refineWithSmart } from '../src/classifiers/smart.js';
10
10
  import { crawlCanonicalPages } from '../src/multipage.js';
11
11
  import { extractLogo } from '../src/extractors/logo.js';
12
+ import { captureComponentScreenshotsV10 } from '../src/extractors/component-screenshots.js';
12
13
  import { buildPromptPack } from '../src/formatters/prompt-pack.js';
13
14
  import { formatMarkdown } from '../src/formatters/markdown.js';
14
15
  import { formatTokens } from '../src/formatters/tokens.js';
@@ -52,7 +53,7 @@ const program = new Command();
52
53
  program
53
54
  .name('designlang')
54
55
  .description('Extract the complete design language from any website')
55
- .version('10.0.0');
56
+ .version('10.1.0');
56
57
 
57
58
  // ── Main command: extract ──────────────────────────────────────
58
59
  program
@@ -220,6 +221,18 @@ program
220
221
  } catch (e) { design.logo = { found: false, error: e.message }; }
221
222
  }
222
223
 
224
+ // v10.1: cluster-aware retina component screenshots.
225
+ if (merged.full || merged.screenshots) {
226
+ spinner.text = 'Capturing component screenshots (retina)...';
227
+ try {
228
+ design.componentScreenshots = await captureComponentScreenshotsV10(url, outDir, {
229
+ width: merged.width,
230
+ height: parseInt(merged.height) || 800,
231
+ channel: merged.systemChrome ? 'chrome' : undefined,
232
+ });
233
+ } catch (e) { design.componentScreenshots = { error: e.message }; }
234
+ }
235
+
223
236
  // v10: multi-page canonical crawl (pricing/docs/blog/about/product).
224
237
  const pagesArg = merged.pages != null ? merged.pages : (merged.full ? 5 : 0);
225
238
  if (pagesArg > 0) {
@@ -306,6 +319,9 @@ program
306
319
  if (design.multiPage) {
307
320
  files.push({ name: `${prefix}-multipage.json`, content: JSON.stringify(design.multiPage, null, 2), label: 'Multi-Page Crawl' });
308
321
  }
322
+ if (design.componentScreenshots && (design.componentScreenshots.components || []).length) {
323
+ files.push({ name: `${prefix}-screenshots.json`, content: JSON.stringify(design.componentScreenshots, null, 2), label: 'Component Screenshots index' });
324
+ }
309
325
  if (merged.prompts !== false) {
310
326
  const pack = buildPromptPack(design);
311
327
  const promptsDir = join(outDir, `${prefix}-prompts`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "10.0.0",
3
+ "version": "10.1.0",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,161 @@
1
+ // v10.1 — Component Screenshots
2
+ //
3
+ // Given a Playwright Page, crop PNG screenshots for each detected component
4
+ // cluster. We re-query the DOM using the same candidate selector the crawler
5
+ // uses, group matches by `kind + variantHint + sizeHint`, and capture up to
6
+ // three representatives per group at 2× retina. Writes into a `screenshots/`
7
+ // subdirectory under the output root and returns an index the bin emits as
8
+ // `*-screenshots.json`.
9
+
10
+ import { chromium } from 'playwright';
11
+ import { mkdirSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ const CANDIDATE_SELECTOR = 'button, a[role="button"], .btn, [class*="button"], input[type="text"], input[type="email"], input[type="search"], textarea, [class*="card"]';
15
+
16
+ const FALLBACK_GROUPS = [
17
+ { slug: 'button', selector: 'button:not(:empty), a[role="button"], [class*="btn"]:not(:empty)' },
18
+ { slug: 'card', selector: '[class*="card"]:not(:empty)' },
19
+ { slug: 'input', selector: 'input[type="text"], input[type="email"], input[type="search"], textarea' },
20
+ { slug: 'nav', selector: 'nav, [role="navigation"]' },
21
+ { slug: 'hero', selector: '[class*="hero"], section:first-of-type' },
22
+ ];
23
+
24
+ function slugify(s) {
25
+ return (s || 'component')
26
+ .toString()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-|-$/g, '') || 'component';
30
+ }
31
+
32
+ async function collectClusteredHandles(page) {
33
+ // In-page classification mirrors what the crawler does for candidates but
34
+ // returns enough to screenshot (unique index so we can re-find handles).
35
+ const groups = await page.evaluate((sel) => {
36
+ const out = {};
37
+ let ix = 0;
38
+ for (const el of document.querySelectorAll(sel)) {
39
+ const r = el.getBoundingClientRect();
40
+ if (r.width < 20 || r.height < 10) continue;
41
+ if (r.width > window.innerWidth || r.height > window.innerHeight * 2) continue;
42
+ const cs = getComputedStyle(el);
43
+ if (cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) < 0.1) continue;
44
+
45
+ const tag = el.tagName.toLowerCase();
46
+ const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
47
+ let kind = 'other';
48
+ if (tag === 'button' || el.getAttribute('role') === 'button' || /\bbtn\b|button/.test(cls)) kind = 'button';
49
+ else if (tag === 'input' || tag === 'textarea') kind = 'input';
50
+ else if (/card/.test(cls)) kind = 'card';
51
+ else if (tag === 'a') kind = 'link';
52
+
53
+ const variant = ((cls.match(/\b(primary|secondary|tertiary|ghost|outline|solid|destructive|danger|success|warning|subtle)\b/) || [])[1]) || 'default';
54
+ const size = ((cls.match(/\b(xs|sm|md|lg|xl|small|medium|large)\b/) || [])[1]) || '';
55
+
56
+ const key = [kind, variant, size].filter(Boolean).join('--');
57
+ if (!out[key]) out[key] = [];
58
+ el.setAttribute('data-dl-shot', String(ix));
59
+ out[key].push({ ix, w: Math.round(r.width), h: Math.round(r.height), kind, variant, size });
60
+ ix++;
61
+ }
62
+ return out;
63
+ }, CANDIDATE_SELECTOR);
64
+ return groups;
65
+ }
66
+
67
+ async function captureGroup(page, key, entries, screenshotDir, maxPerGroup = 3) {
68
+ const out = [];
69
+ const slug = slugify(key);
70
+ let variant = 0;
71
+ for (const info of entries.slice(0, maxPerGroup)) {
72
+ const handle = await page.$(`[data-dl-shot="${info.ix}"]`);
73
+ if (!handle) continue;
74
+ const file = `${slug}-${variant}.png`;
75
+ const path = join(screenshotDir, file);
76
+ try {
77
+ await handle.screenshot({ path, omitBackground: false });
78
+ } catch { continue; }
79
+ out.push({
80
+ cluster: key,
81
+ variant,
82
+ path: `screenshots/${file}`,
83
+ bounds: { w: info.w, h: info.h },
84
+ kind: info.kind,
85
+ variantHint: info.variant,
86
+ sizeHint: info.size,
87
+ retina: true,
88
+ });
89
+ variant++;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ async function captureFallbacks(page, screenshotDir) {
95
+ const out = [];
96
+ for (const g of FALLBACK_GROUPS) {
97
+ try {
98
+ const handles = await page.$$(g.selector);
99
+ for (const h of handles.slice(0, 2)) {
100
+ const box = await h.boundingBox();
101
+ if (!box || box.width < 20 || box.height < 10) continue;
102
+ const path = join(screenshotDir, `${g.slug}-${out.filter(x => x.cluster === g.slug).length}.png`);
103
+ await h.screenshot({ path });
104
+ out.push({
105
+ cluster: g.slug,
106
+ variant: out.filter(x => x.cluster === g.slug).length,
107
+ path: `screenshots/${path.split('screenshots/')[1]}`,
108
+ bounds: { w: Math.round(box.width), h: Math.round(box.height) },
109
+ retina: true,
110
+ fallback: true,
111
+ });
112
+ }
113
+ } catch { /* skip */ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // Public entry. Launches a fresh Playwright context at deviceScaleFactor: 2
119
+ // so captures are crisp on retina displays without a second full run.
120
+ export async function captureComponentScreenshotsV10(url, outDir, { width = 1280, height = 800, channel } = {}) {
121
+ const screenshotDir = join(outDir, 'screenshots');
122
+ mkdirSync(screenshotDir, { recursive: true });
123
+
124
+ const browser = await chromium.launch({ headless: true, ...(channel && { channel }) });
125
+ try {
126
+ const context = await browser.newContext({
127
+ viewport: { width, height },
128
+ deviceScaleFactor: 2,
129
+ colorScheme: 'light',
130
+ });
131
+ const page = await context.newPage();
132
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
133
+ await page.waitForLoadState('networkidle').catch(() => {});
134
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
135
+
136
+ const groups = await collectClusteredHandles(page);
137
+ let components = [];
138
+ for (const [key, entries] of Object.entries(groups)) {
139
+ if (!entries.length) continue;
140
+ const rows = await captureGroup(page, key, entries, screenshotDir);
141
+ components.push(...rows);
142
+ }
143
+
144
+ // If clustering produced nothing (auth / docs pages often do), fall back
145
+ // to the v9 hardcoded selector list so users still get something.
146
+ if (!components.length) {
147
+ components = await captureFallbacks(page, screenshotDir);
148
+ }
149
+
150
+ let fullPage = null;
151
+ try {
152
+ const p = join(screenshotDir, 'full-page.png');
153
+ await page.screenshot({ path: p, fullPage: true });
154
+ fullPage = { path: 'screenshots/full-page.png', retina: true };
155
+ } catch { /* non-fatal */ }
156
+
157
+ return { components, fullPage, count: components.length };
158
+ } finally {
159
+ await browser.close();
160
+ }
161
+ }
@@ -802,6 +802,24 @@ export function formatMarkdown(design) {
802
802
  }
803
803
  }
804
804
 
805
+ // ── v10.1: Component Screenshots ──
806
+ if (design.componentScreenshots && Array.isArray(design.componentScreenshots.components) && design.componentScreenshots.components.length) {
807
+ lines.push('## Component Screenshots');
808
+ lines.push('');
809
+ lines.push(`${design.componentScreenshots.components.length} retina crops written to \`screenshots/\`. Index: \`*-screenshots.json\`.`);
810
+ lines.push('');
811
+ lines.push('| Cluster | Variant | Size (px) | File |');
812
+ lines.push('|---------|---------|-----------|------|');
813
+ for (const c of design.componentScreenshots.components.slice(0, 20)) {
814
+ lines.push(`| ${c.cluster} | ${c.variant} | ${c.bounds?.w || '?'} × ${c.bounds?.h || '?'} | \`${c.path}\` |`);
815
+ }
816
+ if (design.componentScreenshots.fullPage) {
817
+ lines.push('');
818
+ lines.push(`Full-page: \`${design.componentScreenshots.fullPage.path}\``);
819
+ }
820
+ lines.push('');
821
+ }
822
+
805
823
  // ── Quick Start ──
806
824
  lines.push('## Quick Start');
807
825
  lines.push('');
package/src/index.js CHANGED
@@ -202,6 +202,7 @@ export { extractComponentLibrary } from './extractors/component-library.js';
202
202
  export { extractMaterialLanguage } from './extractors/material-language.js';
203
203
  export { extractImageryStyle } from './extractors/imagery-style.js';
204
204
  export { extractLogo } from './extractors/logo.js';
205
+ export { captureComponentScreenshotsV10 } from './extractors/component-screenshots.js';
205
206
  export { refineWithSmart } from './classifiers/smart.js';
206
207
  export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
207
208
  export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';