dembrandt 0.11.0 → 0.12.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,7 @@
6
6
 
7
7
  Extract a website's design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command.
8
8
 
9
- ![Dembrandt Any website to design tokens](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/banner.png)
9
+ ![Dembrandt: Any website to design tokens](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/banner.png)
10
10
 
11
11
  ## Install
12
12
 
@@ -77,7 +77,7 @@ Default: formatted terminal display only. Use `--save-output` to persist results
77
77
 
78
78
  ### Multi-Page Extraction
79
79
 
80
- Analyze multiple pages to get a more complete picture of a site's design system. Results are merged into a single unified output with cross-page confidence boosting tokens appearing on multiple pages get higher confidence scores.
80
+ Analyze multiple pages to get a more complete picture of a site's design system. Results are merged into a single unified output with cross-page confidence boosting: tokens appearing on multiple pages get higher confidence scores.
81
81
 
82
82
  ```bash
83
83
  # Analyze homepage + 4 auto-discovered pages (default: 5 total)
@@ -133,7 +133,7 @@ The DTCG format is an industry-standard JSON schema that can be consumed by desi
133
133
 
134
134
  ### DESIGN.md
135
135
 
136
- Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file a plain-text design system document readable by AI agents.
136
+ Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file, a plain-text design system document readable by AI agents.
137
137
 
138
138
  ```bash
139
139
  dembrandt example.com --design-md
@@ -142,7 +142,7 @@ dembrandt example.com --design-md
142
142
 
143
143
  ### Brand Guide PDF
144
144
 
145
- Use `--brand-guide` to generate a printable PDF summarizing the extracted design system colors, typography, components, and logo on a single document.
145
+ Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
146
146
 
147
147
  ```bash
148
148
  dembrandt example.com --brand-guide
@@ -176,10 +176,63 @@ Opens http://localhost:5173 with API on port 3002.
176
176
  - Spacing, shadows, border radius visualization
177
177
  - Button and link component previews
178
178
  - Dark/light theme toggle
179
- - Section nav links on extraction pages jump directly to Colors, Typography, Shadows, etc. via a sticky sidebar
179
+ - Section nav links on extraction pages, jump directly to Colors, Typography, Shadows, etc. via a sticky sidebar
180
180
 
181
181
  Extractions are performed via CLI (`dembrandt <url> --save-output`) and automatically appear in the UI.
182
182
 
183
+ ## Recipes
184
+
185
+ **Quick brand scan**
186
+ ```bash
187
+ dembrandt stripe.com
188
+ ```
189
+
190
+ **Compare two sites**
191
+ ```bash
192
+ dembrandt stripe.com --save-output
193
+ dembrandt braintree.com --save-output
194
+ # Compare output/stripe.com and output/braintree.com side by side
195
+ ```
196
+
197
+ **Multi-page audit** — get a fuller picture across the whole site
198
+ ```bash
199
+ dembrandt stripe.com --pages 10 --sitemap --save-output
200
+ ```
201
+
202
+ **Spot-check a value** — verify a specific token fast
203
+ ```bash
204
+ dembrandt stripe.com --json-only | grep -i "border-radius"
205
+ ```
206
+
207
+ **Export for Tailwind** — get spacing and color values into your config
208
+ ```bash
209
+ dembrandt stripe.com --dtcg --save-output
210
+ # Use the .tokens.json with Style Dictionary to generate tailwind.config.js
211
+ ```
212
+
213
+ **Export for Tokens Studio / Figma**
214
+ ```bash
215
+ dembrandt stripe.com --dtcg --save-output
216
+ # Import the .tokens.json directly into Tokens Studio
217
+ ```
218
+
219
+ **Generate DESIGN.md for your AI agent**
220
+ ```bash
221
+ dembrandt stripe.com --design-md
222
+ # Point your agent at the output DESIGN.md
223
+ ```
224
+
225
+ **Regression baseline** — snapshot now, catch drift later
226
+ ```bash
227
+ dembrandt myapp.com --save-output --dtcg
228
+ # Store output as baseline, re-run after deploys and diff
229
+ ```
230
+
231
+ **CI / headless environments**
232
+ ```bash
233
+ dembrandt myapp.com --no-sandbox --save-output
234
+ ```
235
+
183
236
  ## Use Cases
184
237
 
185
238
  - Design system documentation
@@ -204,9 +257,9 @@ Uses Playwright to render the page, reads computed styles from the DOM, analyzes
204
257
 
205
258
  ### Color Confidence
206
259
 
207
- - High Logo, primary interactive elements
208
- - Medium Secondary interactive elements, icons, navigation
209
- - Low Generic UI components (filtered from display)
260
+ - High: Logo, primary interactive elements
261
+ - Medium: Secondary interactive elements, icons, navigation
262
+ - Low: Generic UI components (filtered from display)
210
263
  - Only shows high and medium confidence colors in terminal. Full palette in JSON.
211
264
 
212
265
  ## Limitations
@@ -228,12 +281,10 @@ Dembrandt does not host, redistribute, or claim rights to any third-party brand
228
281
 
229
282
  ## Contributing
230
283
 
231
- Bugs, weird sites, pull requests all welcome.
284
+ Bugs, weird sites, pull requests. All welcome.
232
285
 
233
286
  Open an [Issue](https://github.com/dembrandt/dembrandt/issues) or PR.
234
287
 
235
288
  @thevangelist
236
289
 
237
- ---
238
-
239
- MIT — do whatever you want with it.
290
+ MIT. Do whatever you want with it.
package/index.js CHANGED
@@ -11,23 +11,27 @@ import { program } from "commander";
11
11
  import chalk from "chalk";
12
12
  import ora from "ora";
13
13
  import { chromium, firefox } from "playwright-core";
14
- import { extractBranding } from "./lib/extractors.js";
15
- import { displayResults } from "./lib/display.js";
16
- import { toW3CFormat } from "./lib/w3c-exporter.js";
17
- import { generatePDF } from "./lib/pdf.js";
18
- import { generateDesignMd } from "./lib/design-md.js";
14
+ import { extractBranding } from "./lib/extractors/index.js";
15
+ import { displayResults } from "./lib/formatters/terminal.js";
16
+ import { toW3CFormat } from "./lib/formatters/w3c.js";
17
+ import { generatePDF } from "./lib/formatters/pdf.js";
18
+ import { generateDesignMd } from "./lib/formatters/markdown.js";
19
19
  import { parseSitemap } from "./lib/discovery.js";
20
20
  import { mergeResults } from "./lib/merger.js";
21
- import { writeFileSync, mkdirSync } from "fs";
22
- import { join } from "path";
21
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
22
+ import { join, dirname } from "path";
23
+ import { fileURLToPath } from "url";
23
24
  import { checkRobotsTxt } from "./lib/robots.js";
24
25
 
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const { version } = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
28
+
25
29
  program
26
30
  .name("dembrandt")
27
31
  .description("Extract design tokens from any website")
28
- .version("0.11.0")
32
+ .version(version)
29
33
  .argument("<url>")
30
- .option("--browser <type>", "Browser to use (chromium|firefox)", "chromium")
34
+ .option("--browser <type>", "Browser to use (chromium|firefox); set BROWSER_CDP_ENDPOINT env var to connect to an existing Chromium instance via CDP", "chromium")
31
35
  .option("--json-only", "Output raw JSON")
32
36
  .option("--save-output", "Save JSON file to output folder")
33
37
  .option("--dtcg", "Export in W3C Design Tokens (DTCG) format")
@@ -39,6 +43,7 @@ program
39
43
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
40
44
  .option("--raw-colors", "Include pre-filter raw colors in JSON output")
41
45
  .option("--screenshot <path>", "Save a screenshot of the page")
46
+ .option("--wcag", "Analyze WCAG contrast ratios between palette colors")
42
47
  .option("--pages <n>", "Analyze up to N total pages including start URL (default: 5)", (v) => {
43
48
  const n = parseInt(v, 10);
44
49
  if (isNaN(n) || n < 1) throw new Error(`--pages must be a positive integer, got: ${v}`);
@@ -93,10 +98,18 @@ program
93
98
  if (opts.noSandbox && opts.browser === 'chromium') {
94
99
  launchArgs.push("--no-sandbox", "--disable-setuid-sandbox");
95
100
  }
96
- browser = await browserType.launch({
97
- headless: !useHeaded,
98
- args: launchArgs,
99
- });
101
+ if (process.env.BROWSER_CDP_ENDPOINT) {
102
+ if (opts.browser !== 'chromium') {
103
+ throw new Error("BROWSER_CDP_ENDPOINT is only supported with --browser chromium.");
104
+ }
105
+ spinner.text = "Connecting over CDP...";
106
+ browser = await browserType.connectOverCDP(process.env.BROWSER_CDP_ENDPOINT);
107
+ } else {
108
+ browser = await browserType.launch({
109
+ headless: !useHeaded,
110
+ args: launchArgs,
111
+ });
112
+ }
100
113
 
101
114
  try {
102
115
  const isMultiPage = opts.pages || opts.sitemap;
@@ -108,6 +121,7 @@ program
108
121
  slow: opts.slow,
109
122
  screenshotPath: opts.screenshot,
110
123
  discoverLinks: isMultiPage && !opts.sitemap ? maxPages : null,
124
+ wcag: opts.wcag,
111
125
  });
112
126
 
113
127
  // Multi-page crawl
@@ -170,7 +184,7 @@ program
170
184
  await browser.close();
171
185
  browser = null;
172
186
 
173
- if (useHeaded) throw err;
187
+ if (useHeaded || process.env.BROWSER_CDP_ENDPOINT) throw err;
174
188
 
175
189
  if (
176
190
  err.message.includes("Timeout") ||
package/lib/colors.js CHANGED
@@ -228,6 +228,52 @@ export function hexToRgb(hex) {
228
228
  return null;
229
229
  }
230
230
 
231
+ /**
232
+ * Compute WCAG 2.1 relative luminance for a hex color.
233
+ * @param {string} hex
234
+ * @returns {number|null}
235
+ */
236
+ export function relativeLuminance(hex) {
237
+ const rgb = hexToRgb(hex);
238
+ if (!rgb) return null;
239
+ return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
240
+ }
241
+
242
+ /**
243
+ * Compute WCAG contrast ratios for all pairs in a color palette.
244
+ * @param {Array<{color: string, normalized: string, confidence: string}>} palette
245
+ * @returns {Array<{fg: string, bg: string, ratio: number, aa: boolean, aaLarge: boolean, aaa: boolean}>}
246
+ */
247
+ export function computeWcag(palette) {
248
+ const colors = palette
249
+ .filter(c => c.normalized && c.normalized.startsWith('#'))
250
+ .slice(0, 10);
251
+
252
+ const pairs = [];
253
+ for (let i = 0; i < colors.length; i++) {
254
+ for (let j = i + 1; j < colors.length; j++) {
255
+ const l1 = relativeLuminance(colors[i].normalized);
256
+ const l2 = relativeLuminance(colors[j].normalized);
257
+ if (l1 === null || l2 === null) continue;
258
+ const lighter = Math.max(l1, l2);
259
+ const darker = Math.min(l1, l2);
260
+ const ratio = (lighter + 0.05) / (darker + 0.05);
261
+ const fg = l1 >= l2 ? colors[i].normalized : colors[j].normalized;
262
+ const bg = l1 >= l2 ? colors[j].normalized : colors[i].normalized;
263
+ pairs.push({
264
+ fg,
265
+ bg,
266
+ ratio: Math.round(ratio * 100) / 100,
267
+ aa: ratio >= 4.5,
268
+ aaLarge: ratio >= 3,
269
+ aaa: ratio >= 7,
270
+ });
271
+ }
272
+ }
273
+
274
+ return pairs.sort((a, b) => b.ratio - a.ratio);
275
+ }
276
+
231
277
  /**
232
278
  * Convert any supported color format to all formats
233
279
  * @param {string} colorString - Color in hex, rgb(), or rgba() format
@@ -0,0 +1,251 @@
1
+ export async function extractBreakpoints(page) {
2
+ return await page.evaluate(() => {
3
+ const breakpoints = new Set();
4
+
5
+ for (const sheet of document.styleSheets) {
6
+ try {
7
+ for (const rule of sheet.cssRules || []) {
8
+ if (rule.media) {
9
+ const match = rule.media.mediaText.match(/(\d+)px/g);
10
+ if (match) match.forEach((m) => breakpoints.add(parseInt(m)));
11
+ }
12
+ }
13
+ } catch (e) {}
14
+ }
15
+
16
+ return Array.from(breakpoints)
17
+ .sort((a, b) => a - b)
18
+ .map((px) => ({ px: px + "px" }));
19
+ });
20
+ }
21
+
22
+ export async function detectIconSystem(page) {
23
+ return await page.evaluate(() => {
24
+ const systems = [];
25
+
26
+ if (document.querySelector('[class*="fa-"]')) {
27
+ systems.push({ name: "Font Awesome", type: "icon-font" });
28
+ }
29
+ if (document.querySelector('[class*="material-icons"]')) {
30
+ systems.push({ name: "Material Icons", type: "icon-font" });
31
+ }
32
+ if (document.querySelector('svg[class*="heroicon"]') || document.querySelector('svg[data-slot="icon"]')) {
33
+ systems.push({ name: "Heroicons", type: "svg" });
34
+ }
35
+ if (document.querySelector('svg[class*="hugeicons"]') || document.querySelector('[class*="hugeicons-"]')) {
36
+ systems.push({ name: "Hugeicons", type: "svg" });
37
+ }
38
+ if (document.querySelector('ion-icon') || document.querySelector('[class*="ionicons"]')) {
39
+ systems.push({ name: "Ionicons", type: "svg" });
40
+ }
41
+ if (document.querySelector('svg[data-feather]') || document.querySelector('i[data-feather]') || document.querySelector('[class*="feather-"]')) {
42
+ systems.push({ name: "Feather Icons", type: "svg" });
43
+ }
44
+ if (document.querySelector('svg[class*="icon"]')) {
45
+ systems.push({ name: "SVG Icons", type: "svg" });
46
+ }
47
+
48
+ return systems;
49
+ });
50
+ }
51
+
52
+ export async function detectFrameworks(page) {
53
+ return await page.evaluate(() => {
54
+ const frameworks = [];
55
+ const html = document.documentElement.outerHTML;
56
+ const body = document.body;
57
+
58
+ function countMatches(selector) {
59
+ try {
60
+ return document.querySelectorAll(selector).length;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ function hasResource(pattern) {
67
+ const links = Array.from(document.querySelectorAll('link[href], script[src]'));
68
+ return links.some(el => pattern.test(el.href || el.src));
69
+ }
70
+
71
+ // Tailwind CSS
72
+ const tailwindEvidence = [];
73
+ if (/\b\w+-\[[^\]]+\]/.test(html)) tailwindEvidence.push('arbitrary values (e.g., top-[117px])');
74
+ if (/(sm|md|lg|xl|2xl|dark|hover|focus|group-hover|peer-):[a-z]/.test(html)) tailwindEvidence.push('responsive/state modifiers');
75
+ if (hasResource(/tailwindcss|tailwind\.css|cdn\.tailwindcss/)) tailwindEvidence.push('stylesheet');
76
+ if (tailwindEvidence.length >= 2) {
77
+ frameworks.push({ name: 'Tailwind CSS', confidence: 'high', evidence: tailwindEvidence.join(', ') });
78
+ }
79
+
80
+ // Bootstrap
81
+ const bootstrapEvidence = [];
82
+ const hasContainer = countMatches('.container, .container-fluid') > 0;
83
+ const hasRow = countMatches('.row') > 0;
84
+ const hasCol = countMatches('[class*="col-"]') > 0;
85
+ if (hasContainer && hasRow && hasCol) bootstrapEvidence.push('grid system (container + row + col)');
86
+ if (/\bbtn-primary\b|\bbtn-secondary\b|\bbtn-success\b/.test(html)) bootstrapEvidence.push('button variants');
87
+ if (hasResource(/bootstrap\.min\.css|bootstrap\.css|getbootstrap\.com/)) bootstrapEvidence.push('stylesheet');
88
+ if (bootstrapEvidence.length >= 2) {
89
+ frameworks.push({ name: 'Bootstrap', confidence: 'high', evidence: bootstrapEvidence.join(', ') });
90
+ }
91
+
92
+ // Material UI (MUI)
93
+ const muiCount = countMatches('[class*="MuiBox-"], [class*="MuiButton-"], [class*="Mui"]');
94
+ if (muiCount > 3) frameworks.push({ name: 'Material UI (MUI)', confidence: 'high', evidence: `${muiCount} MUI components` });
95
+
96
+ // Chakra UI
97
+ const chakraCount = countMatches('[class*="chakra-"]');
98
+ if (chakraCount > 3) frameworks.push({ name: 'Chakra UI', confidence: 'high', evidence: `${chakraCount} Chakra components` });
99
+
100
+ // Ant Design
101
+ const antCount = countMatches('[class^="ant-"], [class*=" ant-"]');
102
+ if (antCount > 3) frameworks.push({ name: 'Ant Design', confidence: 'high', evidence: `${antCount} Ant components` });
103
+
104
+ // Vuetify
105
+ const vuetifySpecific = countMatches('[class*="v-btn"], [class*="v-card"], [class*="v-app"], [class*="v-toolbar"], [class*="v-navigation"], [class*="v-list"], [class*="v-sheet"]');
106
+ const hasVuetifyTheme = body.classList.contains('theme--light') || body.classList.contains('theme--dark');
107
+ const hasVuetifyApp = countMatches('[class*="v-application"]') > 0;
108
+ if ((vuetifySpecific > 8 && hasVuetifyApp) || (hasVuetifyTheme && vuetifySpecific > 5)) {
109
+ frameworks.push({ name: 'Vuetify', confidence: 'high', evidence: `${vuetifySpecific} Vuetify components` });
110
+ }
111
+
112
+ // Shopify Polaris
113
+ const polarisCount = countMatches('[class*="Polaris-"]');
114
+ if (polarisCount > 2) frameworks.push({ name: 'Shopify Polaris', confidence: 'high', evidence: `${polarisCount} Polaris components` });
115
+
116
+ // Radix UI
117
+ const radixCount = document.querySelectorAll('[data-radix-], [data-state]').length;
118
+ if (radixCount > 5) frameworks.push({ name: 'Radix UI', confidence: 'high', evidence: `${radixCount} Radix primitives` });
119
+
120
+ // DaisyUI
121
+ if (tailwindEvidence.length >= 2) {
122
+ const daisySpecific = countMatches('.btn-primary.btn, .badge, .drawer, .swap, .mockup-code');
123
+ const hasDaisyTheme = body.hasAttribute('data-theme');
124
+ if (daisySpecific > 3 || hasDaisyTheme) {
125
+ frameworks.push({ name: 'DaisyUI', confidence: 'high', evidence: `Tailwind + ${daisySpecific} DaisyUI components` });
126
+ }
127
+ }
128
+
129
+ // Foundation
130
+ const foundationEvidence = [];
131
+ if (countMatches('.grid-x, .grid-y, .cell') > 0 || countMatches('.button.primary, .button.secondary') > 0) foundationEvidence.push('grid/button system');
132
+ if (hasResource(/foundation\.min\.css|foundation\.css|zurb\.com\/foundation/)) foundationEvidence.push('stylesheet');
133
+ if (foundationEvidence.length >= 1 || countMatches('[data-foundation]') > 0) {
134
+ frameworks.push({ name: 'Foundation', confidence: 'high', evidence: foundationEvidence.join(', ') || 'data attributes' });
135
+ }
136
+
137
+ // Bulma
138
+ const bulmaEvidence = [];
139
+ if (countMatches('.columns, .column') > 0 && countMatches('.column') > 2) bulmaEvidence.push('columns system');
140
+ if (/\bbutton is-primary\b|\bbutton is-link\b/.test(html)) bulmaEvidence.push('button modifiers');
141
+ if (hasResource(/bulma\.min\.css|bulma\.css/)) bulmaEvidence.push('stylesheet');
142
+ if (bulmaEvidence.length >= 2) frameworks.push({ name: 'Bulma', confidence: 'high', evidence: bulmaEvidence.join(', ') });
143
+
144
+ // Semantic UI
145
+ const semanticCount = countMatches('.ui.button, .ui.menu, .ui.card, .ui.grid');
146
+ if (semanticCount > 3 || hasResource(/semantic\.min\.css|semantic-ui/)) {
147
+ frameworks.push({ name: 'Semantic UI', confidence: 'high', evidence: `${semanticCount} .ui components` });
148
+ }
149
+
150
+ // UIkit
151
+ const uikitCount = countMatches('[class*="uk-"], [uk-grid], [uk-navbar]');
152
+ if (uikitCount > 3 || hasResource(/uikit\.min\.css|getuikit\.com/)) {
153
+ frameworks.push({ name: 'UIkit', confidence: 'high', evidence: `${uikitCount} uk- components` });
154
+ }
155
+
156
+ // shadcn/ui
157
+ const shadcnClasses = /\bcn\(|\bslot-\w+|\bdata-\[state=/.test(html);
158
+ const hasShadcnComponents = countMatches('[data-slot], [data-state]') > 5;
159
+ if (tailwindEvidence.length >= 2 && radixCount > 3 && (shadcnClasses || hasShadcnComponents)) {
160
+ frameworks.push({ name: 'shadcn/ui', confidence: 'medium', evidence: 'Tailwind + Radix + component patterns' });
161
+ }
162
+
163
+ // Headless UI
164
+ const headlessCount = document.querySelectorAll('[aria-controls][aria-expanded], [role="dialog"][data-headlessui]').length;
165
+ if (tailwindEvidence.length >= 2 && headlessCount > 2) {
166
+ frameworks.push({ name: 'Headless UI', confidence: 'high', evidence: `${headlessCount} headless components with Tailwind` });
167
+ }
168
+
169
+ // PrimeReact/Vue/NG
170
+ const primeCount = countMatches('[class*="p-"], .p-component, .p-button, .p-datatable');
171
+ if (primeCount > 5) frameworks.push({ name: 'PrimeReact/Vue/NG', confidence: 'high', evidence: `${primeCount} Prime components` });
172
+
173
+ // Mantine
174
+ const mantineCount = countMatches('[class*="mantine-"], [data-mantine]');
175
+ if (mantineCount > 3) frameworks.push({ name: 'Mantine', confidence: 'high', evidence: `${mantineCount} Mantine components` });
176
+
177
+ // Carbon Design System
178
+ const carbonCount = countMatches('[class*="cds--"], [class*="bx--"]');
179
+ if (carbonCount > 3) frameworks.push({ name: 'Carbon Design System', confidence: 'high', evidence: `${carbonCount} Carbon components` });
180
+
181
+ // Fluent UI
182
+ const fluentCount = countMatches('[class*="ms-"], .ms-Button, .ms-TextField');
183
+ if (fluentCount > 5) frameworks.push({ name: 'Fluent UI', confidence: 'high', evidence: `${fluentCount} Fluent components` });
184
+
185
+ // Quasar
186
+ const quasarCount = countMatches('[class*="q-"]');
187
+ if (quasarCount > 5 || body.classList.contains('q-app')) {
188
+ frameworks.push({ name: 'Quasar', confidence: 'high', evidence: `${quasarCount} q- components` });
189
+ }
190
+
191
+ // Element Plus/UI
192
+ const elementCount = countMatches('[class*="el-"]');
193
+ if (elementCount > 5) frameworks.push({ name: 'Element Plus/UI', confidence: 'high', evidence: `${elementCount} el- components` });
194
+
195
+ return frameworks;
196
+ });
197
+ }
198
+
199
+ export async function extractGradients(page) {
200
+ return await page.evaluate(() => {
201
+ const seen = new Map();
202
+
203
+ const els = document.querySelectorAll('*');
204
+ let checked = 0;
205
+ for (const el of els) {
206
+ if (checked++ > 2000) break;
207
+ if (el.style.backgroundImage === 'none') continue;
208
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
209
+ const s = getComputedStyle(el);
210
+ const bg = s.backgroundImage;
211
+ if (!bg || bg === 'none') continue;
212
+
213
+ const gradients = [];
214
+ let depth = 0, start = 0;
215
+ for (let i = 0; i < bg.length; i++) {
216
+ if (bg[i] === '(') depth++;
217
+ else if (bg[i] === ')') depth--;
218
+ else if (bg[i] === ',' && depth === 0) {
219
+ gradients.push(bg.slice(start, i).trim());
220
+ start = i + 1;
221
+ }
222
+ }
223
+ gradients.push(bg.slice(start).trim());
224
+
225
+ for (const grad of gradients) {
226
+ if (!/^(repeating-)?(linear|radial|conic)-gradient/.test(grad)) continue;
227
+
228
+ const base = grad.replace(/^repeating-/, '');
229
+ const repeating = grad.startsWith('repeating-');
230
+ const type = (base.startsWith('linear') ? 'linear' : base.startsWith('radial') ? 'radial' : 'conic') + (repeating ? '-repeating' : '');
231
+
232
+ const key = grad.replace(/\s+/g, ' ');
233
+ if (seen.has(key)) {
234
+ seen.get(key).count++;
235
+ continue;
236
+ }
237
+
238
+ const stopColors = [];
239
+ const stopRe = /#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)/gi;
240
+ let m;
241
+ while ((m = stopRe.exec(grad)) !== null) stopColors.push(m[0]);
242
+
243
+ seen.set(key, { gradient: key, type, stopColors, count: 1 });
244
+ }
245
+ }
246
+
247
+ return Array.from(seen.values())
248
+ .sort((a, b) => b.count - a.count)
249
+ .slice(0, 20);
250
+ });
251
+ }