dembrandt 0.12.1 → 0.12.2

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,371 @@ 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
- }
110
- }
69
+ const result = await page.evaluate((baseUrl) => {
70
+ const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
111
71
 
112
- if (rect.top < 200) score += 10;
113
- if (rect.left < 400) score += 10;
114
- if (rect.top > 600) score -= 20;
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')}`;
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
+ }
115
96
 
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;
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
+ }
120
108
 
121
- if (altText.length > 50) score -= 30;
122
- if (altText.includes(' the ') || altText.includes(' a ') || altText.includes(' of ')) score -= 20;
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
+ }
123
116
 
124
- if (width > height && width < 300 && width > 40 && height > 15 && height < 100) score += 15;
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
+ }
125
131
 
126
- if (!inHeader && !imgSrc.toLowerCase().includes(siteDomain) && !altText.includes(siteDomain)) {
127
- score -= 30;
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
+ if (parentLink) {
150
+ const href = linkHref.toLowerCase();
151
+ if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
152
+ score += 30;
128
153
  }
154
+ }
129
155
 
130
- return { el, score };
131
- });
156
+ if (rect.top < 200) score += 10;
157
+ if (rect.left < 400) score += 10;
132
158
 
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;
159
+ const width = el.naturalWidth || el.width?.baseVal?.value || rect.width;
160
+ const height = el.naturalHeight || el.height?.baseVal?.value || rect.height;
161
+ if (width < 20 || height < 20) score -= 30;
162
+ if (width > 600 || height > 400) score -= 40;
163
+ if (altText.length > 50) score -= 30;
164
+ if (width > height && width < 400 && width > 40 && height > 10 && height < 120) score += 15;
165
+
166
+ return score;
167
+ }
168
+
169
+ function extractLogoFromEl(el, context, baseUrl) {
170
+ const rect = el.getBoundingClientRect();
171
+ const computed = getComputedStyle(el);
172
+ const parent = el.parentElement;
173
+ const parentComputed = parent ? getComputedStyle(parent) : null;
174
+ const parentLink = el.closest('a');
175
+ const bg = findBgColor(el);
176
+ const altText = el.getAttribute('alt') || '';
138
177
 
139
178
  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),
179
+ top: parseFloat(computed.marginTop) + (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
180
+ right: parseFloat(computed.marginRight) + (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
181
+ bottom: parseFloat(computed.marginBottom) + (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
182
+ left: parseFloat(computed.marginLeft) + (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
152
183
  };
153
184
 
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;
185
+ const logoType = detectLogoType(el, altText);
186
+ const reversed = bg ? !isLight(bg) : false;
187
+
188
+ if (el.tagName === 'IMG') {
189
+ // Handle picture element -- prefer highest-res source
190
+ const picture = el.closest('picture');
191
+ let src = el.src;
192
+ if (picture) {
193
+ const sources = picture.querySelectorAll('source');
194
+ for (const source of sources) {
195
+ const srcset = source.getAttribute('srcset');
196
+ if (srcset) {
197
+ const best = srcset.split(',').map(s => s.trim().split(/\s+/)).sort((a, b) => {
198
+ const wa = parseFloat(a[1]) || 0;
199
+ const wb = parseFloat(b[1]) || 0;
200
+ return wb - wa;
201
+ })[0];
202
+ if (best?.[0]) { src = new URL(best[0], baseUrl).href; break; }
203
+ }
204
+ }
161
205
  }
162
- walker = walker.parentElement;
163
- }
164
206
 
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,
207
+ return {
208
+ source: 'img',
209
+ context,
210
+ url: new URL(src, baseUrl).href,
211
+ width: el.naturalWidth || rect.width,
212
+ height: el.naturalHeight || rect.height,
213
+ alt: altText,
214
+ type: logoType,
215
+ reversed,
216
+ background: bg,
172
217
  safeZone,
173
- background: logoBg,
218
+ position: { top: rect.top, left: rect.left },
174
219
  };
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,
220
+ } else if (el.tagName === 'SVG' || el.tagName === 'svg') {
221
+ return {
222
+ source: 'svg',
223
+ context,
224
+ url: parentLink ? parentLink.href : baseUrl,
225
+ width: el.width?.baseVal?.value || rect.width,
226
+ height: el.height?.baseVal?.value || rect.height,
227
+ type: logoType,
228
+ reversed,
229
+ background: bg,
182
230
  safeZone,
183
- background: logoBg,
231
+ position: { top: rect.top, left: rect.left },
184
232
  };
185
233
  }
234
+ return null;
186
235
  }
187
236
 
188
- const favicons = [];
237
+ function findLogosInZone(container, context) {
238
+ if (!container) return [];
239
+ const candidates = [];
189
240
 
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
- });
241
+ // img and svg
242
+ container.querySelectorAll('img, svg').forEach(el => {
243
+ try {
244
+ const s = getComputedStyle(el);
245
+ if (s.display === 'none' || s.visibility === 'hidden') return;
246
+ const rect = el.getBoundingClientRect();
247
+ if (rect.width === 0 || rect.height === 0) return;
248
+
249
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
250
+ const attrs = (className + ' ' + (el.id || '') + ' ' + (el.getAttribute('alt') || '')).toLowerCase();
251
+
252
+ let qualifies = attrs.includes('logo') || attrs.includes('brand');
253
+
254
+ if (!qualifies && el.tagName === 'svg') {
255
+ const useEls = el.querySelectorAll('use');
256
+ for (const use of useEls) {
257
+ const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
258
+ if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) { qualifies = true; break; }
259
+ }
260
+ }
261
+
262
+ if (!qualifies) {
263
+ const parentLink = el.closest('a');
264
+ if (parentLink) {
265
+ const href = (parentLink.getAttribute('href') || '').toLowerCase();
266
+ const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
267
+ if (href === '/' || href.match(/^https?:\/\/[^/]+\/?$/) || ariaLabel.includes('home')) qualifies = true;
268
+ }
269
+ }
270
+
271
+ if (qualifies) {
272
+ const score = scoreLogo(el, context);
273
+ candidates.push({ el, score, context });
274
+ }
275
+ } catch {}
276
+ });
277
+
278
+ // CSS background-image logos
279
+ container.querySelectorAll('a, [class*="logo"], [id*="logo"], header > *, nav > *').forEach(el => {
280
+ try {
281
+ const s = getComputedStyle(el);
282
+ const bg = s.backgroundImage;
283
+ if (!bg || bg === 'none') return;
284
+ const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
285
+ if (!urlMatch) return;
286
+ const imgUrl = urlMatch[1];
287
+ if (!/\.(svg|png|webp|gif)(\?|$)/i.test(imgUrl) && !imgUrl.includes('logo') && !imgUrl.includes('brand')) return;
288
+ const rect = el.getBoundingClientRect();
289
+ if (rect.width === 0 || rect.height === 0) return;
290
+
291
+ candidates.push({
292
+ el: null,
293
+ score: scoreLogo(el, context) + 10,
294
+ context,
295
+ cssBackground: {
296
+ source: 'css-background',
297
+ context,
298
+ url: new URL(imgUrl, window.location.href).href,
299
+ width: rect.width,
300
+ height: rect.height,
301
+ type: rect.width / rect.height > 2 ? 'wordmark' : 'logomark',
302
+ reversed: !isLight(toHex(s.backgroundColor)),
303
+ background: toHex(s.backgroundColor),
304
+ safeZone: { top: parseFloat(s.paddingTop), right: parseFloat(s.paddingRight), bottom: parseFloat(s.paddingBottom), left: parseFloat(s.paddingLeft) },
305
+ position: { top: rect.top, left: rect.left },
306
+ }
307
+ });
308
+ } catch {}
309
+ });
310
+
311
+ return candidates;
312
+ }
313
+
314
+ const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]');
315
+ const navEl = document.querySelector('nav, [role="navigation"]');
316
+ const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
317
+
318
+ // Hero: first large section that's not header/footer
319
+ const heroEl = (() => {
320
+ const sections = document.querySelectorAll('main > *:first-child, [class*="hero"], [class*="Hero"], [class*="banner"]:not([role="banner"])');
321
+ for (const s of sections) {
322
+ const rect = s.getBoundingClientRect();
323
+ if (rect.height > 200) return s;
198
324
  }
199
- });
325
+ return null;
326
+ })();
327
+
328
+ // Deduplicate zones — nav might be inside header, avoid double-scanning
329
+ const headerCandidates = findLogosInZone(headerEl, 'header');
330
+ const navCandidates = (navEl && !headerEl?.contains(navEl))
331
+ ? findLogosInZone(navEl, 'header') // same score weight as header
332
+ : [];
333
+
334
+ const allCandidates = [
335
+ ...headerCandidates,
336
+ ...navCandidates,
337
+ ...findLogosInZone(footerEl, 'footer'),
338
+ ...findLogosInZone(heroEl, 'hero'),
339
+ ];
340
+
341
+ allCandidates.sort((a, b) => b.score - a.score);
342
+
343
+ // Primary logo = highest scoring
344
+ const primary = allCandidates[0];
345
+ let primaryLogo = null;
346
+ if (primary) {
347
+ if (primary.cssBackground) {
348
+ primaryLogo = primary.cssBackground;
349
+ } else if (primary.el) {
350
+ primaryLogo = extractLogoFromEl(primary.el, primary.context, baseUrl);
351
+ }
352
+ }
353
+
354
+ // Collect all unique instances (header, footer, hero)
355
+ const instances = [];
356
+ const seenContexts = new Set();
357
+ for (const c of allCandidates) {
358
+ if (seenContexts.has(c.context)) continue;
359
+ seenContexts.add(c.context);
360
+ let inst = null;
361
+ if (c.cssBackground) inst = c.cssBackground;
362
+ else if (c.el) inst = extractLogoFromEl(c.el, c.context, baseUrl);
363
+ if (inst) instances.push(inst);
364
+ }
200
365
 
201
- document.querySelectorAll('link[rel="apple-touch-icon"]').forEach((link) => {
202
- const href = link.getAttribute("href");
366
+ // Favicons
367
+ const favicons = [];
368
+ document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]').forEach(link => {
369
+ const href = link.getAttribute('href');
203
370
  if (href) {
204
- favicons.push({
205
- type: "apple-touch-icon",
206
- url: new URL(href, baseUrl).href,
207
- sizes: link.getAttribute("sizes") || null,
208
- });
371
+ try {
372
+ favicons.push({
373
+ type: link.getAttribute('rel'),
374
+ url: new URL(href, baseUrl).href,
375
+ sizes: link.getAttribute('sizes') || null,
376
+ });
377
+ } catch {}
209
378
  }
210
379
  });
211
380
 
212
381
  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
- }
382
+ if (ogImage?.getAttribute('content')) {
383
+ try { favicons.push({ type: 'og:image', url: new URL(ogImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
222
384
  }
223
385
 
224
386
  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
- }
387
+ if (twitterImage?.getAttribute('content')) {
388
+ try { favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
234
389
  }
235
390
 
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
- });
391
+ if (!favicons.some(f => f.url.endsWith('/favicon.ico'))) {
392
+ favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
243
393
  }
244
394
 
245
- return { logo: logoData, favicons };
395
+ return { logo: primaryLogo, instances, favicons };
246
396
  }, url);
397
+
398
+ // Merge PWA icons into favicons
399
+ result.favicons = [...result.favicons, ...pwaIcons];
400
+
401
+ return result;
247
402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
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",