designlang 11.0.1 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,8 @@ program
84
84
  .option('--user-agent <ua>', 'override the browser User-Agent string')
85
85
  .option('--insecure', 'ignore HTTPS/SSL certificate errors (self-signed, dev, proxies)')
86
86
  .option('--ignore <selectors...>', 'CSS selectors to remove before extraction')
87
+ .option('--ignore-widgets', 'Also ignore a curated list of third-party widgets (Intercom, Drift, HubSpot chat, cookie banners, reCAPTCHA, etc.) See `designlang widgets`.')
88
+ .option('--storybook', 'Emit a runnable Storybook project (stories/, .storybook/, package.json) alongside the extraction')
87
89
  .option('--selector <css>', 'only extract design from elements matching this CSS selector (e.g. ".pricing-card")')
88
90
  .option('--system-chrome', 'use the system Chrome install instead of the bundled Chromium (skips the 150MB Playwright download)')
89
91
  .option('--tokens-legacy', 'Emit pre-v7 flat token JSON (backward compat)')
@@ -106,6 +108,11 @@ program
106
108
  const config = loadConfig();
107
109
  const merged = mergeConfig(opts, config);
108
110
 
111
+ if (merged.ignoreWidgets || opts.ignoreWidgets) {
112
+ const { widgetIgnoreList } = await import('../src/widgets.js');
113
+ merged.ignore = [...(merged.ignore || []), ...widgetIgnoreList()];
114
+ }
115
+
109
116
  // Validate URL
110
117
  validateUrl(url);
111
118
 
@@ -467,6 +474,20 @@ program
467
474
  }
468
475
  }
469
476
 
477
+ // Storybook project (opt-in via --storybook)
478
+ if (merged.storybook && Array.isArray(design.componentAnatomy) && design.componentAnatomy.length > 0) {
479
+ const { formatStorybook } = await import('../src/formatters/storybook.js');
480
+ const sbFiles = formatStorybook(design);
481
+ const sbDir = join(outDir, `${prefix}-storybook`);
482
+ mkdirSync(sbDir, { recursive: true });
483
+ for (const [rel, content] of Object.entries(sbFiles)) {
484
+ const p = join(sbDir, rel);
485
+ mkdirSync(join(p, '..'), { recursive: true });
486
+ writeFileSync(p, content, 'utf-8');
487
+ platformFiles.push({ path: p, label: `Storybook (${rel})` });
488
+ }
489
+ }
490
+
470
491
  // Save to history
471
492
  if (opts.history !== false) {
472
493
  const histInfo = saveSnapshot(design);
@@ -1056,6 +1077,55 @@ program
1056
1077
  }
1057
1078
  });
1058
1079
 
1080
+ // ── Replay — record a short WebM of motion from a URL ─────
1081
+ program
1082
+ .command('replay <url>')
1083
+ .description('Record a short WebM clip of a site\'s motion (scroll + hover). Optional MP4 if ffmpeg is on PATH.')
1084
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
1085
+ .option('-n, --name <name>', 'output file prefix', 'motion-replay')
1086
+ .option('-d, --duration <s>', 'duration in seconds (2-15)', parseInt, 5)
1087
+ .option('-w, --width <px>', 'viewport width', parseInt, 1280)
1088
+ .option('--height <px>', 'viewport height', parseInt, 800)
1089
+ .option('--mp4', 'also emit an MP4 (requires ffmpeg on PATH)')
1090
+ .action(async (url, opts) => {
1091
+ if (!url.startsWith('http')) url = `https://${url}`;
1092
+ validateUrl(url);
1093
+ const spinner = ora('Recording motion replay...').start();
1094
+ try {
1095
+ const { recordReplay } = await import('../src/replay.js');
1096
+ const r = await recordReplay(url, {
1097
+ out: opts.out,
1098
+ prefix: opts.name,
1099
+ duration: opts.duration,
1100
+ width: opts.width,
1101
+ height: opts.height,
1102
+ mp4: opts.mp4,
1103
+ });
1104
+ if (!r.webm) {
1105
+ spinner.fail('No video was produced. The browser may have blocked recording; try a different URL.');
1106
+ process.exit(1);
1107
+ }
1108
+ spinner.succeed(`Replay captured (${r.duration}s)`);
1109
+ console.log('');
1110
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(r.webm)} — WebM`);
1111
+ if (r.mp4) console.log(` ${chalk.green('✓')} ${chalk.cyan(r.mp4)} — MP4`);
1112
+ else if (opts.mp4) console.log(` ${chalk.gray('note: ffmpeg not found on PATH; MP4 skipped')}`);
1113
+ console.log('');
1114
+ } catch (err) {
1115
+ spinner.fail(err.message);
1116
+ process.exit(1);
1117
+ }
1118
+ });
1119
+
1120
+ // ── Widgets — print the curated third-party widget ignore list ─
1121
+ program
1122
+ .command('widgets')
1123
+ .description('Print the curated widget-ignore selector list used by --ignore-widgets')
1124
+ .action(async () => {
1125
+ const { WIDGET_SELECTORS } = await import('../src/widgets.js');
1126
+ for (const s of WIDGET_SELECTORS) console.log(s);
1127
+ });
1128
+
1059
1129
  // ── CI command — single PR-comment-ready report ────────────
1060
1130
  program
1061
1131
  .command('ci <url>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "11.0.1",
3
+ "version": "11.1.0",
4
4
  "description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,225 @@
1
+ // Storybook emitter — turns extracted componentAnatomy + design tokens into
2
+ // a runnable CSF3 Storybook project. Writes stories/*, .storybook/*, and a
3
+ // README showing the install + run steps. Zero Storybook dependency in
4
+ // designlang itself; the emitted project installs its own.
5
+ //
6
+ // Consumer wiring (bin):
7
+ // if (merged.storybook && (design.componentAnatomy || []).length) {
8
+ // const { formatStorybook } = await import('../src/formatters/storybook.js');
9
+ // const files = formatStorybook(design);
10
+ // for (const [rel, content] of Object.entries(files)) {
11
+ // writeFileSync(join(outDir, 'storybook', rel), content);
12
+ // }
13
+ // }
14
+
15
+ function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
16
+
17
+ function htmlTagFor(kind) {
18
+ return kind === 'input' ? 'input'
19
+ : kind === 'link' ? 'a'
20
+ : kind === 'card' ? 'div'
21
+ : 'button';
22
+ }
23
+
24
+ function storyFor(anatomy, tokenVars) {
25
+ const Name = cap(anatomy.kind);
26
+ const variants = anatomy.props.variant.length ? anatomy.props.variant : ['default'];
27
+ const sizes = anatomy.props.size.length ? anatomy.props.size : ['md'];
28
+ const tag = htmlTagFor(anatomy.kind);
29
+ const hasIcon = !!anatomy.slots?.icon;
30
+ const hasBadge = !!anatomy.slots?.badge;
31
+
32
+ const sampleLabel = anatomy.kind === 'card' ? 'Card content'
33
+ : anatomy.kind === 'input' ? ''
34
+ : anatomy.kind === 'link' ? 'Read more'
35
+ : 'Button';
36
+
37
+ // Render via inline React to keep the emitted project dependency-light.
38
+ const render = `(args) => {
39
+ const style = {
40
+ fontFamily: 'var(--font-sans, inherit)',
41
+ padding: args.size === 'sm' ? '6px 12px' : args.size === 'lg' ? '14px 22px' : '10px 16px',
42
+ borderRadius: 'var(--radius, 8px)',
43
+ background: args.variant === 'secondary' ? 'transparent'
44
+ : args.variant === 'outline' ? 'transparent'
45
+ : 'var(--color-primary, #3b82f6)',
46
+ color: args.variant === 'secondary' || args.variant === 'outline' ? 'var(--color-foreground, #111)' : '#fff',
47
+ border: args.variant === 'outline' ? '1px solid var(--color-foreground, #111)' : 'none',
48
+ fontWeight: 500,
49
+ cursor: 'pointer',
50
+ };
51
+ return React.createElement('${tag}', { style, 'data-variant': args.variant, 'data-size': args.size },
52
+ '${sampleLabel}'${hasIcon ? ", React.createElement('span', { style: { marginLeft: 8 } }, '→')" : ''}${hasBadge ? ", React.createElement('span', { style: { marginLeft: 6, padding: '2px 6px', background: '#f59e0b', borderRadius: 99, color: '#fff', fontSize: 11 } }, '3')" : ''}
53
+ );
54
+ }`;
55
+
56
+ return `import * as React from 'react';
57
+ import type { Meta, StoryObj } from '@storybook/react';
58
+
59
+ // Extracted from a live site by \`designlang\`. No runtime library —
60
+ // these stories render inline to stay dependency-free.
61
+ const ${Name}: React.FC<{ variant?: string; size?: string }> = ${render};
62
+
63
+ const meta: Meta<typeof ${Name}> = {
64
+ title: 'Extracted/${Name}',
65
+ component: ${Name},
66
+ tags: ['autodocs'],
67
+ argTypes: {
68
+ variant: { control: 'select', options: [${variants.map(v => `'${v}'`).join(', ')}] },
69
+ size: { control: 'select', options: [${sizes.map(s => `'${s}'`).join(', ')}] },
70
+ },
71
+ parameters: {
72
+ docs: {
73
+ description: {
74
+ component: '${anatomy.kind} — ${anatomy.totalInstances || 0} instances detected across the page.',
75
+ },
76
+ },
77
+ },
78
+ };
79
+ export default meta;
80
+
81
+ type Story = StoryObj<typeof ${Name}>;
82
+
83
+ ${variants.map(v => `export const ${cap(v)}: Story = { args: { variant: '${v}', size: '${sizes[0]}' } };`).join('\n')}
84
+
85
+ export const Sizes: Story = {
86
+ render: () => React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center' } },
87
+ ${sizes.map(s => `React.createElement(${Name}, { key: '${s}', variant: '${variants[0]}', size: '${s}' })`).join(',\n ')}
88
+ ),
89
+ };
90
+ `;
91
+ }
92
+
93
+ function tokensCss(design) {
94
+ const colors = design.colors || {};
95
+ const primary = colors.primary?.hex || '#3b82f6';
96
+ const secondary = colors.secondary?.hex || '#8b5cf6';
97
+ const accent = colors.accent?.hex || '#f59e0b';
98
+ const bg = colors.backgrounds?.[0] || '#ffffff';
99
+ const fg = colors.text?.[0] || '#171717';
100
+ const radii = design.borders?.radii || [];
101
+ const radius = radii.find(r => r.label === 'md')?.value ?? 8;
102
+ const shadow = design.shadows?.values?.find(s => s.label === 'md')?.raw || '0 4px 6px rgba(0,0,0,0.1)';
103
+ const font = design.typography?.families?.[0]?.name || 'Inter';
104
+ return `:root {
105
+ --color-primary: ${primary};
106
+ --color-secondary: ${secondary};
107
+ --color-accent: ${accent};
108
+ --color-background: ${bg};
109
+ --color-foreground: ${fg};
110
+ --radius: ${radius}px;
111
+ --shadow: ${shadow};
112
+ --font-sans: '${font}', system-ui, sans-serif;
113
+ }
114
+ body { background: var(--color-background); color: var(--color-foreground); font-family: var(--font-sans); }
115
+ `;
116
+ }
117
+
118
+ export function formatStorybook(design) {
119
+ const anatomies = design.componentAnatomy || [];
120
+ const files = {};
121
+
122
+ // Story files (one per kind).
123
+ for (const a of anatomies) {
124
+ const Name = cap(a.kind);
125
+ files[`stories/${Name}.stories.tsx`] = storyFor(a, design);
126
+ }
127
+
128
+ // A tokens page so people can see the palette/scale inline.
129
+ files['stories/Tokens.mdx'] = `import { Meta, ColorPalette, ColorItem, Typeset } from '@storybook/blocks';
130
+
131
+ <Meta title="Extracted/Tokens" />
132
+
133
+ # Design tokens
134
+
135
+ Extracted from ${design.meta?.url || 'the target site'} by [designlang](https://github.com/Manavarya09/design-extract).
136
+
137
+ <ColorPalette>
138
+ <ColorItem title="primary" colors={{ primary: '${design.colors?.primary?.hex || '#3b82f6'}' }} />
139
+ <ColorItem title="secondary" colors={{ secondary: '${design.colors?.secondary?.hex || '#8b5cf6'}' }} />
140
+ <ColorItem title="accent" colors={{ accent: '${design.colors?.accent?.hex || '#f59e0b'}' }} />
141
+ <ColorItem title="background" colors={{ background: '${design.colors?.backgrounds?.[0] || '#ffffff'}' }} />
142
+ <ColorItem title="foreground" colors={{ foreground: '${design.colors?.text?.[0] || '#171717'}' }} />
143
+ </ColorPalette>
144
+ `;
145
+
146
+ files['stories/tokens.css'] = tokensCss(design);
147
+
148
+ files['.storybook/main.ts'] = `import type { StorybookConfig } from '@storybook/react-vite';
149
+
150
+ const config: StorybookConfig = {
151
+ stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(ts|tsx)'],
152
+ addons: ['@storybook/addon-essentials', '@storybook/addon-docs'],
153
+ framework: { name: '@storybook/react-vite', options: {} },
154
+ };
155
+ export default config;
156
+ `;
157
+
158
+ files['.storybook/preview.ts'] = `import type { Preview } from '@storybook/react';
159
+ import './../stories/tokens.css';
160
+
161
+ const preview: Preview = {
162
+ parameters: {
163
+ backgrounds: { default: 'paper' },
164
+ controls: { matchers: { color: /(background|color)$/i } },
165
+ },
166
+ };
167
+ export default preview;
168
+ `;
169
+
170
+ files['package.json'] = JSON.stringify({
171
+ name: `${(design.meta?.title || 'extracted').toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 40) || 'extracted'}-storybook`,
172
+ private: true,
173
+ version: '0.1.0',
174
+ type: 'module',
175
+ scripts: {
176
+ storybook: 'storybook dev -p 6006',
177
+ 'build-storybook': 'storybook build',
178
+ },
179
+ dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' },
180
+ devDependencies: {
181
+ '@storybook/addon-essentials': '^8.0.0',
182
+ '@storybook/addon-docs': '^8.0.0',
183
+ '@storybook/blocks': '^8.0.0',
184
+ '@storybook/react': '^8.0.0',
185
+ '@storybook/react-vite': '^8.0.0',
186
+ '@types/react': '^19.0.0',
187
+ '@types/react-dom': '^19.0.0',
188
+ storybook: '^8.0.0',
189
+ typescript: '^5.0.0',
190
+ vite: '^5.0.0',
191
+ },
192
+ }, null, 2);
193
+
194
+ files['tsconfig.json'] = JSON.stringify({
195
+ compilerOptions: {
196
+ target: 'ES2020',
197
+ module: 'ESNext',
198
+ moduleResolution: 'Bundler',
199
+ jsx: 'react-jsx',
200
+ strict: true,
201
+ esModuleInterop: true,
202
+ skipLibCheck: true,
203
+ },
204
+ include: ['stories/**/*', '.storybook/**/*'],
205
+ }, null, 2);
206
+
207
+ files['README.md'] = `# ${design.meta?.title || 'Extracted'} · Storybook
208
+
209
+ Auto-generated by \`designlang <url> --storybook\`.
210
+
211
+ ## Stories
212
+ ${anatomies.map(a => `- **${cap(a.kind)}** — ${a.props.variant.length || 1} variant(s), ${a.props.size.length || 1} size(s), ${a.totalInstances || 0} detected`).join('\n') || '_No anatomy detected on the source page._'}
213
+
214
+ ## Run
215
+
216
+ \`\`\`
217
+ npm install
218
+ npm run storybook
219
+ \`\`\`
220
+
221
+ Opens at http://localhost:6006.
222
+ `;
223
+
224
+ return files;
225
+ }
package/src/replay.js ADDED
@@ -0,0 +1,131 @@
1
+ // Motion replay — records a short WebM of a site with scripted
2
+ // interactions (hover/scroll) so the captured motion actually fires.
3
+ // Zero new deps: Playwright ships video recording out of the box.
4
+ //
5
+ // Output: <prefix>-motion.webm (+ optional MP4 if ffmpeg is on PATH)
6
+ //
7
+ // Usage: designlang replay <url> [--duration 5] [--out dir]
8
+
9
+ import { chromium } from 'playwright';
10
+ import { mkdirSync, existsSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from 'fs';
11
+ import { resolve, join } from 'path';
12
+ import { spawnSync } from 'child_process';
13
+
14
+ async function ensureFfmpeg() {
15
+ try {
16
+ const r = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
17
+ return r.status === 0;
18
+ } catch { return false; }
19
+ }
20
+
21
+ function toMp4(webmPath) {
22
+ const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
23
+ const r = spawnSync('ffmpeg', ['-y', '-i', webmPath, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4Path], { stdio: 'ignore' });
24
+ return r.status === 0 ? mp4Path : null;
25
+ }
26
+
27
+ export async function recordReplay(url, opts = {}) {
28
+ const outDir = resolve(opts.out || './design-extract-output');
29
+ mkdirSync(outDir, { recursive: true });
30
+ const prefix = opts.prefix || 'motion-replay';
31
+ const duration = Math.max(2, Math.min(15, parseInt(opts.duration) || 5));
32
+ const width = parseInt(opts.width) || 1280;
33
+ const height = parseInt(opts.height) || 800;
34
+
35
+ const videoDir = join(outDir, `.playwright-video-${Date.now()}`);
36
+ mkdirSync(videoDir, { recursive: true });
37
+
38
+ const browser = await chromium.launch({ headless: true });
39
+ const context = await browser.newContext({
40
+ viewport: { width, height },
41
+ recordVideo: { dir: videoDir, size: { width, height } },
42
+ reducedMotion: 'no-preference',
43
+ });
44
+ const page = await context.newPage();
45
+
46
+ try {
47
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 });
48
+ } catch {
49
+ try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch {}
50
+ }
51
+
52
+ // Scripted motion pass: slow scroll → hover first CTA → scroll back.
53
+ const frameMs = 50;
54
+ const steps = Math.floor((duration * 1000) / frameMs);
55
+ for (let i = 0; i < steps; i++) {
56
+ const progress = i / steps;
57
+ // Slow smooth scroll across the page
58
+ await page.evaluate((p) => {
59
+ const max = Math.max(document.body.scrollHeight - window.innerHeight, 0);
60
+ window.scrollTo({ top: max * p, behavior: 'auto' });
61
+ }, progress).catch(() => {});
62
+ // Partway through, hover a button to trigger transition states.
63
+ if (i === Math.floor(steps / 2)) {
64
+ try {
65
+ const btn = await page.$('button, [role="button"], a.btn, .button, [class*="btn"]');
66
+ if (btn) await btn.hover({ timeout: 1000 }).catch(() => {});
67
+ } catch {}
68
+ }
69
+ await page.waitForTimeout(frameMs);
70
+ }
71
+
72
+ const videoPromise = page.video();
73
+ await context.close();
74
+ await browser.close();
75
+
76
+ // Move the captured WebM out of the Playwright temp dir.
77
+ const finalWebm = join(outDir, `${prefix}.webm`);
78
+ try {
79
+ const video = await videoPromise;
80
+ if (video && typeof video.path === 'function') {
81
+ const src = await video.path();
82
+ if (src && existsSync(src)) {
83
+ renameSync(src, finalWebm);
84
+ }
85
+ } else {
86
+ // Fallback: grab whatever .webm Playwright wrote to the dir.
87
+ const produced = readdirSync(videoDir).filter(f => f.endsWith('.webm'))
88
+ .map(f => ({ f, t: statSync(join(videoDir, f)).mtimeMs }))
89
+ .sort((a, b) => b.t - a.t)[0];
90
+ if (produced) renameSync(join(videoDir, produced.f), finalWebm);
91
+ }
92
+ } catch { /* no video — fall through with null result */ }
93
+
94
+ // Clean the scratch dir.
95
+ try {
96
+ for (const f of readdirSync(videoDir)) unlinkSync(join(videoDir, f));
97
+ rmdirSync(videoDir);
98
+ } catch {}
99
+
100
+ const result = {
101
+ url,
102
+ webm: existsSync(finalWebm) ? finalWebm : null,
103
+ mp4: null,
104
+ duration,
105
+ width,
106
+ height,
107
+ };
108
+
109
+ if (result.webm && opts.mp4 && await ensureFfmpeg()) {
110
+ const mp4 = toMp4(result.webm);
111
+ if (mp4) result.mp4 = mp4;
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ // Tiny snippet that can be spliced into the existing HTML preview to embed
118
+ // the replay next to the static screenshot. Consumer decides where to place.
119
+ export function replayEmbedHtml(result) {
120
+ if (!result?.webm) return '';
121
+ const src = result.webm.split(/[\\/]/).pop();
122
+ return `<figure class="motion-replay" style="margin:24px 0">
123
+ <video autoplay loop muted playsinline style="width:100%;border:1px solid #0a0908">
124
+ <source src="${src}" type="video/webm" />
125
+ ${result.mp4 ? `<source src="${result.mp4.split(/[\\/]/).pop()}" type="video/mp4" />` : ''}
126
+ </video>
127
+ <figcaption style="font-family:var(--font-mono, monospace);font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#403c34;margin-top:8px">
128
+ motion replay · ${result.duration}s · ${result.width}×${result.height}
129
+ </figcaption>
130
+ </figure>`;
131
+ }
package/src/widgets.js ADDED
@@ -0,0 +1,48 @@
1
+ // Curated selector list for third-party widgets that pollute extractions.
2
+ // Enabled via --ignore-widgets; appended to user-supplied --ignore selectors.
3
+ //
4
+ // Criteria for inclusion: widget renders on tens of thousands of sites, uses a
5
+ // stable ID/attr hook, and its styles would skew token extraction (chat bubbles
6
+ // with off-brand radii/shadows, cookie banners with alarm colors, etc.).
7
+
8
+ export const WIDGET_SELECTORS = [
9
+ // Live chat / support
10
+ '#intercom-container', '#intercom-frame', 'iframe[name*="intercom"]',
11
+ '#drift-widget', '#drift-frame-controller', '#drift-frame-chat',
12
+ '#hubspot-messages-iframe-container',
13
+ '#crisp-chatbox', '.crisp-client',
14
+ 'iframe[title*="Messaging"]', '#launcher', '[data-product="web_widget"]', // Zendesk
15
+ '#tawk-default', 'iframe[title*="chat window"]',
16
+ '#chat-widget-container', '#livechat-compact-container', // LiveChat
17
+ '#helpshift-iframe',
18
+ 'iframe[src*="freshchat"]', '#fc_frame',
19
+ 'iframe[src*="olark"]', '#olark-wrapper',
20
+
21
+ // Cookie / consent banners
22
+ '#CybotCookiebotDialog', '#CybotCookiebotDialogBody',
23
+ '#onetrust-banner-sdk', '#onetrust-consent-sdk', '#onetrust-pc-sdk',
24
+ '.termly-banner-top', '.termly-styles-banner',
25
+ '#cookiebanner', '#cookie-banner', '#cookie-notice',
26
+ '.cc-window', '.cc-banner', // Cookieconsent by Insites
27
+ '#usercentrics-root',
28
+ '#iubenda-cs-banner',
29
+
30
+ // reCAPTCHA / anti-bot (visible v2 badge)
31
+ '.grecaptcha-badge', 'iframe[src*="recaptcha"]',
32
+
33
+ // Analytics / pixel iframes (invisible but can leak tokens)
34
+ 'iframe[src*="doubleclick"]', 'iframe[src*="googletagmanager"]',
35
+ 'iframe[src*="facebook.com/tr"]',
36
+
37
+ // Social share floating bars
38
+ '.addthis_floating_style', '#addthis-smartlayers',
39
+ '.sharethis-inline-share-buttons',
40
+
41
+ // Generic catch-alls (last so specific IDs take priority in logs)
42
+ '[id^="chat-widget"]', '[class*="chat-widget"]',
43
+ '[aria-label*="cookie" i][role="dialog"]',
44
+ ];
45
+
46
+ export function widgetIgnoreList() {
47
+ return [...WIDGET_SELECTORS];
48
+ }