dembrandt 0.6.1 → 0.7.1

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/README.md CHANGED
@@ -6,7 +6,19 @@
6
6
 
7
7
  Extract any website’s design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command.
8
8
 
9
- ![Dembrandt Demo](showcase.png)
9
+ ![Dembrandt — Any website to design tokens](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/banner.png)
10
+
11
+ **CLI output**
12
+
13
+ ![CLI extraction of netflix.com](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/cli-output.png)
14
+
15
+ **Brand Guide PDF**
16
+
17
+ ![Brand guide PDF extracted from any URL](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/brand-guide.png)
18
+
19
+ **Local UI**
20
+
21
+ ![Local UI showing extracted brand](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/local-ui.png)
10
22
 
11
23
  ## Install
12
24
 
@@ -41,6 +53,7 @@ dembrandt bmw.de --dtcg # Export in W3C Design Tokens (DTCG) format (
41
53
  dembrandt bmw.de --dark-mode # Extract colors from dark mode variant
42
54
  dembrandt bmw.de --mobile # Use mobile viewport (390x844, iPhone 12/13/14/15) for responsive analysis
43
55
  dembrandt bmw.de --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
56
+ dembrandt bmw.de --brand-guide # Generate a brand guide PDF
44
57
  dembrandt bmw.de --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
45
58
  dembrandt bmw.de --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
46
59
  ```
@@ -82,6 +95,37 @@ dembrandt stripe.com --dtcg
82
95
 
83
96
  The DTCG format is an industry-standard JSON schema that can be consumed by design tools and token transformation libraries like [Style Dictionary](https://styledictionary.com).
84
97
 
98
+ ## Local UI
99
+
100
+ Browse your extracted brands in a visual interface.
101
+
102
+ ### Setup
103
+
104
+ ```bash
105
+ cd local-ui
106
+ npm install
107
+ ```
108
+
109
+ ### Running
110
+
111
+ ```bash
112
+ npm start
113
+ ```
114
+
115
+ Opens http://localhost:5173 with API on port 3002.
116
+
117
+ ### Features
118
+
119
+ - Visual grid of all extracted brands
120
+ - Color palettes with click-to-copy
121
+ - Typography specimens
122
+ - Spacing, shadows, border radius visualization
123
+ - Button and link component previews
124
+ - Dark/light theme toggle
125
+ - Section nav links on extraction pages — jump directly to Colors, Typography, Shadows, etc. via a sticky sidebar
126
+
127
+ Extractions are performed via CLI (`dembrandt <url> --save-output`) and automatically appear in the UI.
128
+
85
129
  ## Use Cases
86
130
 
87
131
  - Brand audits & competitive analysis
package/index.js CHANGED
@@ -14,13 +14,14 @@ import { chromium, firefox } from "playwright-core";
14
14
  import { extractBranding } from "./lib/extractors.js";
15
15
  import { displayResults } from "./lib/display.js";
16
16
  import { toW3CFormat } from "./lib/w3c-exporter.js";
17
+ import { generatePDF } from "./lib/pdf.js";
17
18
  import { writeFileSync, mkdirSync } from "fs";
18
19
  import { join } from "path";
19
20
 
20
21
  program
21
22
  .name("dembrandt")
22
23
  .description("Extract design tokens from any website")
23
- .version("0.6.1")
24
+ .version("0.7.1")
24
25
  .argument("<url>")
25
26
  .option("--browser <type>", "Browser to use (chromium|firefox)", "chromium")
26
27
  .option("--json-only", "Output raw JSON")
@@ -29,7 +30,10 @@ program
29
30
  .option("--dark-mode", "Extract colors from dark mode")
30
31
  .option("--mobile", "Extract from mobile viewport")
31
32
  .option("--slow", "3x longer timeouts for slow-loading sites")
33
+ .option("--brand-guide", "Export a brand guide PDF")
32
34
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
35
+ .option("--raw-colors", "Include pre-filter raw colors in JSON output")
36
+ .option("--screenshot <path>", "Save a screenshot of the page")
33
37
  .action(async (input, opts) => {
34
38
  let url = input;
35
39
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
@@ -68,6 +72,7 @@ program
68
72
  darkMode: opts.darkMode,
69
73
  mobile: opts.mobile,
70
74
  slow: opts.slow,
75
+ screenshotPath: opts.screenshot,
71
76
  });
72
77
  break;
73
78
  } catch (err) {
@@ -95,11 +100,16 @@ program
95
100
 
96
101
  console.log();
97
102
 
103
+ // Strip raw colors unless --raw-colors flag is set
104
+ if (!opts.rawColors && result.colors && result.colors.rawColors) {
105
+ delete result.colors.rawColors;
106
+ }
107
+
98
108
  // Convert to W3C format if requested
99
109
  const outputData = opts.dtcg ? toW3CFormat(result) : result;
100
110
 
101
111
  // Save JSON output if --save-output or --dtcg is specified
102
- if ((opts.saveOutput || opts.dtcg) && !opts.jsonOnly) {
112
+ if (opts.saveOutput || opts.dtcg) {
103
113
  try {
104
114
  const domain = new URL(url).hostname.replace("www.", "");
105
115
  const timestamp = new Date()
@@ -129,6 +139,35 @@ program
129
139
  }
130
140
  }
131
141
 
142
+ // Generate PDF brand guide
143
+ if (opts.brandGuide) {
144
+ try {
145
+ const pdfDomain = new URL(url).hostname.replace("www.", "");
146
+ const now = new Date();
147
+ const pdfDate = now.toISOString().slice(0, 10);
148
+ const pdfTime = `${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`;
149
+ const pdfDir = join(process.cwd(), "output", pdfDomain);
150
+ mkdirSync(pdfDir, { recursive: true });
151
+ const pdfFilename = `${pdfDomain}-brand-guide-${pdfDate}-${pdfTime}.pdf`;
152
+ const pdfPath = join(pdfDir, pdfFilename);
153
+ spinner.start("Generating PDF brand guide...");
154
+ await generatePDF(result, pdfPath, browser);
155
+ spinner.stop();
156
+ console.log(
157
+ chalk.dim(
158
+ `PDF saved to: ${chalk.hex('#8BE9FD')(
159
+ `output/${pdfDomain}/${pdfFilename}`
160
+ )}`
161
+ )
162
+ );
163
+ } catch (err) {
164
+ spinner.stop();
165
+ console.log(
166
+ chalk.hex('#FFB86C')(`Could not generate PDF: ${err.message}`)
167
+ );
168
+ }
169
+ }
170
+
132
171
  // Output to terminal
133
172
  if (opts.jsonOnly) {
134
173
  console.log(JSON.stringify(outputData, null, 2));
package/lib/extractors.js CHANGED
@@ -206,7 +206,7 @@ export async function extractBranding(
206
206
  spinner.stop();
207
207
  console.log(chalk.hex('#8BE9FD')("\n Extracting design tokens...\n"));
208
208
 
209
- spinner.start("Analyzing design system (14 parallel tasks)...");
209
+ spinner.start("Analyzing design system (15 parallel tasks)...");
210
210
  const [
211
211
  { logo, favicons },
212
212
  colors,
@@ -222,6 +222,7 @@ export async function extractBranding(
222
222
  breakpoints,
223
223
  iconSystem,
224
224
  frameworks,
225
+ siteName,
225
226
  ] = await Promise.all([
226
227
  extractLogo(page, url),
227
228
  extractColors(page),
@@ -237,6 +238,7 @@ export async function extractBranding(
237
238
  extractBreakpoints(page),
238
239
  detectIconSystem(page),
239
240
  detectFrameworks(page),
241
+ extractSiteName(page),
240
242
  ]);
241
243
 
242
244
  spinner.stop();
@@ -584,6 +586,7 @@ export async function extractBranding(
584
586
  const result = {
585
587
  url: page.url(),
586
588
  extractedAt: new Date().toISOString(),
589
+ siteName,
587
590
  logo,
588
591
  favicons,
589
592
  colors,
@@ -616,6 +619,18 @@ export async function extractBranding(
616
619
  result.isCanvasOnly = true;
617
620
  }
618
621
 
622
+ // Save page screenshot if requested
623
+ if (options.screenshotPath) {
624
+ try {
625
+ const { mkdirSync } = await import("fs");
626
+ const { dirname } = await import("path");
627
+ mkdirSync(dirname(options.screenshotPath), { recursive: true });
628
+ await page.screenshot({ path: options.screenshotPath, fullPage: false });
629
+ } catch (err) {
630
+ console.error(chalk.dim(` ↳ Screenshot failed: ${err.message}`));
631
+ }
632
+ }
633
+
619
634
  return result;
620
635
  } catch (error) {
621
636
  spinner.fail("Extraction failed");
@@ -626,6 +641,47 @@ export async function extractBranding(
626
641
  }
627
642
  }
628
643
 
644
+ /**
645
+ * Extract the site/company name from meta tags, title, or structured data
646
+ */
647
+ async function extractSiteName(page) {
648
+ return await page.evaluate(() => {
649
+ // 1. og:site_name - most reliable
650
+ const ogSiteName = document.querySelector('meta[property="og:site_name"]');
651
+ if (ogSiteName?.content?.trim()) return ogSiteName.content.trim();
652
+
653
+ // 2. application-name
654
+ const appName = document.querySelector('meta[name="application-name"]');
655
+ if (appName?.content?.trim()) return appName.content.trim();
656
+
657
+ // 3. JSON-LD structured data
658
+ const ldScripts = document.querySelectorAll('script[type="application/ld+json"]');
659
+ for (const s of ldScripts) {
660
+ try {
661
+ const data = JSON.parse(s.textContent);
662
+ const items = Array.isArray(data) ? data : data?.['@graph'] || [data];
663
+ for (const obj of items) {
664
+ if (obj?.name && typeof obj.name === 'string') return obj.name;
665
+ if (obj?.organization?.name) return obj.organization.name;
666
+ }
667
+ } catch {}
668
+ }
669
+
670
+ // 4. Title tag - take part before separator
671
+ const title = document.title?.trim();
672
+ if (title) {
673
+ const sep = title.match(/(.+?)\s*[|\-–—:]\s*/);
674
+ if (sep && sep[1].length > 1 && sep[1].length < 40) return sep[1].trim();
675
+ }
676
+
677
+ // 5. Logo alt text
678
+ const logoImg = document.querySelector('img[class*="logo"], img[id*="logo"], a[class*="logo"] img');
679
+ if (logoImg?.alt?.trim() && logoImg.alt.length < 40) return logoImg.alt.trim();
680
+
681
+ return null;
682
+ });
683
+ }
684
+
629
685
  /**
630
686
  * Extract logo information from the page
631
687
  * Looks for common logo patterns: img with logo in class/id, SVG logos, etc.
@@ -668,13 +724,91 @@ async function extractLogo(page, url) {
668
724
  }
669
725
  }
670
726
 
727
+ // Include header SVGs/images that link to homepage (likely site logo)
728
+ const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="Header"], [id*="header"]');
729
+ if (inHeader) {
730
+ const parentLink = el.closest('a');
731
+ if (parentLink) {
732
+ const href = parentLink.getAttribute('href') || '';
733
+ const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
734
+ // Check for homepage link patterns (including /country/ paths like /fi/, /en-us/)
735
+ if (href === '/' || href === baseUrl || href === baseUrl + '/' ||
736
+ href.match(/^https?:\/\/[^/]+\/?$/) ||
737
+ href.match(/^https?:\/\/[^/]+\/[a-z]{2}(-[a-z]{2})?\/?$/) ||
738
+ ariaLabel.includes('homepage') || ariaLabel.includes('home page')) {
739
+ return true;
740
+ }
741
+ }
742
+ }
743
+
671
744
  return false;
672
745
  }
673
746
  );
674
747
 
675
748
  let logoData = null;
676
749
  if (candidates.length > 0) {
677
- const logo = candidates[0];
750
+ // Extract site domain for matching
751
+ const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
752
+
753
+ // Score each candidate
754
+ const scored = candidates.map(el => {
755
+ let score = 0;
756
+ const rect = el.getBoundingClientRect();
757
+ const parentLink = el.closest('a');
758
+ const linkHref = parentLink?.getAttribute('href') || '';
759
+ const imgSrc = el.tagName === 'IMG' ? (el.src || '') : '';
760
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
761
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
762
+
763
+ // 1. Prioritize logos in header/nav (+50)
764
+ const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="nav"], [id*="header"], [id*="nav"]');
765
+ if (inHeader) score += 50;
766
+
767
+ // 2. URL/alt contains site domain (+40)
768
+ if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) {
769
+ score += 40;
770
+ }
771
+
772
+ // 3. Links to homepage (+30)
773
+ if (parentLink) {
774
+ const href = linkHref.toLowerCase();
775
+ if (href === '/' || href === baseUrl || href === baseUrl + '/' || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
776
+ score += 30;
777
+ }
778
+ }
779
+
780
+ // 4. Position scoring - top-left bias (+20 max)
781
+ // Logos in top 200px get bonus, left 400px gets bonus
782
+ if (rect.top < 200) score += 10;
783
+ if (rect.left < 400) score += 10;
784
+
785
+ // Penalty for logos far down the page
786
+ if (rect.top > 600) score -= 20;
787
+
788
+ // Penalty for very small or very large images (likely not logos)
789
+ const width = el.naturalWidth || el.width?.baseVal?.value || rect.width;
790
+ const height = el.naturalHeight || el.height?.baseVal?.value || rect.height;
791
+ if (width < 20 || height < 20) score -= 30;
792
+ if (width > 500 || height > 300) score -= 40;
793
+
794
+ // Penalty for descriptive alt text (likely content images, not logos)
795
+ if (altText.length > 50) score -= 30;
796
+ if (altText.includes(' the ') || altText.includes(' a ') || altText.includes(' of ')) score -= 20;
797
+
798
+ // Bonus for typical logo dimensions (width > height, reasonable size)
799
+ if (width > height && width < 300 && width > 40 && height > 15 && height < 100) score += 15;
800
+
801
+ // Strong penalty if not in header and no domain match
802
+ if (!inHeader && !imgSrc.toLowerCase().includes(siteDomain) && !altText.includes(siteDomain)) {
803
+ score -= 30;
804
+ }
805
+
806
+ return { el, score };
807
+ });
808
+
809
+ // Sort by score descending and pick the best
810
+ scored.sort((a, b) => b.score - a.score);
811
+ const logo = scored[0].el;
678
812
  const computed = window.getComputedStyle(logo);
679
813
  const parent = logo.parentElement;
680
814
  const parentComputed = parent ? window.getComputedStyle(parent) : null;
@@ -695,6 +829,18 @@ async function extractLogo(page, url) {
695
829
  (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
696
830
  };
697
831
 
832
+ // Walk up the DOM to find the actual rendered background behind the logo
833
+ let logoBg = null;
834
+ let walker = logo;
835
+ while (walker) {
836
+ const bg = window.getComputedStyle(walker).backgroundColor;
837
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
838
+ logoBg = bg;
839
+ break;
840
+ }
841
+ walker = walker.parentElement;
842
+ }
843
+
698
844
  if (logo.tagName === "IMG") {
699
845
  logoData = {
700
846
  source: "img",
@@ -703,6 +849,7 @@ async function extractLogo(page, url) {
703
849
  height: logo.naturalHeight || logo.height,
704
850
  alt: logo.alt,
705
851
  safeZone: safeZone,
852
+ background: logoBg,
706
853
  };
707
854
  } else {
708
855
  // SVG logo - try to get the parent link or closest anchor
@@ -713,6 +860,7 @@ async function extractLogo(page, url) {
713
860
  width: logo.width?.baseVal?.value,
714
861
  height: logo.height?.baseVal?.value,
715
862
  safeZone: safeZone,
863
+ background: logoBg,
716
864
  };
717
865
  }
718
866
  }
@@ -1124,6 +1272,17 @@ async function extractColors(page) {
1124
1272
  return Math.sqrt(deltaL * deltaL + deltaA * deltaA + deltaB * deltaB);
1125
1273
  }
1126
1274
 
1275
+ // Capture all colors before filtering (for QA/debugging)
1276
+ const rawColors = Array.from(colorMap.entries())
1277
+ .map(([normalizedColor, data]) => ({
1278
+ color: data.original,
1279
+ normalized: normalizedColor,
1280
+ count: data.count,
1281
+ score: data.score,
1282
+ sources: Array.from(data.sources).slice(0, 3),
1283
+ }))
1284
+ .sort((a, b) => b.count - a.count);
1285
+
1127
1286
  const palette = Array.from(colorMap.entries())
1128
1287
  .filter(([normalizedColor, data]) => {
1129
1288
  // Filter out colors below threshold
@@ -1212,6 +1371,7 @@ async function extractColors(page) {
1212
1371
  return {
1213
1372
  semantic: semanticColors,
1214
1373
  palette: perceptuallyDeduped,
1374
+ rawColors,
1215
1375
  cssVariables: filteredCssVariables,
1216
1376
  };
1217
1377
  });
@@ -1630,7 +1790,7 @@ async function extractButtonStyles(page) {
1630
1790
  color: computed.color,
1631
1791
  padding: computed.padding,
1632
1792
  borderRadius: computed.borderRadius,
1633
- border: computed.border,
1793
+ border: computed.border || `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
1634
1794
  boxShadow: computed.boxShadow,
1635
1795
  outline: computed.outline,
1636
1796
  transform: computed.transform,
@@ -1653,11 +1813,25 @@ async function extractButtonStyles(page) {
1653
1813
  const bg = computed.backgroundColor;
1654
1814
  const border = computed.border;
1655
1815
  const borderWidth = computed.borderWidth;
1656
- const hasBorder = borderWidth && parseFloat(borderWidth) > 0 && border !== 'none';
1657
- const hasBackground = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
1658
-
1659
- // Skip only if BOTH background and border are missing/transparent
1660
- if (!hasBackground && !hasBorder) {
1816
+ const borderColor = computed.borderColor;
1817
+ const boxShadow = computed.boxShadow;
1818
+
1819
+ const hasBorder = borderWidth &&
1820
+ parseFloat(borderWidth) > 0 &&
1821
+ border !== 'none' &&
1822
+ borderColor !== 'rgba(0, 0, 0, 0)' &&
1823
+ borderColor !== 'transparent';
1824
+
1825
+ const hasBackground = bg &&
1826
+ bg !== 'rgba(0, 0, 0, 0)' &&
1827
+ bg !== 'transparent';
1828
+
1829
+ const hasShadow = boxShadow &&
1830
+ boxShadow !== 'none' &&
1831
+ boxShadow !== 'rgba(0, 0, 0, 0)';
1832
+
1833
+ // Skip only if background, border, and shadow are all missing/transparent
1834
+ if (!hasBackground && !hasBorder && !hasShadow) {
1661
1835
  return;
1662
1836
  }
1663
1837
 
@@ -1755,7 +1929,9 @@ async function extractButtonStyles(page) {
1755
1929
  const seen = new Set();
1756
1930
 
1757
1931
  for (const btn of buttonStyles) {
1758
- const key = btn.states.default.backgroundColor;
1932
+ const s = btn.states.default;
1933
+ // Key includes background, border (width/style), and shadow to distinguish variants
1934
+ const key = `${s.backgroundColor}|${s.border}|${s.boxShadow}`;
1759
1935
  if (!seen.has(key)) {
1760
1936
  seen.add(key);
1761
1937
  uniqueButtons.push(btn);
@@ -1825,7 +2001,7 @@ async function extractInputStyles(page) {
1825
2001
  const defaultState = {
1826
2002
  backgroundColor: computed.backgroundColor,
1827
2003
  color: computed.color,
1828
- border: computed.border,
2004
+ border: computed.border || `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
1829
2005
  borderRadius: computed.borderRadius,
1830
2006
  padding: computed.padding,
1831
2007
  boxShadow: computed.boxShadow,