dembrandt 0.12.1 → 0.12.3

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
@@ -71,6 +71,7 @@ dembrandt example.com --sitemap # Discover pages from sitemap.xml instead
71
71
  dembrandt example.com --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
72
72
  dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
73
73
  dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
74
+ dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
74
75
  ```
75
76
 
76
77
  Default: formatted terminal display only. Use `--save-output` to persist results as JSON files. Browser automatically retries in visible mode if headless extraction fails.
@@ -140,6 +141,16 @@ dembrandt example.com --design-md
140
141
  # Saves to: output/example.com/DESIGN.md
141
142
  ```
142
143
 
144
+ ### WCAG Contrast Analysis
145
+
146
+ Use `--wcag` to check accessibility contrast ratios across the page. Unlike palette-based checkers, dembrandt walks the actual DOM and finds what color is rendered on top of what background — per element.
147
+
148
+ ```bash
149
+ dembrandt stripe.com --wcag
150
+ ```
151
+
152
+ Returns every text/background pair with contrast ratio and WCAG 2.1 grade (AA, AA-Large, AAA, or fail), sorted by how often each pair appears. Results are shown in terminal and included in JSON output as `wcag`.
153
+
143
154
  ### Brand Guide PDF
144
155
 
145
156
  Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
@@ -222,6 +233,11 @@ dembrandt stripe.com --design-md
222
233
  # Point your agent at the output DESIGN.md
223
234
  ```
224
235
 
236
+ **Accessibility audit** — check contrast on any live URL
237
+ ```bash
238
+ dembrandt stripe.com --wcag
239
+ ```
240
+
225
241
  **Regression baseline** — snapshot now, catch drift later
226
242
  ```bash
227
243
  dembrandt myapp.com --save-output --dtcg
@@ -1,137 +1,99 @@
1
1
  export async function extractButtonStyles(page) {
2
2
  return await page.evaluate(() => {
3
- const buttons = Array.from(
4
- document.querySelectorAll(`
5
- button,
6
- a[type="button"],
7
- [role="button"],
8
- [role="tab"],
9
- [role="menuitem"],
10
- [role="switch"],
11
- [aria-pressed],
12
- [aria-expanded],
13
- .btn,
14
- [class*="btn"],
15
- [class*="button"],
16
- [class*="cta"],
17
- [data-cta]
18
- `)
19
- );
20
-
21
- const extractState = (btn) => {
22
- const computed = getComputedStyle(btn);
23
- return {
24
- backgroundColor: computed.backgroundColor,
25
- color: computed.color,
26
- padding: computed.padding,
27
- borderRadius: computed.borderRadius,
28
- border: computed.border || `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
29
- boxShadow: computed.boxShadow,
30
- outline: computed.outline,
31
- transform: computed.transform,
32
- opacity: computed.opacity,
33
- };
34
- };
35
-
36
- const buttonStyles = [];
37
-
38
- buttons.forEach((btn) => {
39
- const computed = getComputedStyle(btn);
40
- const rect = btn.getBoundingClientRect();
41
- if (rect.width === 0 || rect.height === 0 || computed.display === 'none' || computed.visibility === 'hidden') return;
42
-
43
- const bg = computed.backgroundColor;
44
- const border = computed.border;
45
- const borderWidth = computed.borderWidth;
46
- const borderColor = computed.borderColor;
47
- const boxShadow = computed.boxShadow;
48
-
49
- const hasBorder = borderWidth && parseFloat(borderWidth) > 0 && border !== 'none' && borderColor !== 'rgba(0, 0, 0, 0)' && borderColor !== 'transparent';
50
- const hasBackground = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
51
- const hasShadow = boxShadow && boxShadow !== 'none' && boxShadow !== 'rgba(0, 0, 0, 0)';
3
+ // Only real interactive buttons not tabs, menus, dropdowns
4
+ const candidates = Array.from(document.querySelectorAll(
5
+ 'button, a[href], [role="button"]'
6
+ ));
52
7
 
53
- if (!hasBackground && !hasBorder && !hasShadow) return;
8
+ const isTransparent = (color) =>
9
+ !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
54
10
 
55
- const role = btn.getAttribute('role');
56
- const isNativeButton = btn.tagName === "BUTTON";
57
- const isButtonRole = ['button', 'tab', 'menuitem', 'switch'].includes(role);
58
- const hasAriaPressed = btn.hasAttribute('aria-pressed');
59
- const hasAriaExpanded = btn.hasAttribute('aria-expanded');
60
- const isHighConfidence = isNativeButton || isButtonRole || hasAriaPressed || hasAriaExpanded;
61
-
62
- const className = typeof btn.className === 'string' ? btn.className : btn.className.baseVal || '';
63
-
64
- const defaultState = extractState(btn);
65
- const states = { default: defaultState, hover: null, active: null, focus: null };
11
+ const results = [];
66
12
 
13
+ for (const el of candidates) {
67
14
  try {
68
- const sheets = Array.from(document.styleSheets);
69
- for (const sheet of sheets) {
70
- try {
71
- const rules = Array.from(sheet.cssRules || []);
72
- for (const rule of rules) {
73
- if (rule.selectorText) {
74
- const btnClasses = className.split(' ').filter(c => c);
75
- const matchesButton = btnClasses.some(cls => rule.selectorText.includes(`.${cls}`));
76
- if (matchesButton || rule.selectorText.includes(btn.tagName.toLowerCase())) {
77
- if (rule.selectorText.includes(':hover')) {
78
- if (!states.hover) states.hover = {};
79
- if (rule.style.backgroundColor) states.hover.backgroundColor = rule.style.backgroundColor;
80
- if (rule.style.color) states.hover.color = rule.style.color;
81
- if (rule.style.boxShadow) states.hover.boxShadow = rule.style.boxShadow;
82
- if (rule.style.outline) states.hover.outline = rule.style.outline;
83
- if (rule.style.border) states.hover.border = rule.style.border;
84
- if (rule.style.transform) states.hover.transform = rule.style.transform;
85
- if (rule.style.opacity) states.hover.opacity = rule.style.opacity;
86
- }
87
- if (rule.selectorText.includes(':active')) {
88
- if (!states.active) states.active = {};
89
- if (rule.style.backgroundColor) states.active.backgroundColor = rule.style.backgroundColor;
90
- if (rule.style.color) states.active.color = rule.style.color;
91
- if (rule.style.boxShadow) states.active.boxShadow = rule.style.boxShadow;
92
- if (rule.style.outline) states.active.outline = rule.style.outline;
93
- if (rule.style.border) states.active.border = rule.style.border;
94
- if (rule.style.transform) states.active.transform = rule.style.transform;
95
- if (rule.style.opacity) states.active.opacity = rule.style.opacity;
96
- }
97
- if (rule.selectorText.includes(':focus')) {
98
- if (!states.focus) states.focus = {};
99
- if (rule.style.backgroundColor) states.focus.backgroundColor = rule.style.backgroundColor;
100
- if (rule.style.color) states.focus.color = rule.style.color;
101
- if (rule.style.boxShadow) states.focus.boxShadow = rule.style.boxShadow;
102
- if (rule.style.outline) states.focus.outline = rule.style.outline;
103
- if (rule.style.border) states.focus.border = rule.style.border;
104
- if (rule.style.transform) states.focus.transform = rule.style.transform;
105
- if (rule.style.opacity) states.focus.opacity = rule.style.opacity;
106
- }
107
- }
108
- }
109
- }
110
- } catch (e) {}
111
- }
112
- } catch (e) {}
15
+ const computed = getComputedStyle(el);
16
+ const rect = el.getBoundingClientRect();
17
+
18
+ if (rect.width === 0 || rect.height === 0) continue;
19
+ if (computed.display === 'none' || computed.visibility === 'hidden') continue;
20
+
21
+ // Skip nav/menu context elements
22
+ const role = el.getAttribute('role');
23
+ if (['tab', 'menuitem', 'option', 'switch', 'treeitem'].includes(role)) continue;
24
+ if (el.closest('[role="tablist"], [role="menu"], [role="menubar"], nav, footer')) continue;
25
+
26
+ // Must have a visible background or border — not just inherited
27
+ const bg = computed.backgroundColor;
28
+ const borderWidth = parseFloat(computed.borderWidth);
29
+ const hasBackground = !isTransparent(bg);
30
+ const hasBorder = borderWidth > 0 && !isTransparent(computed.borderColor);
31
+
32
+ if (!hasBackground && !hasBorder) continue;
33
+
34
+ // Size sanity: real buttons aren't huge or tiny
35
+ if (rect.height < 24 || rect.height > 100) continue;
36
+ if (rect.width < 40 || rect.width > 600) continue;
37
+
38
+ // Prefer above-the-fold
39
+ const aboveFold = rect.top < window.innerHeight;
40
+
41
+ // Score by prominence
42
+ let score = 0;
43
+ if (el.tagName === 'BUTTON') score += 30;
44
+ if (role === 'button') score += 20;
45
+ if (hasBackground) score += 20;
46
+ if (hasBorder && !hasBackground) score += 10;
47
+ if (aboveFold) score += 15;
48
+ if (rect.top < 300) score += 10;
49
+
50
+ // Skip buttons with no visible text
51
+ const text = el.textContent?.trim().replace(/\s+/g, ' ') || '';
52
+ if (text.length === 0) continue;
53
+
54
+ // Skip fully transparent backgrounds with no border (ghost-only inherited bg)
55
+ if (isTransparent(bg) && !hasBorder) continue;
56
+
57
+ results.push({
58
+ el,
59
+ score,
60
+ state: {
61
+ backgroundColor: bg,
62
+ color: computed.color,
63
+ padding: computed.padding,
64
+ borderRadius: computed.borderRadius,
65
+ border: `${computed.borderWidth} ${computed.borderStyle} ${computed.borderColor}`,
66
+ boxShadow: computed.boxShadow !== 'none' ? computed.boxShadow : undefined,
67
+ fontSize: computed.fontSize,
68
+ fontWeight: computed.fontWeight,
69
+ },
70
+ text: text.slice(0, 40),
71
+ });
72
+ } catch {}
73
+ }
113
74
 
114
- buttonStyles.push({
115
- states,
116
- fontWeight: computed.fontWeight,
117
- fontSize: computed.fontSize,
118
- classes: className.substring(0, 50),
119
- confidence: isHighConfidence ? "high" : "medium",
120
- });
121
- });
75
+ // Sort by score, deduplicate by visual fingerprint
76
+ results.sort((a, b) => b.score - a.score);
122
77
 
123
- const uniqueButtons = [];
124
78
  const seen = new Set();
125
- for (const btn of buttonStyles) {
126
- const s = btn.states.default;
127
- const key = `${s.backgroundColor}|${s.border}|${s.boxShadow}`;
79
+ const unique = [];
80
+ for (const r of results) {
81
+ const s = r.state;
82
+ const key = `${s.backgroundColor}|${s.color}|${s.borderRadius}|${s.border}`;
128
83
  if (!seen.has(key)) {
129
84
  seen.add(key);
130
- uniqueButtons.push(btn);
85
+ unique.push({
86
+ states: { default: s },
87
+ fontWeight: s.fontWeight,
88
+ fontSize: s.fontSize,
89
+ text: r.text,
90
+ confidence: r.score >= 40 ? 'high' : 'medium',
91
+ });
131
92
  }
93
+ if (unique.length >= 8) break;
132
94
  }
133
95
 
134
- return uniqueButtons.slice(0, 15);
96
+ return unique;
135
97
  });
136
98
  }
137
99
 
@@ -169,7 +169,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
169
169
 
170
170
  spinner.start("Analyzing design system (16 parallel tasks)...");
171
171
  const [
172
- { logo, favicons },
172
+ { logo, instances: logoInstances, favicons },
173
173
  colors,
174
174
  typography,
175
175
  spacing,
@@ -438,6 +438,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
438
438
  extractedAt: new Date().toISOString(),
439
439
  siteName,
440
440
  logo,
441
+ logoInstances,
441
442
  favicons,
442
443
  colors,
443
444
  typography,
@@ -32,216 +32,402 @@ export async function extractSiteName(page) {
32
32
  }
33
33
 
34
34
  export async function extractLogo(page, url) {
35
- return await page.evaluate((baseUrl) => {
36
- const candidates = Array.from(document.querySelectorAll("img, svg")).filter(
37
- (el) => {
38
- const className =
39
- typeof el.className === "string"
40
- ? el.className
41
- : el.className.baseVal || "";
42
- const attrs = (
43
- className +
44
- " " +
45
- (el.id || "") +
46
- " " +
47
- (el.getAttribute("alt") || "")
48
- ).toLowerCase();
49
-
50
- if (attrs.includes("logo") || attrs.includes("brand")) return true;
51
-
52
- if (el.tagName === "svg" || el.tagName === "SVG") {
53
- const useElements = el.querySelectorAll("use");
54
- for (const use of useElements) {
55
- const href =
56
- use.getAttribute("href") || use.getAttribute("xlink:href") || "";
57
- if (
58
- href.toLowerCase().includes("logo") ||
59
- href.toLowerCase().includes("brand")
60
- ) {
61
- return true;
62
- }
63
- }
64
- }
65
-
66
- const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="Header"], [id*="header"]');
67
- if (inHeader) {
68
- const parentLink = el.closest('a');
69
- if (parentLink) {
70
- const href = parentLink.getAttribute('href') || '';
71
- const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
72
- if (href === '/' || href === baseUrl || href === baseUrl + '/' ||
73
- href.match(/^https?:\/\/[^/]+\/?$/) ||
74
- href.match(/^https?:\/\/[^/]+\/[a-z]{2}(-[a-z]{2})?\/?$/) ||
75
- ariaLabel.includes('homepage') || ariaLabel.includes('home page')) {
76
- return true;
77
- }
78
- }
79
- }
35
+ // Extract manifest.json for PWA icons
36
+ const manifestIcons = await page.evaluate((baseUrl) => {
37
+ try {
38
+ const manifestLink = document.querySelector('link[rel="manifest"]');
39
+ if (!manifestLink) return [];
40
+ return [{ manifestUrl: new URL(manifestLink.getAttribute('href'), baseUrl).href }];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }, url);
80
45
 
81
- return false;
46
+ let pwaIcons = [];
47
+ if (manifestIcons.length > 0) {
48
+ try {
49
+ const manifestUrl = manifestIcons[0].manifestUrl;
50
+ const response = await page.evaluate(async (mUrl) => {
51
+ try {
52
+ const r = await fetch(mUrl);
53
+ if (!r.ok) return null;
54
+ return await r.json();
55
+ } catch { return null; }
56
+ }, manifestUrl);
57
+
58
+ if (response?.icons) {
59
+ pwaIcons = response.icons.map(icon => ({
60
+ type: 'pwa',
61
+ url: new URL(icon.src, url).href,
62
+ sizes: icon.sizes || null,
63
+ purpose: icon.purpose || 'any',
64
+ }));
82
65
  }
83
- );
84
-
85
- let logoData = null;
86
- if (candidates.length > 0) {
87
- const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
88
-
89
- const scored = candidates.map(el => {
90
- let score = 0;
91
- const rect = el.getBoundingClientRect();
92
- const parentLink = el.closest('a');
93
- const linkHref = parentLink?.getAttribute('href') || '';
94
- const imgSrc = el.tagName === 'IMG' ? (el.src || '') : '';
95
- const altText = (el.getAttribute('alt') || '').toLowerCase();
96
- const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
97
-
98
- const inHeader = el.closest('header, nav, [role="banner"], [class*="header"], [class*="nav"], [id*="header"], [id*="nav"]');
99
- if (inHeader) score += 50;
100
-
101
- if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) {
102
- score += 40;
103
- }
66
+ } catch {}
67
+ }
104
68
 
105
- if (parentLink) {
106
- const href = linkHref.toLowerCase();
107
- if (href === '/' || href === baseUrl || href === baseUrl + '/' || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
108
- score += 30;
109
- }
69
+ const result = await page.evaluate((baseUrl) => {
70
+ const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
71
+
72
+ // Canvas for background color detection
73
+ const canvas = document.createElement('canvas');
74
+ canvas.width = canvas.height = 1;
75
+ const ctx = canvas.getContext('2d');
76
+
77
+ function toHex(color) {
78
+ if (!color || color === 'transparent') return null;
79
+ try {
80
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
81
+ if (m) {
82
+ if (m[4] !== undefined && parseFloat(m[4]) < 0.1) return null;
83
+ return `#${parseInt(m[1]).toString(16).padStart(2,'0')}${parseInt(m[2]).toString(16).padStart(2,'0')}${parseInt(m[3]).toString(16).padStart(2,'0')}`;
110
84
  }
85
+ if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
86
+ if (!ctx) return null;
87
+ ctx.clearRect(0, 0, 1, 1);
88
+ ctx.fillStyle = 'rgba(0,0,0,0)';
89
+ ctx.fillStyle = color;
90
+ ctx.fillRect(0, 0, 1, 1);
91
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
92
+ if (a < 25) return null;
93
+ return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
94
+ } catch { return null; }
95
+ }
111
96
 
112
- if (rect.top < 200) score += 10;
113
- if (rect.left < 400) score += 10;
114
- if (rect.top > 600) score -= 20;
97
+ function findBgColor(el) {
98
+ let node = el;
99
+ while (node && node.tagName !== 'HTML') {
100
+ try {
101
+ const bg = toHex(getComputedStyle(node).backgroundColor);
102
+ if (bg) return bg;
103
+ } catch {}
104
+ node = node.parentElement;
105
+ }
106
+ return null;
107
+ }
115
108
 
116
- const width = el.naturalWidth || el.width?.baseVal?.value || rect.width;
117
- const height = el.naturalHeight || el.height?.baseVal?.value || rect.height;
118
- if (width < 20 || height < 20) score -= 30;
119
- if (width > 500 || height > 300) score -= 40;
109
+ function isLight(hex) {
110
+ if (!hex) return true;
111
+ const r = parseInt(hex.slice(1,3), 16);
112
+ const g = parseInt(hex.slice(3,5), 16);
113
+ const b = parseInt(hex.slice(5,7), 16);
114
+ return (0.299*r + 0.587*g + 0.114*b) / 255 > 0.5;
115
+ }
120
116
 
121
- if (altText.length > 50) score -= 30;
122
- if (altText.includes(' the ') || altText.includes(' a ') || altText.includes(' of ')) score -= 20;
117
+ function detectLogoType(el, altText) {
118
+ const text = (altText || '').toLowerCase().trim();
119
+ const hasText = text.length > 0 && !/^logo$|^brand$|^icon$/i.test(text);
120
+ const rect = el.getBoundingClientRect();
121
+ const ratio = rect.width / (rect.height || 1);
122
+
123
+ // Wide element with text in alt = wordmark
124
+ if (hasText && ratio > 3) return 'wordmark';
125
+ // Squarish = logomark/icon
126
+ if (ratio < 1.5 && ratio > 0.5) return 'logomark';
127
+ // Wide with no meaningful alt = likely combination or wordmark
128
+ if (ratio > 2) return 'wordmark';
129
+ return 'combination';
130
+ }
123
131
 
124
- if (width > height && width < 300 && width > 40 && height > 15 && height < 100) score += 15;
132
+ function scoreLogo(el, context) {
133
+ let score = 0;
134
+ const rect = el.getBoundingClientRect();
135
+ const s = getComputedStyle(el);
136
+ const parentLink = el.closest('a');
137
+ const linkHref = parentLink?.getAttribute('href') || '';
138
+ const imgSrc = el.tagName === 'IMG' ? (el.getAttribute('src') || '') : '';
139
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
140
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
141
+
142
+ if (context === 'header') score += 50;
143
+ if (context === 'footer') score += 20;
144
+ if (context === 'hero') score += 15;
145
+
146
+ if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) score += 40;
147
+ if (className.includes('logo') || el.id?.toLowerCase().includes('logo')) score += 30;
148
+
149
+ // SVG with aria-label="Homepage" directly in a home anchor — strong signal
150
+ if (el.tagName === 'svg' || el.tagName === 'SVG') {
151
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
152
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo')) score += 40;
153
+ }
125
154
 
126
- if (!inHeader && !imgSrc.toLowerCase().includes(siteDomain) && !altText.includes(siteDomain)) {
127
- score -= 30;
155
+ if (parentLink) {
156
+ const href = linkHref.toLowerCase();
157
+ if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
158
+ score += 30;
128
159
  }
160
+ }
129
161
 
130
- return { el, score };
131
- });
162
+ if (rect.top < 200) score += 10;
163
+ if (rect.left < 400) score += 10;
164
+
165
+ // Penalize images hosted on a different domain (CDN paths on same domain are fine)
166
+ if (el.tagName === 'IMG') {
167
+ try {
168
+ const srcHost = new URL(el.src).hostname.replace('www.', '')
169
+ const pageHost = new URL(baseUrl).hostname.replace('www.', '')
170
+ // If neither is a subdomain/CDN of the other, it's third-party
171
+ if (!srcHost.endsWith(pageHost) && !pageHost.endsWith(srcHost)) score -= 60
172
+ } catch {}
173
+ }
132
174
 
133
- scored.sort((a, b) => b.score - a.score);
134
- const logo = scored[0].el;
135
- const computed = window.getComputedStyle(logo);
136
- const parent = logo.parentElement;
137
- const parentComputed = parent ? window.getComputedStyle(parent) : null;
175
+ // For SVGs use rendered rect — baseVal reflects viewBox coordinates, not display size
176
+ const width = el.tagName === 'IMG' ? (el.naturalWidth || rect.width) : rect.width;
177
+ const height = el.tagName === 'IMG' ? (el.naturalHeight || rect.height) : rect.height;
178
+ if (width < 20 || height < 20) score -= 30;
179
+ if (width > 600 || height > 400) score -= 40;
180
+ if (altText.length > 50) score -= 30;
181
+ if (width > height && width < 400 && width > 40 && height > 10 && height < 120) score += 15;
182
+
183
+ return score;
184
+ }
185
+
186
+ function extractLogoFromEl(el, context, baseUrl) {
187
+ const rect = el.getBoundingClientRect();
188
+ const computed = getComputedStyle(el);
189
+ const parent = el.parentElement;
190
+ const parentComputed = parent ? getComputedStyle(parent) : null;
191
+ const parentLink = el.closest('a');
192
+ const bg = findBgColor(el);
193
+ const altText = el.getAttribute('alt') || '';
138
194
 
139
195
  const safeZone = {
140
- top:
141
- parseFloat(computed.marginTop) +
142
- (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
143
- right:
144
- parseFloat(computed.marginRight) +
145
- (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
146
- bottom:
147
- parseFloat(computed.marginBottom) +
148
- (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
149
- left:
150
- parseFloat(computed.marginLeft) +
151
- (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
196
+ top: parseFloat(computed.marginTop) + (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
197
+ right: parseFloat(computed.marginRight) + (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
198
+ bottom: parseFloat(computed.marginBottom) + (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
199
+ left: parseFloat(computed.marginLeft) + (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
152
200
  };
153
201
 
154
- let logoBg = null;
155
- let walker = logo;
156
- while (walker) {
157
- const bg = window.getComputedStyle(walker).backgroundColor;
158
- if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
159
- logoBg = bg;
160
- break;
202
+ const logoType = detectLogoType(el, altText);
203
+ const reversed = bg ? !isLight(bg) : false;
204
+
205
+ if (el.tagName === 'IMG') {
206
+ // Handle picture element -- prefer highest-res source
207
+ const picture = el.closest('picture');
208
+ let src = el.src;
209
+ if (picture) {
210
+ const sources = picture.querySelectorAll('source');
211
+ for (const source of sources) {
212
+ const srcset = source.getAttribute('srcset');
213
+ if (srcset) {
214
+ const best = srcset.split(',').map(s => s.trim().split(/\s+/)).sort((a, b) => {
215
+ const wa = parseFloat(a[1]) || 0;
216
+ const wb = parseFloat(b[1]) || 0;
217
+ return wb - wa;
218
+ })[0];
219
+ if (best?.[0]) { src = new URL(best[0], baseUrl).href; break; }
220
+ }
221
+ }
161
222
  }
162
- walker = walker.parentElement;
163
- }
164
223
 
165
- if (logo.tagName === "IMG") {
166
- logoData = {
167
- source: "img",
168
- url: new URL(logo.src, baseUrl).href,
169
- width: logo.naturalWidth || logo.width,
170
- height: logo.naturalHeight || logo.height,
171
- alt: logo.alt,
224
+ return {
225
+ source: 'img',
226
+ context,
227
+ url: new URL(src, baseUrl).href,
228
+ width: el.naturalWidth || rect.width,
229
+ height: el.naturalHeight || rect.height,
230
+ alt: altText,
231
+ type: logoType,
232
+ reversed,
233
+ background: bg,
172
234
  safeZone,
173
- background: logoBg,
235
+ position: { top: rect.top, left: rect.left },
174
236
  };
175
- } else {
176
- const parentLink = logo.closest("a");
177
- logoData = {
178
- source: "svg",
179
- url: parentLink ? parentLink.href : window.location.href,
180
- width: logo.width?.baseVal?.value,
181
- height: logo.height?.baseVal?.value,
237
+ } else if (el.tagName === 'SVG' || el.tagName === 'svg') {
238
+ return {
239
+ source: 'svg',
240
+ context,
241
+ url: parentLink ? parentLink.href : baseUrl,
242
+ width: el.width?.baseVal?.value || rect.width,
243
+ height: el.height?.baseVal?.value || rect.height,
244
+ type: logoType,
245
+ reversed,
246
+ background: bg,
182
247
  safeZone,
183
- background: logoBg,
248
+ position: { top: rect.top, left: rect.left },
184
249
  };
185
250
  }
251
+ return null;
186
252
  }
187
253
 
188
- const favicons = [];
254
+ function findLogosInZone(container, context) {
255
+ if (!container) return [];
256
+ const candidates = [];
257
+
258
+ // img and svg
259
+ container.querySelectorAll('img, svg').forEach(el => {
260
+ try {
261
+ const s = getComputedStyle(el);
262
+ if (s.display === 'none' || s.visibility === 'hidden') return;
263
+ const rect = el.getBoundingClientRect();
264
+ if (rect.width === 0 || rect.height === 0) return;
265
+
266
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
267
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
268
+ const attrs = (className + ' ' + (el.id || '') + ' ' + altText).toLowerCase();
269
+
270
+ // Disqualify third-party brand logos: alt like "Notion logo" / "Perplexity logo" / "Figma" where the brand isn't our site
271
+ // These appear in customer/integration/testimonial sections on marketing pages.
272
+ const altBrandMatch = altText.match(/^([a-z][a-z0-9\-\.]{1,30}?)(?:\s+(?:logo|icon|brand|wordmark))?$/i);
273
+ if (altBrandMatch) {
274
+ const altBrand = altBrandMatch[1].replace(/[\s\-\.]/g, '').toLowerCase();
275
+ if (altBrand && altBrand !== 'logo' && altBrand !== 'brand' && altBrand !== 'icon' && altBrand !== siteDomain && !altBrand.includes(siteDomain) && !siteDomain.includes(altBrand)) {
276
+ return; // third-party brand, skip entirely
277
+ }
278
+ }
189
279
 
190
- document.querySelectorAll('link[rel*="icon"]').forEach((link) => {
191
- const href = link.getAttribute("href");
192
- if (href) {
193
- favicons.push({
194
- type: link.getAttribute("rel"),
195
- url: new URL(href, baseUrl).href,
196
- sizes: link.getAttribute("sizes") || null,
197
- });
280
+ let qualifies = attrs.includes('logo') || attrs.includes('brand');
281
+
282
+ if (!qualifies && el.tagName === 'svg') {
283
+ const useEls = el.querySelectorAll('use');
284
+ for (const use of useEls) {
285
+ const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
286
+ if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) { qualifies = true; break; }
287
+ }
288
+ // aria-label="Homepage" or similar on the SVG itself
289
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
290
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo')) qualifies = true;
291
+ }
292
+
293
+ if (!qualifies) {
294
+ const parentLink = el.closest('a');
295
+ if (parentLink) {
296
+ const href = (parentLink.getAttribute('href') || '').toLowerCase();
297
+ const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
298
+ if (href === '/' || href.match(/^https?:\/\/[^/]+\/?$/) || ariaLabel.includes('home')) qualifies = true;
299
+ }
300
+ }
301
+
302
+ if (qualifies) {
303
+ const score = scoreLogo(el, context);
304
+ candidates.push({ el, score, context });
305
+ }
306
+ } catch {}
307
+ });
308
+
309
+ // CSS background-image logos
310
+ container.querySelectorAll('a, [class*="logo"], [id*="logo"], header > *, nav > *').forEach(el => {
311
+ try {
312
+ const s = getComputedStyle(el);
313
+ const bg = s.backgroundImage;
314
+ if (!bg || bg === 'none') return;
315
+ const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
316
+ if (!urlMatch) return;
317
+ const imgUrl = urlMatch[1];
318
+ if (!/\.(svg|png|webp|gif)(\?|$)/i.test(imgUrl) && !imgUrl.includes('logo') && !imgUrl.includes('brand')) return;
319
+ const rect = el.getBoundingClientRect();
320
+ if (rect.width === 0 || rect.height === 0) return;
321
+
322
+ candidates.push({
323
+ el: null,
324
+ score: scoreLogo(el, context) + 10,
325
+ context,
326
+ cssBackground: {
327
+ source: 'css-background',
328
+ context,
329
+ url: new URL(imgUrl, window.location.href).href,
330
+ width: rect.width,
331
+ height: rect.height,
332
+ type: rect.width / rect.height > 2 ? 'wordmark' : 'logomark',
333
+ reversed: !isLight(toHex(s.backgroundColor)),
334
+ background: toHex(s.backgroundColor),
335
+ safeZone: { top: parseFloat(s.paddingTop), right: parseFloat(s.paddingRight), bottom: parseFloat(s.paddingBottom), left: parseFloat(s.paddingLeft) },
336
+ position: { top: rect.top, left: rect.left },
337
+ }
338
+ });
339
+ } catch {}
340
+ });
341
+
342
+ return candidates;
343
+ }
344
+
345
+ const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]');
346
+ const navEl = document.querySelector('nav, [role="navigation"]');
347
+ const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
348
+
349
+ // Hero: first large section that's not header/footer
350
+ const heroEl = (() => {
351
+ const sections = document.querySelectorAll('main > *:first-child, [class*="hero"], [class*="Hero"], [class*="banner"]:not([role="banner"])');
352
+ for (const s of sections) {
353
+ const rect = s.getBoundingClientRect();
354
+ if (rect.height > 200) return s;
198
355
  }
199
- });
356
+ return null;
357
+ })();
358
+
359
+ // Deduplicate zones — nav might be inside header, avoid double-scanning
360
+ const headerCandidates = findLogosInZone(headerEl, 'header');
361
+ const navCandidates = (navEl && !headerEl?.contains(navEl))
362
+ ? findLogosInZone(navEl, 'header') // same score weight as header
363
+ : [];
364
+
365
+ const allCandidates = [
366
+ ...headerCandidates,
367
+ ...navCandidates,
368
+ ...findLogosInZone(footerEl, 'footer'),
369
+ ...findLogosInZone(heroEl, 'hero'),
370
+ ];
371
+
372
+ allCandidates.sort((a, b) => b.score - a.score);
373
+
374
+ // Primary logo = highest scoring
375
+ const primary = allCandidates[0];
376
+ let primaryLogo = null;
377
+ if (primary) {
378
+ if (primary.cssBackground) {
379
+ primaryLogo = primary.cssBackground;
380
+ } else if (primary.el) {
381
+ primaryLogo = extractLogoFromEl(primary.el, primary.context, baseUrl);
382
+ }
383
+ }
200
384
 
201
- document.querySelectorAll('link[rel="apple-touch-icon"]').forEach((link) => {
202
- const href = link.getAttribute("href");
385
+ // Collect all unique instances (header, footer, hero)
386
+ const instances = [];
387
+ const seenContexts = new Set();
388
+ for (const c of allCandidates) {
389
+ if (seenContexts.has(c.context)) continue;
390
+ seenContexts.add(c.context);
391
+ let inst = null;
392
+ if (c.cssBackground) inst = c.cssBackground;
393
+ else if (c.el) inst = extractLogoFromEl(c.el, c.context, baseUrl);
394
+ if (inst) instances.push(inst);
395
+ }
396
+
397
+ // Favicons
398
+ const favicons = [];
399
+ document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]').forEach(link => {
400
+ const href = link.getAttribute('href');
203
401
  if (href) {
204
- favicons.push({
205
- type: "apple-touch-icon",
206
- url: new URL(href, baseUrl).href,
207
- sizes: link.getAttribute("sizes") || null,
208
- });
402
+ try {
403
+ favicons.push({
404
+ type: link.getAttribute('rel'),
405
+ url: new URL(href, baseUrl).href,
406
+ sizes: link.getAttribute('sizes') || null,
407
+ });
408
+ } catch {}
209
409
  }
210
410
  });
211
411
 
212
412
  const ogImage = document.querySelector('meta[property="og:image"]');
213
- if (ogImage) {
214
- const content = ogImage.getAttribute("content");
215
- if (content) {
216
- favicons.push({
217
- type: "og:image",
218
- url: new URL(content, baseUrl).href,
219
- sizes: null,
220
- });
221
- }
413
+ if (ogImage?.getAttribute('content')) {
414
+ try { favicons.push({ type: 'og:image', url: new URL(ogImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
222
415
  }
223
416
 
224
417
  const twitterImage = document.querySelector('meta[name="twitter:image"]');
225
- if (twitterImage) {
226
- const content = twitterImage.getAttribute("content");
227
- if (content) {
228
- favicons.push({
229
- type: "twitter:image",
230
- url: new URL(content, baseUrl).href,
231
- sizes: null,
232
- });
233
- }
418
+ if (twitterImage?.getAttribute('content')) {
419
+ try { favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
234
420
  }
235
421
 
236
- const hasFaviconIco = favicons.some((f) => f.url.endsWith("/favicon.ico"));
237
- if (!hasFaviconIco) {
238
- favicons.push({
239
- type: "favicon.ico",
240
- url: new URL("/favicon.ico", baseUrl).href,
241
- sizes: null,
242
- });
422
+ if (!favicons.some(f => f.url.endsWith('/favicon.ico'))) {
423
+ favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
243
424
  }
244
425
 
245
- return { logo: logoData, favicons };
426
+ return { logo: primaryLogo, instances, favicons };
246
427
  }, url);
428
+
429
+ // Merge PWA icons into favicons
430
+ result.favicons = [...result.favicons, ...pwaIcons];
431
+
432
+ return result;
247
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Extract design tokens and publicly visible CSS information from any website",
5
5
  "mcpName": "io.github.dembrandt/dembrandt",
6
6
  "main": "index.js",