designlang 10.0.0 → 10.2.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 +35 -0
- package/README.md +1 -0
- package/bin/design-extract.js +40 -1
- package/package.json +1 -1
- package/src/extractors/component-screenshots.js +161 -0
- package/src/extractors/dark-mode-pair.js +96 -0
- package/src/extractors/responsive-screenshots.js +55 -0
- package/src/formatters/markdown.js +18 -0
- package/src/index.js +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [10.2.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
**Dark mode pairing + responsive screenshots.** Joins the light & dark extractor passes into semantic pairs, and adds full-page captures at 4 breakpoints × (light, dark).
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`src/extractors/dark-mode-pair.js`** — pure function that maps light ↔ dark pairs for primary/secondary/accent/background/text roles and every CSS variable that actually differs between themes. Emits a drop-in Tailwind `darkMode: 'class'` config plus an audit (tokens missing from either pass).
|
|
10
|
+
- **`src/extractors/responsive-screenshots.js`** — full-page PNGs at mobile / tablet / desktop / wide × (light, dark). Writes to `screenshots/responsive/<breakpoint>-<scheme>.png` with an index.
|
|
11
|
+
- New flag `--responsive-shots`. Auto-on with `--full`.
|
|
12
|
+
- New outputs: `*-dark-mode.json`, `*-responsive.json`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- CLI version test now reads from `package.json` instead of a hardcoded string — no per-release test churn going forward.
|
|
17
|
+
|
|
18
|
+
## [10.1.0] — 2026-04-22
|
|
19
|
+
|
|
20
|
+
**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.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`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).
|
|
25
|
+
- Retina capture via a dedicated Playwright context at `deviceScaleFactor: 2`.
|
|
26
|
+
- **`*-screenshots.json`** — index file mapping every cropped PNG to its cluster name, variant, bounds, and fallback flag.
|
|
27
|
+
- Markdown formatter gains a **Component Screenshots** section listing the first 20 crops.
|
|
28
|
+
|
|
29
|
+
### Behaviour
|
|
30
|
+
|
|
31
|
+
- No new CLI flags. `--screenshots` and `--full` continue to opt into capture.
|
|
32
|
+
- Backward compatible — when no clusters match, the v9 hardcoded selector set still fires.
|
|
33
|
+
|
|
34
|
+
### Tests
|
|
35
|
+
|
|
36
|
+
297 → **299** passing.
|
|
37
|
+
|
|
3
38
|
## [10.0.0] — 2026-04-22
|
|
4
39
|
|
|
5
40
|
**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
|
+
[](https://safeskill.dev/scan/manavarya09-design-extract)
|
|
10
11
|
</p>
|
|
11
12
|
|
|
12
13
|
---
|
package/bin/design-extract.js
CHANGED
|
@@ -9,6 +9,9 @@ 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';
|
|
13
|
+
import { pairDarkMode } from '../src/extractors/dark-mode-pair.js';
|
|
14
|
+
import { captureResponsiveScreenshots } from '../src/extractors/responsive-screenshots.js';
|
|
12
15
|
import { buildPromptPack } from '../src/formatters/prompt-pack.js';
|
|
13
16
|
import { formatMarkdown } from '../src/formatters/markdown.js';
|
|
14
17
|
import { formatTokens } from '../src/formatters/tokens.js';
|
|
@@ -52,7 +55,7 @@ const program = new Command();
|
|
|
52
55
|
program
|
|
53
56
|
.name('designlang')
|
|
54
57
|
.description('Extract the complete design language from any website')
|
|
55
|
-
.version('10.
|
|
58
|
+
.version('10.2.0');
|
|
56
59
|
|
|
57
60
|
// ── Main command: extract ──────────────────────────────────────
|
|
58
61
|
program
|
|
@@ -84,6 +87,7 @@ program
|
|
|
84
87
|
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (needs OPENAI_API_KEY or ANTHROPIC_API_KEY)')
|
|
85
88
|
.option('--pages <n>', 'crawl N canonical pages (pricing/docs/blog/about/product) in addition to the homepage', parseInt)
|
|
86
89
|
.option('--no-prompts', 'skip writing the prompt-pack directory')
|
|
90
|
+
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
|
|
87
91
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
88
92
|
.option('--json-pretty', 'output formatted JSON to stdout')
|
|
89
93
|
.option('--no-history', 'skip saving to history')
|
|
@@ -220,6 +224,32 @@ program
|
|
|
220
224
|
} catch (e) { design.logo = { found: false, error: e.message }; }
|
|
221
225
|
}
|
|
222
226
|
|
|
227
|
+
// v10.1: cluster-aware retina component screenshots.
|
|
228
|
+
if (merged.full || merged.screenshots) {
|
|
229
|
+
spinner.text = 'Capturing component screenshots (retina)...';
|
|
230
|
+
try {
|
|
231
|
+
design.componentScreenshots = await captureComponentScreenshotsV10(url, outDir, {
|
|
232
|
+
width: merged.width,
|
|
233
|
+
height: parseInt(merged.height) || 800,
|
|
234
|
+
channel: merged.systemChrome ? 'chrome' : undefined,
|
|
235
|
+
});
|
|
236
|
+
} catch (e) { design.componentScreenshots = { error: e.message }; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// v10.2: dark-mode pairing (pure, based on already-extracted data).
|
|
240
|
+
design.darkModePaired = pairDarkMode(design);
|
|
241
|
+
|
|
242
|
+
// v10.2: responsive screenshots at 4 breakpoints × (light, dark).
|
|
243
|
+
if (merged.full || merged.responsiveShots) {
|
|
244
|
+
spinner.text = 'Capturing responsive screenshots...';
|
|
245
|
+
try {
|
|
246
|
+
design.responsiveShots = await captureResponsiveScreenshots(url, outDir, {
|
|
247
|
+
includeDark: merged.dark || merged.full,
|
|
248
|
+
channel: merged.systemChrome ? 'chrome' : undefined,
|
|
249
|
+
});
|
|
250
|
+
} catch (e) { design.responsiveShots = { error: e.message }; }
|
|
251
|
+
}
|
|
252
|
+
|
|
223
253
|
// v10: multi-page canonical crawl (pricing/docs/blog/about/product).
|
|
224
254
|
const pagesArg = merged.pages != null ? merged.pages : (merged.full ? 5 : 0);
|
|
225
255
|
if (pagesArg > 0) {
|
|
@@ -306,6 +336,15 @@ program
|
|
|
306
336
|
if (design.multiPage) {
|
|
307
337
|
files.push({ name: `${prefix}-multipage.json`, content: JSON.stringify(design.multiPage, null, 2), label: 'Multi-Page Crawl' });
|
|
308
338
|
}
|
|
339
|
+
if (design.componentScreenshots && (design.componentScreenshots.components || []).length) {
|
|
340
|
+
files.push({ name: `${prefix}-screenshots.json`, content: JSON.stringify(design.componentScreenshots, null, 2), label: 'Component Screenshots index' });
|
|
341
|
+
}
|
|
342
|
+
if (design.darkModePaired && design.darkModePaired.available) {
|
|
343
|
+
files.push({ name: `${prefix}-dark-mode.json`, content: JSON.stringify(design.darkModePaired, null, 2), label: 'Dark Mode Pairing' });
|
|
344
|
+
}
|
|
345
|
+
if (design.responsiveShots && Array.isArray(design.responsiveShots.shots) && design.responsiveShots.shots.length) {
|
|
346
|
+
files.push({ name: `${prefix}-responsive.json`, content: JSON.stringify(design.responsiveShots, null, 2), label: 'Responsive Screenshots index' });
|
|
347
|
+
}
|
|
309
348
|
if (merged.prompts !== false) {
|
|
310
349
|
const pack = buildPromptPack(design);
|
|
311
350
|
const promptsDir = join(outDir, `${prefix}-prompts`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.2.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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// v10.2 — Dark Mode Pairing
|
|
2
|
+
//
|
|
3
|
+
// When the user passes --dark the crawler runs a second pass under
|
|
4
|
+
// `color-scheme: dark` and the main extractor emits `design.darkMode` with
|
|
5
|
+
// parallel colors + CSS variable maps. This module joins the two halves into
|
|
6
|
+
// *semantic pairs* — same role in light mode ↔ same role in dark mode — so
|
|
7
|
+
// downstream consumers (prompt pack, Tailwind darkMode config, agents) can
|
|
8
|
+
// author a single `[data-theme="dark"]` override block without guessing.
|
|
9
|
+
//
|
|
10
|
+
// Pure function. No Page handle, no side effects — takes the finished design
|
|
11
|
+
// object and returns a plain JSON structure.
|
|
12
|
+
|
|
13
|
+
function hexOf(c) {
|
|
14
|
+
if (!c) return null;
|
|
15
|
+
if (typeof c === 'string') return c.toLowerCase();
|
|
16
|
+
return (c.hex || '').toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pairRoleColors(light = {}, dark = {}) {
|
|
20
|
+
const pairs = {};
|
|
21
|
+
for (const role of ['primary', 'secondary', 'accent']) {
|
|
22
|
+
const l = hexOf(light[role]);
|
|
23
|
+
const d = hexOf(dark[role]);
|
|
24
|
+
if (l || d) pairs[role] = { light: l, dark: d };
|
|
25
|
+
}
|
|
26
|
+
const bgPair = {
|
|
27
|
+
light: (light.backgrounds?.[0]?.hex || null),
|
|
28
|
+
dark: (dark.backgrounds?.[0]?.hex || null),
|
|
29
|
+
};
|
|
30
|
+
if (bgPair.light || bgPair.dark) pairs.background = bgPair;
|
|
31
|
+
const textPair = {
|
|
32
|
+
light: (light.text?.[0]?.hex || null),
|
|
33
|
+
dark: (dark.text?.[0]?.hex || null),
|
|
34
|
+
};
|
|
35
|
+
if (textPair.light || textPair.dark) pairs.text = textPair;
|
|
36
|
+
return pairs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pairVariables(lightVars = {}, darkVars = {}) {
|
|
40
|
+
// Walk the union of keys; emit light/dark only when values differ.
|
|
41
|
+
const keys = new Set([...Object.keys(lightVars || {}), ...Object.keys(darkVars || {})]);
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
const l = lightVars[k];
|
|
45
|
+
const d = darkVars[k];
|
|
46
|
+
if (l == null && d == null) continue;
|
|
47
|
+
if (typeof l === 'string' && typeof d === 'string' && l === d) continue;
|
|
48
|
+
out[k] = { light: l ?? null, dark: d ?? null };
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pairDarkMode(design = {}) {
|
|
54
|
+
if (!design.darkMode) {
|
|
55
|
+
return { available: false, reason: 'no --dark pass captured' };
|
|
56
|
+
}
|
|
57
|
+
const lightColors = design.colors || {};
|
|
58
|
+
const darkColors = design.darkMode.colors || {};
|
|
59
|
+
const roles = pairRoleColors(lightColors, darkColors);
|
|
60
|
+
const variables = pairVariables(design.variables || {}, design.darkMode.variables || {});
|
|
61
|
+
const pairedVarCount = Object.keys(variables).length;
|
|
62
|
+
|
|
63
|
+
// Light colors that don't appear in dark (and vice versa) signal tokens the
|
|
64
|
+
// site forgot to theme — useful for an audit.
|
|
65
|
+
const lightSet = new Set((lightColors.all || []).map(c => (c.hex || '').toLowerCase()).filter(Boolean));
|
|
66
|
+
const darkSet = new Set((darkColors.all || []).map(c => (c.hex || '').toLowerCase()).filter(Boolean));
|
|
67
|
+
const missingInDark = [...lightSet].filter(x => !darkSet.has(x)).slice(0, 20);
|
|
68
|
+
const missingInLight = [...darkSet].filter(x => !lightSet.has(x)).slice(0, 20);
|
|
69
|
+
|
|
70
|
+
// Tailwind-ready config snippet.
|
|
71
|
+
const tailwind = {
|
|
72
|
+
darkMode: 'class',
|
|
73
|
+
theme: {
|
|
74
|
+
extend: {
|
|
75
|
+
colors: Object.fromEntries(
|
|
76
|
+
Object.entries(roles)
|
|
77
|
+
.filter(([, v]) => v.light && v.dark)
|
|
78
|
+
.map(([role, v]) => [role, { DEFAULT: v.light, dark: v.dark }]),
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
available: true,
|
|
86
|
+
roles,
|
|
87
|
+
variables,
|
|
88
|
+
pairedVarCount,
|
|
89
|
+
audit: {
|
|
90
|
+
missingInDark,
|
|
91
|
+
missingInLight,
|
|
92
|
+
coverage: pairedVarCount > 0 ? 'paired' : 'light-only-vars',
|
|
93
|
+
},
|
|
94
|
+
tailwind,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// v10.2 — Responsive Screenshots
|
|
2
|
+
//
|
|
3
|
+
// Full-page PNGs at each breakpoint × (light, dark). Lives alongside the
|
|
4
|
+
// component screenshots dir so output stays organised. Writes to
|
|
5
|
+
// `screenshots/responsive/<breakpoint>-<scheme>.png` and returns an index.
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
import { mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
const BREAKPOINTS = [
|
|
12
|
+
{ slug: 'mobile', width: 375, height: 812 },
|
|
13
|
+
{ slug: 'tablet', width: 768, height: 1024 },
|
|
14
|
+
{ slug: 'desktop', width: 1280, height: 800 },
|
|
15
|
+
{ slug: 'wide', width: 1920, height: 1080 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
async function captureAt(url, dir, bp, scheme, channel) {
|
|
19
|
+
const browser = await chromium.launch({ headless: true, ...(channel && { channel }) });
|
|
20
|
+
try {
|
|
21
|
+
const ctx = await browser.newContext({
|
|
22
|
+
viewport: { width: bp.width, height: bp.height },
|
|
23
|
+
deviceScaleFactor: bp.slug === 'mobile' ? 2 : 1,
|
|
24
|
+
colorScheme: scheme,
|
|
25
|
+
});
|
|
26
|
+
const page = await ctx.newPage();
|
|
27
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
28
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
29
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
30
|
+
const file = `${bp.slug}-${scheme}.png`;
|
|
31
|
+
const path = join(dir, file);
|
|
32
|
+
await page.screenshot({ path, fullPage: true });
|
|
33
|
+
return { breakpoint: bp.slug, scheme, width: bp.width, path: `screenshots/responsive/${file}` };
|
|
34
|
+
} finally {
|
|
35
|
+
await browser.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function captureResponsiveScreenshots(url, outDir, { includeDark = true, channel } = {}) {
|
|
40
|
+
const dir = join(outDir, 'screenshots', 'responsive');
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
const out = [];
|
|
43
|
+
const schemes = includeDark ? ['light', 'dark'] : ['light'];
|
|
44
|
+
for (const bp of BREAKPOINTS) {
|
|
45
|
+
for (const scheme of schemes) {
|
|
46
|
+
try {
|
|
47
|
+
const row = await captureAt(url, dir, bp, scheme, channel);
|
|
48
|
+
out.push(row);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
out.push({ breakpoint: bp.slug, scheme, error: e.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { count: out.filter(r => !r.error).length, shots: out };
|
|
55
|
+
}
|
|
@@ -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,9 @@ 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';
|
|
206
|
+
export { pairDarkMode } from './extractors/dark-mode-pair.js';
|
|
207
|
+
export { captureResponsiveScreenshots } from './extractors/responsive-screenshots.js';
|
|
205
208
|
export { refineWithSmart } from './classifiers/smart.js';
|
|
206
209
|
export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
|
|
207
210
|
export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';
|