webgl-forensics 3.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.
Files changed (35) hide show
  1. package/.github/workflows/forensics.yml +44 -0
  2. package/CLAUDE_CODE.md +49 -0
  3. package/MASTER_PROMPT.md +202 -0
  4. package/README.md +579 -0
  5. package/SKILL.md +1256 -0
  6. package/package.json +53 -0
  7. package/project summary.md +33 -0
  8. package/puppeteer-runner.js +486 -0
  9. package/scripts/00-tech-stack-detect.js +185 -0
  10. package/scripts/01-source-map-extractor.js +73 -0
  11. package/scripts/02-interaction-model.js +129 -0
  12. package/scripts/03-responsive-analysis.js +115 -0
  13. package/scripts/04-page-transitions.js +128 -0
  14. package/scripts/05-loading-sequence.js +124 -0
  15. package/scripts/06-audio-extraction.js +115 -0
  16. package/scripts/07-accessibility-reduced-motion.js +133 -0
  17. package/scripts/08-complexity-scorer.js +138 -0
  18. package/scripts/09-visual-diff-validator.js +113 -0
  19. package/scripts/10-webgpu-extractor.js +248 -0
  20. package/scripts/11-scroll-screenshot-grid.js +76 -0
  21. package/scripts/12-network-waterfall.js +151 -0
  22. package/scripts/13-react-fiber-walker.js +285 -0
  23. package/scripts/14-shader-hotpatch.js +349 -0
  24. package/scripts/15-multipage-crawler.js +221 -0
  25. package/scripts/16-gsap-timeline-recorder.js +335 -0
  26. package/scripts/17-r3f-fiber-serializer.js +316 -0
  27. package/scripts/18-font-extractor.js +290 -0
  28. package/scripts/19-design-token-export.js +85 -0
  29. package/scripts/20-timeline-visualizer.js +61 -0
  30. package/scripts/21-lighthouse-audit.js +35 -0
  31. package/scripts/22-sitemap-crawler.js +128 -0
  32. package/scripts/23-scaffold-generator.js +451 -0
  33. package/scripts/24-ai-reconstruction.js +226 -0
  34. package/scripts/25-shader-annotator.js +226 -0
  35. package/tui.js +196 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * SCRIPT 10 — WebGPU Extractor
3
+ * webgl-forensics skill | Enhancement #7
4
+ *
5
+ * PURPOSE:
6
+ * Detect and extract WebGPU compute shaders, render pipelines, and
7
+ * bind group layouts from sites using the WebGPU API (next-gen graphics).
8
+ *
9
+ * WebGPU is the successor to WebGL. Sites using Three.js WebGPURenderer,
10
+ * Babylon.js WebGPU mode, or custom WebGPU code will be captured here.
11
+ *
12
+ * WHAT IT EXTRACTS:
13
+ * - Whether WebGPU is supported + preferred adapter info
14
+ * - Detected WebGPU renderer (Three.js WebGPURenderer, Babylon, custom)
15
+ * - WGSL shader source (the WebGPU shader language, like GLSL for WebGPU)
16
+ * - Pipeline descriptors (vertex, fragment, compute stages)
17
+ * - Bind group layouts (how data is passed to shaders)
18
+ * - GPU device limits and features
19
+ *
20
+ * RUN VIA:
21
+ * evaluate_script — run after page loads, before/after navigating
22
+ *
23
+ * NOTE:
24
+ * WebGPU is still experimental in 2026. Most sites still use WebGL.
25
+ * Run this script when 00-tech-stack-detect.js reports:
26
+ * - Three.js >= r163 (WebGPURenderer was added in r163)
27
+ * - Babylon.js 6.x+ in WebGPU mode
28
+ * - Any custom WebGPU code
29
+ */
30
+ (async () => {
31
+ const result = {
32
+ meta: {
33
+ url: window.location.href,
34
+ timestamp: new Date().toISOString(),
35
+ webgpuSupported: typeof navigator.gpu !== 'undefined',
36
+ webglFallback: typeof WebGLRenderingContext !== 'undefined',
37
+ },
38
+ adapter: null,
39
+ device: null,
40
+ detectedRenderer: null,
41
+ pipelines: [],
42
+ shaders: [],
43
+ bindGroupLayouts: [],
44
+ computeShaders: [],
45
+ recommendations: [],
46
+ };
47
+
48
+ // ═══════════════════════════════════════════════
49
+ // GUARD — WebGPU detection
50
+ // ═══════════════════════════════════════════════
51
+ if (!navigator.gpu) {
52
+ result.status = 'WebGPU not supported in this browser';
53
+ result.recommendations.push('This browser does not support WebGPU. Use Chrome 113+ or Edge 113+.');
54
+ result.recommendations.push('For cloning: this site likely uses WebGL fallback — run scripts 00 and 14 instead.');
55
+ return result;
56
+ }
57
+
58
+ // ═══════════════════════════════════════════════
59
+ // STEP 1 — Request adapter info (read-only, no permission needed)
60
+ // ═══════════════════════════════════════════════
61
+ try {
62
+ const adapter = await navigator.gpu.requestAdapter({
63
+ powerPreference: 'high-performance',
64
+ });
65
+
66
+ if (adapter) {
67
+ const info = await adapter.requestAdapterInfo?.();
68
+ result.adapter = {
69
+ vendor: info?.vendor || 'unknown',
70
+ architecture: info?.architecture || 'unknown',
71
+ device: info?.device || 'unknown',
72
+ description: info?.description || 'unknown',
73
+ isFallbackAdapter: adapter.isFallbackAdapter,
74
+ features: [...(adapter.features?.values() || [])],
75
+ limits: {
76
+ maxTextureDimension2D: adapter.limits?.maxTextureDimension2D,
77
+ maxBufferSize: adapter.limits?.maxBufferSize,
78
+ maxComputeWorkgroupSizeX: adapter.limits?.maxComputeWorkgroupSizeX,
79
+ maxComputeInvocationsPerWorkgroup: adapter.limits?.maxComputeInvocationsPerWorkgroup,
80
+ maxBindGroups: adapter.limits?.maxBindGroups,
81
+ maxSampledTexturesPerShaderStage: adapter.limits?.maxSampledTexturesPerShaderStage,
82
+ },
83
+ };
84
+ }
85
+ } catch (e) {
86
+ result.adapter = { error: e.message };
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════
90
+ // STEP 2 — Detect WebGPU renderer type
91
+ // ═══════════════════════════════════════════════
92
+ function detectWebGPURenderer() {
93
+ // Three.js WebGPURenderer (r163+)
94
+ if (window.THREE) {
95
+ if (window.THREE.WebGPURenderer) {
96
+ return { name: 'Three.js WebGPURenderer', version: window.THREE.REVISION, detected: true };
97
+ }
98
+ // Check if any existing renderer is WebGPU-based
99
+ if (window.__threeRenderer?.isWebGPURenderer) {
100
+ return { name: 'Three.js WebGPURenderer (active)', version: window.THREE.REVISION, detected: true };
101
+ }
102
+ }
103
+
104
+ // Babylon.js WebGPU
105
+ if (window.BABYLON) {
106
+ const engine = window.BABYLON.Engine?.LastCreatedEngine;
107
+ if (engine?.constructor?.name?.includes('WebGPU')) {
108
+ return { name: 'Babylon.js WebGPU Engine', version: window.BABYLON.Engine?.Version, detected: true };
109
+ }
110
+ }
111
+
112
+ // wgpu-matrix or raw WebGPU
113
+ if (window.GPUDevice || document.querySelector('[data-webgpu]')) {
114
+ return { name: 'Custom WebGPU', detected: true };
115
+ }
116
+
117
+ // Check canvas context types
118
+ for (const canvas of document.querySelectorAll('canvas')) {
119
+ try {
120
+ // Don't actually request context — just check if it was already created
121
+ const ctx = canvas._gpuContext;
122
+ if (ctx) return { name: 'Custom WebGPU (canvas detected)', detected: true };
123
+ } catch(e) {}
124
+ }
125
+
126
+ return { name: null, detected: false };
127
+ }
128
+
129
+ result.detectedRenderer = detectWebGPURenderer();
130
+
131
+ // ═══════════════════════════════════════════════
132
+ // STEP 3 — INTERCEPT WebGPU pipeline creation
133
+ // (Monkey-patch for future calls on this page)
134
+ // ═══════════════════════════════════════════════
135
+ if (!window.__webgpuIntercepted) {
136
+ window.__webgpuIntercepted = true;
137
+ window.__webgpuCaptured = {
138
+ shaderModules: [],
139
+ pipelines: [],
140
+ computePipelines: [],
141
+ };
142
+
143
+ // Intercept GPUDevice.createShaderModule (captures WGSL source)
144
+ const origCreateShaderModule = GPUDevice.prototype.createShaderModule;
145
+ GPUDevice.prototype.createShaderModule = function(descriptor) {
146
+ if (descriptor?.code) {
147
+ window.__webgpuCaptured.shaderModules.push({
148
+ label: descriptor.label || `shader_${window.__webgpuCaptured.shaderModules.length}`,
149
+ code: descriptor.code,
150
+ capturedAt: Date.now(),
151
+ });
152
+ console.log(`🔬 [webgl-forensics] Captured WGSL shader: ${descriptor.label || 'unlabeled'}`);
153
+ }
154
+ return origCreateShaderModule.apply(this, arguments);
155
+ };
156
+
157
+ // Intercept createRenderPipeline
158
+ const origCreateRenderPipeline = GPUDevice.prototype.createRenderPipeline;
159
+ GPUDevice.prototype.createRenderPipeline = function(descriptor) {
160
+ window.__webgpuCaptured.pipelines.push({
161
+ label: descriptor?.label,
162
+ vertexEntry: descriptor?.vertex?.entryPoint,
163
+ fragmentEntry: descriptor?.fragment?.entryPoint,
164
+ topology: descriptor?.primitive?.topology || 'triangle-list',
165
+ cullMode: descriptor?.primitive?.cullMode || 'none',
166
+ depthWriteEnabled: descriptor?.depthStencil?.depthWriteEnabled,
167
+ });
168
+ return origCreateRenderPipeline.apply(this, arguments);
169
+ };
170
+
171
+ // Intercept createComputePipeline
172
+ const origCreateComputePipeline = GPUDevice.prototype.createComputePipeline;
173
+ GPUDevice.prototype.createComputePipeline = function(descriptor) {
174
+ window.__webgpuCaptured.computePipelines.push({
175
+ label: descriptor?.label,
176
+ computeEntry: descriptor?.compute?.entryPoint,
177
+ });
178
+ return origCreateComputePipeline.apply(this, arguments);
179
+ };
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════
183
+ // STEP 4 — Collect already-captured data
184
+ // ═══════════════════════════════════════════════
185
+ if (window.__webgpuCaptured) {
186
+ result.shaders = window.__webgpuCaptured.shaderModules.map(s => ({
187
+ label: s.label,
188
+ wgsl: s.code.slice(0, 3000), // first 3000 chars
189
+ length: s.code.length,
190
+ hasComputeStage: s.code.includes('@compute'),
191
+ hasVertexStage: s.code.includes('@vertex'),
192
+ hasFragmentStage: s.code.includes('@fragment'),
193
+ uniforms: [...s.code.matchAll(/@group\((\d+)\) @binding\((\d+)\) var(?:<uniform>)? (\w+)/g)]
194
+ .map(m => ({ group: m[1], binding: m[2], name: m[3] })),
195
+ storageBuffers: [...s.code.matchAll(/@group\((\d+)\) @binding\((\d+)\) var<storage/g)]
196
+ .map(m => ({ group: m[1], binding: m[2] })),
197
+ }));
198
+
199
+ result.pipelines = window.__webgpuCaptured.pipelines;
200
+ result.computeShaders = window.__webgpuCaptured.computePipelines;
201
+ }
202
+
203
+ // ═══════════════════════════════════════════════
204
+ // STEP 5 — Three.js WebGPURenderer specific extraction
205
+ // ═══════════════════════════════════════════════
206
+ if (window.THREE?.WebGPURenderer && window.__threeRenderer?.isWebGPURenderer) {
207
+ const renderer = window.__threeRenderer;
208
+ try {
209
+ result.threeWebGPU = {
210
+ isWebGPURenderer: true,
211
+ backend: renderer.backend?.constructor?.name,
212
+ info: {
213
+ memory: renderer.info?.memory,
214
+ render: renderer.info?.render,
215
+ },
216
+ // Three.js WebGPU uses a node-based material system
217
+ nodeBuilderCache: renderer._nodeBuilderCache
218
+ ? Object.keys(renderer._nodeBuilderCache).length + ' cached nodes'
219
+ : null,
220
+ };
221
+ } catch(e) {
222
+ result.threeWebGPU = { error: e.message };
223
+ }
224
+ }
225
+
226
+ // ═══════════════════════════════════════════════
227
+ // STEP 6 — Recommendations
228
+ // ═══════════════════════════════════════════════
229
+ if (!result.detectedRenderer.detected) {
230
+ result.recommendations.push('No WebGPU renderer detected. This site likely uses WebGL — run script 14 for shader extraction.');
231
+ } else {
232
+ result.recommendations.push(`Detected: ${result.detectedRenderer.name}`);
233
+ if (result.shaders.length === 0) {
234
+ result.recommendations.push('No WGSL shaders captured yet. The intercept is now installed — hard-reload the page to capture shader compilation.');
235
+ } else {
236
+ result.recommendations.push(`Captured ${result.shaders.length} WGSL shader modules.`);
237
+ }
238
+ if (result.shaders.some(s => s.hasComputeStage)) {
239
+ result.recommendations.push('⚡ Compute shaders found — this site does GPU-side computation (particles, physics, image processing).');
240
+ }
241
+ }
242
+
243
+ result.status = result.detectedRenderer.detected
244
+ ? `WebGPU detected: ${result.detectedRenderer.name}`
245
+ : 'WebGPU supported by browser but not used by this site';
246
+
247
+ return result;
248
+ })()
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SCRIPT 11 — Auto-Scroll Screenshot Grid
4
+ * webgl-forensics skill | Enhancement #9
5
+ *
6
+ * STANDALONE USAGE (no browser DevTools needed):
7
+ * node puppeteer-runner.js https://cappen.com
8
+ *
9
+ * EVALUATE_SCRIPT MODE:
10
+ * Paste into Chrome console or evaluate_script tool
11
+ * Results: returns scroll positions for use with take_screenshot tool
12
+ *
13
+ * Purpose: Generate a complete visual map of the site by:
14
+ * 1. Getting total page height
15
+ * 2. Calculating 10 scroll positions (0%, 10%, 20%... 100%)
16
+ * 3. Returning the scroll positions for sequential screenshot capture
17
+ *
18
+ * In evaluate_script mode — returns positions only (screenshots are manual).
19
+ * In puppeteer mode — auto-scrolls and screenshots.
20
+ */
21
+
22
+ // ════════════════════════════════════════════════
23
+ // BROWSER CONSOLE / evaluate_script VERSION
24
+ // ════════════════════════════════════════════════
25
+ const BROWSER_IIFE = `
26
+ (() => {
27
+ const totalHeight = document.documentElement.scrollHeight;
28
+ const viewportHeight = window.innerHeight;
29
+ const steps = 10;
30
+
31
+ const positions = [];
32
+ for (let i = 0; i <= steps; i++) {
33
+ const pct = i / steps;
34
+ const y = Math.round(pct * (totalHeight - viewportHeight));
35
+ positions.push({
36
+ step: i,
37
+ percentage: Math.round(pct * 100) + '%',
38
+ scrollY: y,
39
+ instructions: [
40
+ \`window.scrollTo(0, \${y})\`,
41
+ \`take_screenshot → save as "scroll-\${Math.round(pct * 100)}pct.png"\`,
42
+ ],
43
+ });
44
+ }
45
+
46
+ console.log('📸 SCREENSHOT GRID PLAN:');
47
+ console.table(positions.map(p => ({ pct: p.percentage, scrollY: p.scrollY })));
48
+
49
+ return {
50
+ totalPageHeight: totalHeight + 'px',
51
+ viewportHeight: viewportHeight + 'px',
52
+ scrollableArea: (totalHeight - viewportHeight) + 'px',
53
+ screenshotCount: steps + 1,
54
+ positions,
55
+ instructions: [
56
+ 'For each position:',
57
+ '1. Call: window.scrollTo(0, {scrollY})',
58
+ '2. Wait 500ms for animations to settle',
59
+ '3. Call: take_screenshot and save with the step name',
60
+ 'Or use evaluate_script with: window.scrollTo(0, {y}); then take_screenshot',
61
+ ],
62
+ };
63
+ })()
64
+ `;
65
+
66
+ // If running in Node.js via puppeteer, export the logic
67
+ if (typeof module !== 'undefined' && module.exports) {
68
+ module.exports = { BROWSER_IIFE };
69
+ }
70
+
71
+ // Print the script for use in browser console
72
+ if (typeof window === 'undefined') {
73
+ console.log('Paste the following into evaluate_script or browser console:');
74
+ console.log('─'.repeat(60));
75
+ console.log(BROWSER_IIFE);
76
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * SCRIPT 12 — Network Waterfall & CDN Fingerprinting
3
+ * webgl-forensics skill | Enhancement #2
4
+ *
5
+ * Run via: evaluate_script (paste entire IIFE)
6
+ * Purpose: Map the network delivery architecture:
7
+ * - CDN provider identification from headers
8
+ * - Critical rendering path (what blocks first paint)
9
+ * - External origin analysis
10
+ * - Cache strategy
11
+ * - Assets worth replicating vs. avoiding
12
+ */
13
+ (() => {
14
+ const result = {
15
+ url: window.location.href,
16
+ delivery: {},
17
+ criticalPath: [],
18
+ externalOrigins: {},
19
+ heaviestByType: {},
20
+ performanceBudget: {},
21
+ recommendations: [],
22
+ };
23
+
24
+ // ═══ RESOURCE TIMING API ═══
25
+ const resources = performance.getEntriesByType('resource');
26
+
27
+ if (!resources.length) {
28
+ return { error: 'No resource timing data. Try running after full page load.' };
29
+ }
30
+
31
+ // ═══ CDN FINGERPRINTING ═══
32
+ // Can't read response headers in JS (CORS), but can infer from resource URLs
33
+ const cdnPatterns = [
34
+ { name: 'Vercel Edge', pattern: /vercel\.app|vercel\.com|v\.cdn\.vercel\.com/i },
35
+ { name: 'Cloudflare', pattern: /cloudflare|workers\.dev|pages\.dev/i },
36
+ { name: 'Fastly', pattern: /fastly\.net/i },
37
+ { name: 'AWS CloudFront', pattern: /cloudfront\.net/i },
38
+ { name: 'Netlify', pattern: /netlify\.app|netlify\.com/i },
39
+ { name: 'Akamai', pattern: /akamai\.net|akamaized\.net/i },
40
+ { name: 'jsDelivr', pattern: /jsdelivr\.net/i },
41
+ { name: 'UNPKG', pattern: /unpkg\.com/i },
42
+ { name: 'Google CDN', pattern: /googleapis\.com|gstatic\.com/i },
43
+ { name: 'Bunny CDN', pattern: /b-cdn\.net|bunnycdn\.com/i },
44
+ ];
45
+
46
+ const detectedCDNs = new Set();
47
+ resources.forEach(r => {
48
+ cdnPatterns.forEach(({ name, pattern }) => {
49
+ if (pattern.test(r.name)) detectedCDNs.add(name);
50
+ });
51
+ });
52
+
53
+ // Check host itself
54
+ cdnPatterns.forEach(({ name, pattern }) => {
55
+ if (pattern.test(window.location.hostname)) detectedCDNs.add(name + ' (host)');
56
+ });
57
+
58
+ result.delivery.cdns = [...detectedCDNs];
59
+ result.delivery.protocol = performance.getEntriesByType('navigation')[0]?.nextHopProtocol || 'unknown';
60
+ result.delivery.serviceWorker = !!navigator.serviceWorker?.controller;
61
+
62
+ // ═══ RESOURCE BREAKDOWN ═══
63
+ const byType = {};
64
+ resources.forEach(r => {
65
+ const type = r.initiatorType;
66
+ if (!byType[type]) byType[type] = { count: 0, totalKB: 0, items: [] };
67
+ byType[type].count++;
68
+ byType[type].totalKB += (r.transferSize / 1024);
69
+ byType[type].items.push({
70
+ name: r.name.split('/').pop().slice(0, 50),
71
+ size: (r.transferSize / 1024).toFixed(1) + 'KB',
72
+ duration: r.duration.toFixed(0) + 'ms',
73
+ cached: r.transferSize === 0,
74
+ });
75
+ });
76
+
77
+ // Sort by size, keep top 5 per type
78
+ Object.keys(byType).forEach(type => {
79
+ byType[type].totalKB = byType[type].totalKB.toFixed(1) + 'KB';
80
+ byType[type].items = byType[type].items
81
+ .sort((a, b) => parseFloat(b.size) - parseFloat(a.size))
82
+ .slice(0, 5);
83
+ });
84
+
85
+ result.heaviestByType = byType;
86
+
87
+ // ═══ CRITICAL RENDERING PATH ═══
88
+ // Resources loaded before first paint are render-blocking
89
+ const fcp = performance.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint');
90
+ const fcpTime = fcp?.startTime || Infinity;
91
+
92
+ result.criticalPath = resources
93
+ .filter(r => r.startTime < fcpTime && r.transferSize > 0)
94
+ .sort((a, b) => a.startTime - b.startTime)
95
+ .slice(0, 15)
96
+ .map(r => ({
97
+ name: r.name.split('/').pop().slice(0, 60),
98
+ type: r.initiatorType,
99
+ startTime: r.startTime.toFixed(0) + 'ms',
100
+ duration: r.duration.toFixed(0) + 'ms',
101
+ size: (r.transferSize / 1024).toFixed(1) + 'KB',
102
+ renderBlocking: r.renderBlockingStatus === 'blocking' || r.initiatorType === 'link',
103
+ }));
104
+
105
+ // ═══ EXTERNAL ORIGINS ═══
106
+ const origins = {};
107
+ resources.forEach(r => {
108
+ try {
109
+ const origin = new URL(r.name).origin;
110
+ if (origin !== window.location.origin) {
111
+ if (!origins[origin]) origins[origin] = { count: 0, totalKB: 0 };
112
+ origins[origin].count++;
113
+ origins[origin].totalKB += r.transferSize / 1024;
114
+ }
115
+ } catch(e) {}
116
+ });
117
+
118
+ result.externalOrigins = Object.fromEntries(
119
+ Object.entries(origins).sort((a, b) => b[1].totalKB - a[1].totalKB)
120
+ );
121
+
122
+ // ═══ PERFORMANCE BUDGET CHECK ═══
123
+ const totalJS = resources.filter(r => r.initiatorType === 'script')
124
+ .reduce((acc, r) => acc + r.transferSize, 0) / 1024;
125
+ const totalCSS = resources.filter(r => r.initiatorType === 'link' || r.initiatorType === 'css')
126
+ .reduce((acc, r) => acc + r.transferSize, 0) / 1024;
127
+ const totalImages = resources.filter(r => r.initiatorType === 'img')
128
+ .reduce((acc, r) => acc + r.transferSize, 0) / 1024;
129
+ const total3D = resources.filter(r => /\.(glb|gltf|obj|fbx|draco|basis)/i.test(r.name))
130
+ .reduce((acc, r) => acc + r.transferSize, 0) / 1024;
131
+ const totalFonts = resources.filter(r => r.initiatorType === 'font' || /\.(woff2?|ttf|otf)/i.test(r.name))
132
+ .reduce((acc, r) => acc + r.transferSize, 0) / 1024;
133
+
134
+ result.performanceBudget = {
135
+ javascript: { actual: totalJS.toFixed(1) + 'KB', budget: '500KB', over: totalJS > 500 },
136
+ css: { actual: totalCSS.toFixed(1) + 'KB', budget: '100KB', over: totalCSS > 100 },
137
+ images: { actual: totalImages.toFixed(1) + 'KB', budget: '1000KB', over: totalImages > 1000 },
138
+ fonts: { actual: totalFonts.toFixed(1) + 'KB', budget: '150KB', over: totalFonts > 150 },
139
+ '3dAssets': { actual: total3D.toFixed(1) + 'KB', budget: '2000KB', over: total3D > 2000 },
140
+ total: ((totalJS + totalCSS + totalImages + totalFonts + total3D)).toFixed(1) + 'KB',
141
+ };
142
+
143
+ // ═══ CLONE RECOMMENDATIONS ═══
144
+ if (totalJS > 1000) result.recommendations.push('⚠️ JS bundle > 1MB — investigate with source map extractor to find what\'s large');
145
+ if (total3D > 0) result.recommendations.push(`✅ 3D assets found (${total3D.toFixed(1)}KB) — download and optimize for clone`);
146
+ if (totalFonts > 300) result.recommendations.push('⚠️ Heavy fonts — consider subsetting or using variable fonts in clone');
147
+ if (result.delivery.serviceWorker) result.recommendations.push('🔧 Service Worker detected — implement for offline functionality in clone');
148
+ if (detectedCDNs.size === 0) result.recommendations.push('ℹ️ No major CDN detected — self-hosted or unknown delivery');
149
+
150
+ return result;
151
+ })()