designlang 10.1.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 +15 -0
- package/bin/design-extract.js +24 -1
- package/package.json +1 -1
- package/src/extractors/dark-mode-pair.js +96 -0
- package/src/extractors/responsive-screenshots.js +55 -0
- package/src/index.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
3
18
|
## [10.1.0] — 2026-04-22
|
|
4
19
|
|
|
5
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.
|
package/bin/design-extract.js
CHANGED
|
@@ -10,6 +10,8 @@ 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
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';
|
|
13
15
|
import { buildPromptPack } from '../src/formatters/prompt-pack.js';
|
|
14
16
|
import { formatMarkdown } from '../src/formatters/markdown.js';
|
|
15
17
|
import { formatTokens } from '../src/formatters/tokens.js';
|
|
@@ -53,7 +55,7 @@ const program = new Command();
|
|
|
53
55
|
program
|
|
54
56
|
.name('designlang')
|
|
55
57
|
.description('Extract the complete design language from any website')
|
|
56
|
-
.version('10.
|
|
58
|
+
.version('10.2.0');
|
|
57
59
|
|
|
58
60
|
// ── Main command: extract ──────────────────────────────────────
|
|
59
61
|
program
|
|
@@ -85,6 +87,7 @@ program
|
|
|
85
87
|
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (needs OPENAI_API_KEY or ANTHROPIC_API_KEY)')
|
|
86
88
|
.option('--pages <n>', 'crawl N canonical pages (pricing/docs/blog/about/product) in addition to the homepage', parseInt)
|
|
87
89
|
.option('--no-prompts', 'skip writing the prompt-pack directory')
|
|
90
|
+
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
|
|
88
91
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
89
92
|
.option('--json-pretty', 'output formatted JSON to stdout')
|
|
90
93
|
.option('--no-history', 'skip saving to history')
|
|
@@ -233,6 +236,20 @@ program
|
|
|
233
236
|
} catch (e) { design.componentScreenshots = { error: e.message }; }
|
|
234
237
|
}
|
|
235
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
|
+
|
|
236
253
|
// v10: multi-page canonical crawl (pricing/docs/blog/about/product).
|
|
237
254
|
const pagesArg = merged.pages != null ? merged.pages : (merged.full ? 5 : 0);
|
|
238
255
|
if (pagesArg > 0) {
|
|
@@ -322,6 +339,12 @@ program
|
|
|
322
339
|
if (design.componentScreenshots && (design.componentScreenshots.components || []).length) {
|
|
323
340
|
files.push({ name: `${prefix}-screenshots.json`, content: JSON.stringify(design.componentScreenshots, null, 2), label: 'Component Screenshots index' });
|
|
324
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
|
+
}
|
|
325
348
|
if (merged.prompts !== false) {
|
|
326
349
|
const pack = buildPromptPack(design);
|
|
327
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,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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -203,6 +203,8 @@ 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
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';
|
|
206
208
|
export { refineWithSmart } from './classifiers/smart.js';
|
|
207
209
|
export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
|
|
208
210
|
export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';
|