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,61 @@
1
+ function generateTimelineHtml(timelineData) {
2
+ if (!timelineData || !Array.isArray(timelineData) || timelineData.length === 0) {
3
+ return '<div>No timeline data to visualize.</div>';
4
+ }
5
+
6
+ const itemsHtml = timelineData.map((item, idx) => {
7
+ let duration = item.duration || 1;
8
+ let delay = item.delay || 0;
9
+ let width = Math.max(10, duration * 100);
10
+ let left = delay * 100;
11
+
12
+ return `
13
+ <div class="timeline-row">
14
+ <div class="timeline-label">
15
+ <span class="element-target">${item.targets || 'Unknown'}</span>
16
+ <span class="element-props">${Object.keys(item.vars || {}).join(', ')}</span>
17
+ </div>
18
+ <div class="timeline-track">
19
+ <div class="timeline-bar"
20
+ style="width: ${width}px; left: ${left}px;"
21
+ title="Target: ${item.targets}\nDuration: ${duration}s\nDelay: ${delay}s\nVars: ${JSON.stringify(item.vars)}">
22
+ ${Object.keys(item.vars || {}).length > 0 ? '✨' : ''}
23
+ </div>
24
+ </div>
25
+ </div>
26
+ `;
27
+ }).join('');
28
+
29
+ return `
30
+ <!DOCTYPE html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="UTF-8">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
35
+ <title>GSAP Timeline Visualizer</title>
36
+ <style>
37
+ body { background: #0f172a; color: #f8fafc; font-family: ui-sans-serif, system-ui, sans-serif; padding: 2rem; }
38
+ h1 { font-size: 1.5rem; margin-bottom: 1rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
39
+ .timeline-container { border: 1px solid #334155; background: #1e293b; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
40
+ .timeline-row { display: flex; border-bottom: 1px solid #334155; }
41
+ .timeline-row:last-child { border-bottom: none; }
42
+ .timeline-label { width: 300px; padding: 0.75rem; border-right: 1px solid #334155; background: #0f172a; flex-shrink: 0; }
43
+ .element-target { display: block; font-weight: 600; font-size: 0.875rem; color: #38bdf8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44
+ .element-props { display: block; font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; }
45
+ .timeline-track { flex-grow: 1; position: relative; padding: 0.75rem 0; overflow-x: auto; background: repeating-linear-gradient(90deg, #1e293b, #1e293b 99px, #334155 99px, #334155 100px); }
46
+ .timeline-bar { position: relative; height: 100%; background: linear-gradient(90deg, #8b5cf6, #d946ef); border-radius: 4px; box-shadow: 0 0 10px rgba(217, 70, 239, 0.4); min-height: 20px; transition: transform 0.2s; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 10px; }
47
+ .timeline-bar:hover { transform: scaleY(1.2); }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <h1>Animation Timeline Map</h1>
52
+ <div class="timeline-container">
53
+ ${itemsHtml}
54
+ </div>
55
+ <p style="margin-top: 1rem; color: #64748b; font-size: 0.875rem;">Scale: 100px = 1 second. Hover over bars for exact property details.</p>
56
+ </body>
57
+ </html>
58
+ `;
59
+ }
60
+
61
+ module.exports = generateTimelineHtml;
@@ -0,0 +1,35 @@
1
+ const lighthouse = require('lighthouse');
2
+
3
+ /**
4
+ * Runs a Lighthouse audit against the URL using the existing Puppeteer browser port.
5
+ */
6
+ async function runAudit(url, port) {
7
+ const options = {
8
+ logLevel: 'error',
9
+ output: 'json',
10
+ onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
11
+ port: port,
12
+ disableStorageReset: true // don't wipe out the cookies if we accumulated any
13
+ };
14
+
15
+ try {
16
+ const runnerResult = await lighthouse(url, options);
17
+ return {
18
+ scores: {
19
+ performance: Math.round(runnerResult.lhr.categories.performance.score * 100),
20
+ accessibility: Math.round(runnerResult.lhr.categories.accessibility.score * 100),
21
+ bestPractices: Math.round(runnerResult.lhr.categories['best-practices'].score * 100),
22
+ seo: Math.round(runnerResult.lhr.categories.seo.score * 100)
23
+ },
24
+ metrics: {
25
+ lcp: runnerResult.lhr.audits['largest-contentful-paint']?.displayValue || 'N/A',
26
+ cls: runnerResult.lhr.audits['cumulative-layout-shift']?.displayValue || 'N/A',
27
+ tti: runnerResult.lhr.audits['interactive']?.displayValue || 'N/A'
28
+ }
29
+ };
30
+ } catch (e) {
31
+ return { error: e.message };
32
+ }
33
+ }
34
+
35
+ module.exports = runAudit;
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * webgl-forensics — Sitemap-Driven Crawler
4
+ * Script 22 — Parses /sitemap.xml or /sitemap_index.xml and returns a
5
+ * prioritized list of internal URLs. Runs INSIDE the browser context.
6
+ */
7
+
8
+ (() => {
9
+ // This script is designed to be eval'd by Puppeteer in the browser.
10
+ // It fetches the sitemap from the current origin and parses it.
11
+
12
+ const origin = window.location.origin;
13
+
14
+ async function fetchSitemap(url) {
15
+ try {
16
+ const res = await fetch(url, { headers: { 'Accept': 'application/xml, text/xml, */*' } });
17
+ if (!res.ok) return null;
18
+ const text = await res.text();
19
+ return text;
20
+ } catch (e) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function parseXml(xmlString) {
26
+ const parser = new DOMParser();
27
+ const doc = parser.parseFromString(xmlString, 'application/xml');
28
+ return doc;
29
+ }
30
+
31
+ function extractUrls(doc) {
32
+ // Try sitemapindex first (index of sitemaps)
33
+ const sitemapIndexEntries = doc.querySelectorAll('sitemapindex sitemap loc');
34
+ if (sitemapIndexEntries.length > 0) {
35
+ return {
36
+ type: 'sitemapindex',
37
+ sitemaps: [...sitemapIndexEntries].map(el => el.textContent.trim()),
38
+ urls: [],
39
+ };
40
+ }
41
+
42
+ // Regular URL set
43
+ const urlEntries = doc.querySelectorAll('urlset url');
44
+ const urls = [...urlEntries].map(el => ({
45
+ loc: el.querySelector('loc')?.textContent.trim() || '',
46
+ priority: parseFloat(el.querySelector('priority')?.textContent || '0.5'),
47
+ changefreq: el.querySelector('changefreq')?.textContent.trim() || 'monthly',
48
+ lastmod: el.querySelector('lastmod')?.textContent.trim() || null,
49
+ }))
50
+ .filter(u => u.loc.startsWith(origin))
51
+ .sort((a, b) => b.priority - a.priority);
52
+
53
+ // Categorize by path pattern
54
+ const categorized = {
55
+ homepage: urls.filter(u => new URL(u.loc).pathname === '/'),
56
+ work: urls.filter(u => /\/work|\/projects|\/portfolio|\/case/i.test(u.loc)),
57
+ about: urls.filter(u => /\/about|\/team|\/story/i.test(u.loc)),
58
+ contact: urls.filter(u => /\/contact|\/hire/i.test(u.loc)),
59
+ blog: urls.filter(u => /\/blog|\/posts|\/articles|\/news/i.test(u.loc)),
60
+ other: urls.filter(u =>
61
+ !/\/|\/work|\/projects|\/portfolio|\/case|\/about|\/team|\/story|\/contact|\/hire|\/blog|\/posts|\/articles|\/news/i.test(new URL(u.loc).pathname)
62
+ && new URL(u.loc).pathname !== '/'
63
+ ),
64
+ };
65
+
66
+ return {
67
+ type: 'urlset',
68
+ total: urls.length,
69
+ urls,
70
+ categorized,
71
+ topPriority: urls.slice(0, 10),
72
+ };
73
+ }
74
+
75
+ async function crawl() {
76
+ const result = {
77
+ origin,
78
+ sitemapFound: false,
79
+ sitemapUrl: null,
80
+ data: null,
81
+ fallback: null,
82
+ };
83
+
84
+ // Try common sitemap locations in order
85
+ const candidates = [
86
+ `${origin}/sitemap.xml`,
87
+ `${origin}/sitemap_index.xml`,
88
+ `${origin}/sitemap/sitemap.xml`,
89
+ `${origin}/page-sitemap.xml`,
90
+ ];
91
+
92
+ let rawXml = null;
93
+ for (const candidate of candidates) {
94
+ rawXml = await fetchSitemap(candidate);
95
+ if (rawXml && rawXml.includes('<urlset') || rawXml && rawXml.includes('<sitemapindex')) {
96
+ result.sitemapFound = true;
97
+ result.sitemapUrl = candidate;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (!rawXml || !result.sitemapFound) {
103
+ // Fallback: DOM discovery
104
+ const links = [...document.querySelectorAll('a[href]')]
105
+ .map(a => a.href)
106
+ .filter(h => h.startsWith(origin) && !h.includes('#') && !h.includes('?'))
107
+ .filter((v, i, a) => a.indexOf(v) === i);
108
+ result.fallback = { type: 'dom-discovery', urls: links.slice(0, 50), count: links.length };
109
+ return result;
110
+ }
111
+
112
+ const doc = parseXml(rawXml);
113
+ result.data = extractUrls(doc);
114
+
115
+ // If it's a sitemapindex, also fetch and parse the first child sitemap
116
+ if (result.data.type === 'sitemapindex' && result.data.sitemaps.length > 0) {
117
+ const childXml = await fetchSitemap(result.data.sitemaps[0]);
118
+ if (childXml) {
119
+ const childDoc = parseXml(childXml);
120
+ result.data.childSitemap = extractUrls(childDoc);
121
+ }
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ return crawl();
128
+ })()
@@ -0,0 +1,451 @@
1
+ /**
2
+ * webgl-forensics — Next.js Scaffold Generator (Script 23)
3
+ *
4
+ * Node.js module. Takes a forensics report and generates a complete
5
+ * Next.js 15 App Router starter with:
6
+ * - Pre-wired design tokens (CSS variables)
7
+ * - Component stubs for detected R3F/Three.js objects
8
+ * - GSAP setup with detected plugins
9
+ * - Lenis/scroll setup
10
+ * - Font imports
11
+ * - tsconfig, tailwind.config.ts, package.json
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ /**
18
+ * Extract key data from forensics report
19
+ */
20
+ function extractScaffoldData(report) {
21
+ const phases = report?.meta?.phases || {};
22
+ const tech = phases['tech-stack'] || {};
23
+ const tokens = phases['design-token-export'] || {};
24
+ const fonts = phases['font-extractor'] || {};
25
+ const gsap = phases['gsap-timeline'] || {};
26
+ const r3f = phases['r3f-serializer'] || {};
27
+ const fiber = phases['react-fiber-walker'] || {};
28
+ const scroll = phases['interaction-model'] || {};
29
+ const loading = phases['loading-sequence'] || {};
30
+ const sitemap = phases['sitemap-crawler'] || {};
31
+ const complexity = phases['complexity-scorer'] || {};
32
+
33
+ // Detect scroll library
34
+ const hasLenis = tech?.scroll?.lenis;
35
+ const hasLocomotive = tech?.scroll?.locomotive;
36
+ const hasGSAP = tech?.animations?.gsap;
37
+ const hasScrollTrigger = tech?.animations?.scrollTrigger;
38
+ const hasR3F = tech?.libraries?.r3f || r3f?.componentCount > 0;
39
+ const hasThree = tech?.libraries?.three;
40
+ const hasFramer = tech?.animations?.framerMotion;
41
+ const framework = tech?.framework?.name || 'Next.js';
42
+
43
+ // Extract site pages from sitemap
44
+ const pages = [];
45
+ if (sitemap?.data?.categorized) {
46
+ const cat = sitemap.data.categorized;
47
+ if (cat.homepage?.length) pages.push({ name: 'Home', path: '/', url: cat.homepage[0]?.loc });
48
+ if (cat.work?.length) pages.push({ name: 'Work', path: '/work', url: cat.work[0]?.loc });
49
+ if (cat.about?.length) pages.push({ name: 'About', path: '/about', url: cat.about[0]?.loc });
50
+ if (cat.contact?.length) pages.push({ name: 'Contact', path: '/contact', url: cat.contact[0]?.loc });
51
+ } else {
52
+ pages.push({ name: 'Home', path: '/' });
53
+ }
54
+
55
+ // Extract colors
56
+ const colors = (tokens?.colors || []).slice(0, 15);
57
+ const cssVars = (tokens?.cssVariables || []).slice(0, 40);
58
+
59
+ // Extract fonts
60
+ const webFonts = fonts?.webFonts || [];
61
+
62
+ // Extract R3F components
63
+ const r3fComponents = r3f?.components || [];
64
+
65
+ // GSAP plugins
66
+ const gsapPlugins = [];
67
+ if (hasScrollTrigger) gsapPlugins.push('ScrollTrigger');
68
+ if (tech?.animations?.scrollSmoother) gsapPlugins.push('ScrollSmoother');
69
+ if ((gsap?.tweens || []).some(t => t.vars?.includes('split') || t.vars?.includes('chars'))) gsapPlugins.push('SplitText');
70
+
71
+ return {
72
+ url: report?.meta?.url,
73
+ domain: report?.meta?.domain,
74
+ complexity: complexity?.score,
75
+ hasLenis, hasLocomotive, hasGSAP, hasScrollTrigger, hasR3F, hasThree, hasFramer,
76
+ gsapPlugins, pages, colors, cssVars, webFonts, r3fComponents, loading,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Generate package.json for the scaffold
82
+ */
83
+ function genPackageJson(data) {
84
+ const deps = {
85
+ 'next': '15.0.0',
86
+ 'react': '19.0.0',
87
+ 'react-dom': '19.0.0',
88
+ };
89
+
90
+ if (data.hasGSAP) deps['gsap'] = '^3.12.5';
91
+ if (data.hasLenis) deps['@studio-freight/lenis'] = '^1.0.42';
92
+ if (data.hasLocomotive) deps['locomotive-scroll'] = '^4.1.4';
93
+ if (data.hasR3F) {
94
+ deps['@react-three/fiber'] = '^8.17.0';
95
+ deps['@react-three/drei'] = '^9.111.0';
96
+ deps['three'] = '^0.167.0';
97
+ }
98
+ if (data.hasFramer) deps['framer-motion'] = '^11.0.0';
99
+
100
+ return JSON.stringify({
101
+ name: data.domain?.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'webgl-clone',
102
+ version: '0.1.0',
103
+ private: true,
104
+ scripts: {
105
+ dev: 'next dev',
106
+ build: 'next build',
107
+ start: 'next start',
108
+ lint: 'next lint',
109
+ },
110
+ dependencies: deps,
111
+ devDependencies: {
112
+ '@types/node': '^20',
113
+ '@types/react': '^19',
114
+ '@types/react-dom': '^19',
115
+ '@types/three': '^0.167.0',
116
+ 'typescript': '^5',
117
+ 'tailwindcss': '^4.0.0',
118
+ '@tailwindcss/typography': '^0.5.15',
119
+ 'postcss': '^8',
120
+ },
121
+ }, null, 2);
122
+ }
123
+
124
+ /**
125
+ * Generate CSS variables from design tokens
126
+ */
127
+ function genTokensCss(data) {
128
+ const lines = [':root {'];
129
+
130
+ for (const { name, value } of data.cssVars) {
131
+ lines.push(` ${name}: ${value};`);
132
+ }
133
+
134
+ // Add commonly needed fallbacks
135
+ const colorNames = data.cssVars.map(v => v.name);
136
+ if (!colorNames.includes('--background')) lines.push(' --background: #000000;');
137
+ if (!colorNames.includes('--foreground')) lines.push(' --foreground: #ffffff;');
138
+ if (!colorNames.includes('--accent')) lines.push(' --accent: #4f46e5;');
139
+
140
+ lines.push('}');
141
+ lines.push('');
142
+ lines.push('* { box-sizing: border-box; margin: 0; padding: 0; }');
143
+ lines.push('html { background: var(--background); color: var(--foreground); }');
144
+ lines.push('body { font-family: var(--font-sans, system-ui, sans-serif); }');
145
+
146
+ return lines.join('\n');
147
+ }
148
+
149
+ /**
150
+ * Generate GSAP setup file
151
+ */
152
+ function genGsapSetup(data) {
153
+ const imports = [`import gsap from 'gsap';`];
154
+ const registers = [];
155
+
156
+ for (const plugin of data.gsapPlugins) {
157
+ imports.push(`import { ${plugin} } from 'gsap/dist/${plugin}';`);
158
+ registers.push(` gsap.registerPlugin(${plugin});`);
159
+ }
160
+
161
+ return `${imports.join('\n')}
162
+
163
+ export function initGSAP() {
164
+ ${registers.join('\n') || ' // No plugins detected — add as needed'}
165
+
166
+ // Global defaults
167
+ gsap.defaults({ ease: 'power2.out', duration: 0.8 });
168
+ }
169
+
170
+ export { gsap };
171
+ ${data.hasScrollTrigger ? '\nexport { ScrollTrigger };' : ''}
172
+ `;
173
+ }
174
+
175
+ /**
176
+ * Generate Lenis smooth scroll setup
177
+ */
178
+ function genLenisSetup() {
179
+ return `'use client';
180
+ import { useEffect } from 'react';
181
+ import Lenis from '@studio-freight/lenis';
182
+ import { gsap } from './gsap-setup';
183
+ import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
184
+
185
+ let lenis: Lenis | null = null;
186
+
187
+ export function initLenis() {
188
+ lenis = new Lenis({
189
+ duration: 1.2,
190
+ easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
191
+ orientation: 'vertical',
192
+ gestureOrientation: 'vertical',
193
+ smoothWheel: true,
194
+ wheelMultiplier: 1,
195
+ touchMultiplier: 2,
196
+ });
197
+
198
+ // Sync with GSAP ticker
199
+ gsap.ticker.add((time) => lenis?.raf(time * 1000));
200
+ gsap.ticker.lagSmoothing(0);
201
+
202
+ // Sync with ScrollTrigger
203
+ lenis.on('scroll', ScrollTrigger.update);
204
+ }
205
+
206
+ export function useLenis() {
207
+ useEffect(() => {
208
+ initLenis();
209
+ return () => {
210
+ lenis?.destroy();
211
+ lenis = null;
212
+ };
213
+ }, []);
214
+ }
215
+ `;
216
+ }
217
+
218
+ /**
219
+ * Generate a basic R3F canvas component
220
+ */
221
+ function genCanvasComponent(data) {
222
+ const hasOrbit = data.r3fComponents?.some(c => /orbit|controls/i.test(c));
223
+
224
+ return `'use client';
225
+ import { Canvas } from '@react-three/fiber';
226
+ import { Suspense } from 'react';
227
+ ${hasOrbit ? "import { OrbitControls } from '@react-three/drei';" : ''}
228
+
229
+ // TODO: Replace with actual detected scene components
230
+ function Scene() {
231
+ return (
232
+ <>
233
+ <ambientLight intensity={0.5} />
234
+ <directionalLight position={[10, 10, 5]} intensity={1} />
235
+ <mesh>
236
+ <torusKnotGeometry args={[1, 0.3, 128, 32]} />
237
+ <meshStandardMaterial color="#4f46e5" />
238
+ </mesh>
239
+ ${hasOrbit ? '<OrbitControls />' : ''}
240
+ </>
241
+ );
242
+ }
243
+
244
+ export function WebGLCanvas() {
245
+ return (
246
+ <Canvas
247
+ camera={{ position: [0, 0, 5], fov: 75 }}
248
+ gl={{ antialias: true, alpha: true }}
249
+ dpr={[1, 2]}
250
+ style={{ position: 'fixed', inset: 0, zIndex: 0 }}
251
+ >
252
+ <Suspense fallback={null}>
253
+ <Scene />
254
+ </Suspense>
255
+ </Canvas>
256
+ );
257
+ }
258
+ `;
259
+ }
260
+
261
+ /**
262
+ * Generate the main layout.tsx
263
+ */
264
+ function genLayout(data) {
265
+ const fontImports = data.webFonts
266
+ .filter(f => f.provider === 'google' || !f.provider)
267
+ .slice(0, 3)
268
+ .map(f => ` // ${f.family} — found on target site`);
269
+
270
+ return `import type { Metadata } from 'next';
271
+ import './globals.css';
272
+
273
+ export const metadata: Metadata = {
274
+ title: 'Clone — ${data.domain}',
275
+ description: 'Rebuilt from webgl-forensics analysis',
276
+ };
277
+
278
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
279
+ ${fontImports.length ? fontImports.join('\n') + '\n' : ''} return (
280
+ <html lang="en">
281
+ <body>{children}</body>
282
+ </html>
283
+ );
284
+ }
285
+ `;
286
+ }
287
+
288
+ /**
289
+ * Generate a page stub for each detected page
290
+ */
291
+ function genPageStub(pageName) {
292
+ return `export default function ${pageName}Page() {
293
+ return (
294
+ <main>
295
+ <h1>${pageName}</h1>
296
+ {/* TODO: Reconstruct from forensics report */}
297
+ </main>
298
+ );
299
+ }
300
+ `;
301
+ }
302
+
303
+ /**
304
+ * Generate the loading/preloader component
305
+ */
306
+ function genLoader(data) {
307
+ return `'use client';
308
+ import { useEffect, useState } from 'react';
309
+
310
+ export function Preloader() {
311
+ const [visible, setVisible] = useState(true);
312
+ const [progress, setProgress] = useState(0);
313
+
314
+ useEffect(() => {
315
+ // Simulate load progress
316
+ const interval = setInterval(() => {
317
+ setProgress(prev => {
318
+ if (prev >= 100) {
319
+ clearInterval(interval);
320
+ setTimeout(() => setVisible(false), 400);
321
+ return 100;
322
+ }
323
+ return prev + Math.random() * 15;
324
+ });
325
+ }, 80);
326
+
327
+ return () => clearInterval(interval);
328
+ }, []);
329
+
330
+ if (!visible) return null;
331
+
332
+ return (
333
+ <div style={{
334
+ position: 'fixed', inset: 0, zIndex: 9999,
335
+ background: 'var(--background, #000)',
336
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
337
+ transition: 'opacity 0.4s',
338
+ opacity: progress >= 100 ? 0 : 1,
339
+ }}>
340
+ <div style={{ textAlign: 'center' }}>
341
+ <div style={{ fontSize: '2rem', fontVariantNumeric: 'tabular-nums' }}>
342
+ {Math.round(Math.min(progress, 100))}%
343
+ </div>
344
+ </div>
345
+ </div>
346
+ );
347
+ }
348
+ `;
349
+ }
350
+
351
+ /**
352
+ * Write all scaffold files to disk
353
+ */
354
+ function writeScaffold(scaffoldDir, data) {
355
+ const write = (filePath, content) => {
356
+ const fullPath = path.join(scaffoldDir, filePath);
357
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
358
+ fs.writeFileSync(fullPath, content, 'utf-8');
359
+ };
360
+
361
+ // Package and tsconfig
362
+ write('package.json', genPackageJson(data));
363
+ write('tsconfig.json', JSON.stringify({
364
+ compilerOptions: {
365
+ target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'],
366
+ allowJs: true, skipLibCheck: true, strict: true, noEmit: true,
367
+ esModuleInterop: true, module: 'esnext', moduleResolution: 'bundler',
368
+ resolveJsonModule: true, isolatedModules: true, jsx: 'preserve',
369
+ incremental: true, plugins: [{ name: 'next' }],
370
+ paths: { '@/*': ['./src/*'] },
371
+ },
372
+ include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
373
+ exclude: ['node_modules'],
374
+ }, null, 2));
375
+
376
+ // next.config.ts
377
+ write('next.config.ts', `import type { NextConfig } from 'next';
378
+ const config: NextConfig = { reactStrictMode: true };
379
+ export default config;
380
+ `);
381
+
382
+ // Styles
383
+ write('src/app/globals.css', genTokensCss(data));
384
+
385
+ // Layout
386
+ write('src/app/layout.tsx', genLayout(data));
387
+
388
+ // Pages
389
+ for (const page of data.pages) {
390
+ const dir = page.path === '/' ? 'src/app' : `src/app${page.path}`;
391
+ write(`${dir}/page.tsx`, genPageStub(page.name));
392
+ }
393
+
394
+ // Lib
395
+ if (data.hasGSAP) write('src/lib/gsap-setup.ts', genGsapSetup(data));
396
+ if (data.hasLenis) write('src/lib/lenis.ts', genLenisSetup());
397
+ if (data.hasR3F) write('src/components/canvas.tsx', genCanvasComponent(data));
398
+ if (data.loading?.hasPreloader) write('src/components/preloader.tsx', genLoader(data));
399
+
400
+ // README stub
401
+ write('README.md', `# ${data.domain} Clone
402
+
403
+ Generated by [webgl-forensics](https://github.com/404kidwiz/webgl-forensics) v3.1.
404
+
405
+ ## Source
406
+ ${data.url}
407
+
408
+ ## Detected Stack
409
+ - Framework: ${data.hasR3F ? 'Next.js + R3F' : 'Next.js'}
410
+ - Animations: ${[data.hasGSAP && 'GSAP', data.hasFramer && 'Framer Motion'].filter(Boolean).join(', ') || 'None'}
411
+ - Scroll: ${[data.hasLenis && 'Lenis', data.hasLocomotive && 'Locomotive'].filter(Boolean).join(', ') || 'Native'}
412
+ - Complexity Score: ${data.complexity || 'N/A'}/100
413
+
414
+ ## Setup
415
+ \`\`\`bash
416
+ npm install
417
+ npm run dev
418
+ \`\`\`
419
+ `);
420
+ }
421
+
422
+ /**
423
+ * Main export
424
+ */
425
+ function generateScaffold(report, outputDir) {
426
+ const data = extractScaffoldData(report);
427
+ const scaffoldDir = path.join(outputDir, 'scaffold');
428
+ fs.mkdirSync(scaffoldDir, { recursive: true });
429
+
430
+ writeScaffold(scaffoldDir, data);
431
+
432
+ const files = [];
433
+ function walk(dir) {
434
+ for (const f of fs.readdirSync(dir)) {
435
+ const full = path.join(dir, f);
436
+ if (fs.statSync(full).isDirectory()) walk(full);
437
+ else files.push(path.relative(scaffoldDir, full));
438
+ }
439
+ }
440
+ walk(scaffoldDir);
441
+
442
+ return {
443
+ scaffoldDir,
444
+ filesGenerated: files.length,
445
+ files,
446
+ pages: data.pages.map(p => p.path),
447
+ dependencies: Object.keys(JSON.parse(fs.readFileSync(path.join(scaffoldDir, 'package.json'))).dependencies),
448
+ };
449
+ }
450
+
451
+ module.exports = generateScaffold;