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,316 @@
1
+ /**
2
+ * SCRIPT 17 — R3F Fiber Serializer
3
+ * webgl-forensics skill | Enhancement #6
4
+ *
5
+ * PURPOSE:
6
+ * Convert the live React Fiber tree (extracted by script 13) into
7
+ * copy-pastable JSX code that reconstructs the R3F scene.
8
+ *
9
+ * Script 13 gives you DATA. This gives you CODE.
10
+ *
11
+ * OUTPUT:
12
+ * - Valid JSX for the full R3F scene graph
13
+ * - Component imports needed
14
+ * - Material definitions
15
+ * - Camera + renderer config
16
+ * - Recommended file structure for clone
17
+ *
18
+ * RUN VIA:
19
+ * evaluate_script — run AFTER script 13 has been run
20
+ * (or run standalone — it re-walks the fiber tree internally)
21
+ *
22
+ * REQUIRES:
23
+ * React + R3F must be on the page (same as script 13)
24
+ */
25
+ (() => {
26
+ // ═══════════════════════════════════════════════
27
+ // FIBER UTILITIES (duplicated from 13 for standalone use)
28
+ // ═══════════════════════════════════════════════
29
+ function findFiber(el) {
30
+ const key = Object.keys(el || {}).find(k =>
31
+ k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
32
+ );
33
+ return key ? el[key] : null;
34
+ }
35
+
36
+ const R3F_TAGS = new Set([
37
+ 'mesh', 'group', 'points', 'line', 'lineSegments', 'instancedMesh',
38
+ 'skinnedMesh', 'sprite', 'bone', 'lod', 'object3d',
39
+ 'meshStandardMaterial', 'meshPhysicalMaterial', 'meshBasicMaterial',
40
+ 'meshLambertMaterial', 'meshNormalMaterial', 'meshMatcapMaterial',
41
+ 'shaderMaterial', 'rawShaderMaterial', 'spriteMaterial',
42
+ 'boxGeometry', 'sphereGeometry', 'planeGeometry', 'cylinderGeometry',
43
+ 'torusGeometry', 'torusKnotGeometry', 'bufferGeometry', 'coneGeometry',
44
+ 'tetrahedronGeometry', 'icosahedronGeometry', 'octahedronGeometry',
45
+ 'ambientLight', 'directionalLight', 'pointLight', 'spotLight',
46
+ 'rectAreaLight', 'hemisphereLight',
47
+ 'perspectiveCamera', 'orthographicCamera',
48
+ 'primitive', 'canvas',
49
+ ]);
50
+
51
+ const DREI_COMPONENTS = new Set([
52
+ 'OrbitControls', 'TrackballControls', 'TransformControls',
53
+ 'Environment', 'Sky', 'Stars', 'Sparkles', 'Cloud',
54
+ 'MeshTransmissionMaterial', 'MeshReflectorMaterial', 'MeshWobbleMaterial',
55
+ 'Html', 'Billboard', 'Text', 'Text3D',
56
+ 'useGLTF', 'useLoader', 'useFBX', 'useAnimations',
57
+ 'ScrollControls', 'Scroll',
58
+ 'Preload', 'BakeShadows', 'AdaptiveDpr', 'AdaptiveEvents',
59
+ 'Bounds', 'Center', 'Resize', 'Float', 'Sampler',
60
+ 'Instances', 'Merged', 'Clone',
61
+ 'Image', 'Video', 'Texture',
62
+ 'Decal', 'Shadow', 'ContactShadows', 'AccumulativeShadows',
63
+ 'Lightformer', 'Caustics',
64
+ 'GizmoHelper', 'GizmoViewport',
65
+ 'Loader', 'Hud',
66
+ ]);
67
+
68
+ // ═══════════════════════════════════════════════
69
+ // VALUE SERIALIZER — props → JSX string
70
+ // ═══════════════════════════════════════════════
71
+ function serializeValue(val, key) {
72
+ if (val === null || val === undefined) return null;
73
+ if (typeof val === 'boolean') return val ? key : `${key}={false}`;
74
+ if (typeof val === 'string') return `${key}="${val}"`;
75
+ if (typeof val === 'number') return `${key}={${val}}`;
76
+ if (typeof val === 'function') return `${key}={${val.name ? val.name : '() => {}'}}`;
77
+ if (Array.isArray(val)) {
78
+ if (val.length <= 4 && val.every(v => typeof v === 'number')) {
79
+ return `${key}={[${val.map(v => parseFloat(v.toFixed(4))).join(', ')}]}`;
80
+ }
81
+ return `${key}={/* array[${val.length}] */}`;
82
+ }
83
+ if (val && typeof val === 'object') {
84
+ if (val.isColor) return `${key}="${val.getHexString ? '#' + val.getHexString() : val}"`;
85
+ if (val.isVector3) return `${key}={[${val.x.toFixed(3)}, ${val.y.toFixed(3)}, ${val.z.toFixed(3)}]}`;
86
+ if (val.isEuler) return `${key}={[${val.x.toFixed(3)}, ${val.y.toFixed(3)}, ${val.z.toFixed(3)}]}`;
87
+ return `${key}={/* ${Object.keys(val).slice(0,3).join(', ')} */}`;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // ═══════════════════════════════════════════════
93
+ // JSX COMPONENT NAME — map fiber type to JSX tag
94
+ // ═══════════════════════════════════════════════
95
+ function getFiberJSXName(fiber) {
96
+ const { type } = fiber;
97
+ if (typeof type === 'string') {
98
+ // R3F intrinsic elements: capitalize for JSX readability... actually they're lowercase
99
+ if (R3F_TAGS.has(type)) return type; // <mesh>, <group>, etc. are lowercase in R3F
100
+ return type;
101
+ }
102
+ if (typeof type === 'function') {
103
+ return type.displayName || type.name || 'Unknown';
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ═══════════════════════════════════════════════
109
+ // GENERATE JSX for a single fiber node
110
+ // ═══════════════════════════════════════════════
111
+ function serializeNode(fiber, depth = 0, imports = { r3f: new Set(), drei: new Set() }) {
112
+ if (!fiber || depth > 40) return '';
113
+ const indent = ' '.repeat(depth);
114
+ const name = getFiberJSXName(fiber);
115
+ if (!name) return '';
116
+
117
+ const isR3F = R3F_TAGS.has(name);
118
+ const isDrei = DREI_COMPONENTS.has(name);
119
+ const isCanvas = name === 'Canvas';
120
+ const isDOM = !isR3F && !isDrei && !isCanvas && typeof fiber.type === 'string';
121
+
122
+ // Skip most DOM elements — only keep Canvas wrapper divs
123
+ if (isDOM && !['div', 'section', 'main', 'canvas'].includes(name)) return '';
124
+
125
+ // Track imports
126
+ if (isDrei) imports.drei.add(name);
127
+ if (isCanvas) imports.r3f.add('Canvas');
128
+
129
+ // Serialize props
130
+ const props = fiber.memoizedProps || {};
131
+ const propStrings = [];
132
+
133
+ const SKIP_PROPS = new Set(['children', 'ref', 'key', '__self', '__source']);
134
+
135
+ for (const [key, val] of Object.entries(props)) {
136
+ if (SKIP_PROPS.has(key)) continue;
137
+ if (key.startsWith('on')) {
138
+ propStrings.push(`${key}={handleEvent}`);
139
+ continue;
140
+ }
141
+ const serialized = serializeValue(val, key);
142
+ if (serialized) propStrings.push(serialized);
143
+ }
144
+
145
+ // Serialize children recursively
146
+ let childrenJSX = '';
147
+ if (fiber.child) {
148
+ const childLines = [];
149
+ let child = fiber.child;
150
+ while (child) {
151
+ const childStr = serializeNode(child, depth + 1, imports);
152
+ if (childStr.trim()) childLines.push(childStr);
153
+ child = child.sibling;
154
+ }
155
+ childrenJSX = childLines.join('\n');
156
+ }
157
+
158
+ // Build JSX
159
+ const propsStr = propStrings.length
160
+ ? '\n' + propStrings.map(p => `${indent} ${p}`).join('\n') + '\n' + indent
161
+ : '';
162
+
163
+ if (!childrenJSX.trim()) {
164
+ return `${indent}<${name}${propsStr ? propsStr : ' '}/>`; // self-closing
165
+ }
166
+
167
+ return `${indent}<${name}${propsStr}>\n${childrenJSX}\n${indent}</${name}>`;
168
+ }
169
+
170
+ // ═══════════════════════════════════════════════
171
+ // FIND R3F ENTRY POINT — the <Canvas> component
172
+ // ═══════════════════════════════════════════════
173
+ function findCanvasFiber() {
174
+ for (const canvas of document.querySelectorAll('canvas')) {
175
+ const fiber = findFiber(canvas);
176
+ if (!fiber) continue;
177
+
178
+ // Walk up to find Canvas component
179
+ let node = fiber;
180
+ let depth = 0;
181
+ while (node && depth < 60) {
182
+ if (typeof node.type === 'function') {
183
+ const name = node.type.displayName || node.type.name || '';
184
+ if (name === 'Canvas' || name.includes('Canvas')) {
185
+ return node;
186
+ }
187
+ }
188
+ node = node.return;
189
+ depth++;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ // ═══════════════════════════════════════════════
196
+ // CANVAS CONFIG — extract R3F Canvas props
197
+ // ═══════════════════════════════════════════════
198
+ function extractCanvasProps(canvasFiber) {
199
+ if (!canvasFiber) return {};
200
+ const props = canvasFiber.memoizedProps || {};
201
+ return {
202
+ camera: props.camera,
203
+ frameloop: props.frameloop || 'always',
204
+ dpr: props.dpr,
205
+ shadows: props.shadows,
206
+ gl: props.gl ? Object.keys(props.gl) : undefined,
207
+ linear: props.linear,
208
+ flat: props.flat,
209
+ orthographic: props.orthographic,
210
+ eventSource: props.eventSource ? 'eventSource={ref}' : undefined,
211
+ style: props.style,
212
+ };
213
+ }
214
+
215
+ // ═══════════════════════════════════════════════
216
+ // GENERATE COMPLETE FILE
217
+ // ═══════════════════════════════════════════════
218
+ const imports = { r3f: new Set(['Canvas']), drei: new Set() };
219
+ const canvasFiber = findCanvasFiber();
220
+
221
+ if (!canvasFiber) {
222
+ return {
223
+ error: 'No R3F Canvas found. Run script 13 first to confirm R3F is present.',
224
+ hint: 'Try scrolling to the 3D section of the page then re-running.',
225
+ };
226
+ }
227
+
228
+ const canvasProps = extractCanvasProps(canvasFiber);
229
+ const sceneJSX = canvasFiber.child ? serializeNode(canvasFiber.child, 3, imports) : '';
230
+
231
+ // Build import statements
232
+ const r3fImports = imports.r3f.size ? `import { ${[...imports.r3f].join(', ')} } from '@react-three/fiber'` : '';
233
+ const dreiImports = imports.drei.size ? `import { ${[...imports.drei].join(', ')} } from '@react-three/drei'` : '';
234
+
235
+ // Build Canvas prop string
236
+ const canvasPropStr = Object.entries(canvasProps)
237
+ .filter(([_, v]) => v !== undefined && v !== null)
238
+ .map(([k, v]) => {
239
+ if (typeof v === 'boolean') return v ? k : `${k}={false}`;
240
+ if (typeof v === 'string') return `${k}="${v}"`;
241
+ if (Array.isArray(v)) return `${k}={[${v.join(', ')}]}`;
242
+ if (typeof v === 'object') return `${k}={${JSON.stringify(v)}}`;
243
+ return `${k}={${v}}`;
244
+ })
245
+ .map(p => ` ${p}`)
246
+ .join('\n');
247
+
248
+ const output = `// ═══════════════════════════════════════════════════
249
+ // R3F Scene — Serialized by webgl-forensics v2.0
250
+ // Source: ${window.location.href}
251
+ // Generated: ${new Date().toISOString()}
252
+ // ═══════════════════════════════════════════════════
253
+
254
+ ${r3fImports}
255
+ ${dreiImports}
256
+ import * as THREE from 'three'
257
+
258
+ // TODO: Add your scene-specific imports (models, textures, etc.)
259
+
260
+ export default function Scene() {
261
+ return (
262
+ <Canvas
263
+ ${canvasPropStr}
264
+ style={{ position: 'fixed', inset: 0 }}
265
+ >
266
+ ${sceneJSX || ' {/* Scene content — check script 13 output for R3F nodes */}'}
267
+ </Canvas>
268
+ )
269
+ }
270
+
271
+ /* ═══════════════════════════════════════════════════
272
+ USAGE NOTES:
273
+
274
+ 1. Install deps:
275
+ pnpm add @react-three/fiber @react-three/drei three
276
+ pnpm add -D @types/three
277
+
278
+ 2. Place this component in your layout or page
279
+
280
+ 3. Required CSS on parent element:
281
+ position: relative;
282
+ width: 100%;
283
+ height: 100vh;
284
+
285
+ 4. If using ScrollControls (Drei):
286
+ Wrap content in <Scroll> inside ScrollControls
287
+
288
+ 5. Three.js object refs: use useRef() and attach via ref prop
289
+ ═══════════════════════════════════════════════════ */
290
+ `;
291
+
292
+ return {
293
+ meta: {
294
+ url: window.location.href,
295
+ timestamp: new Date().toISOString(),
296
+ r3fImports: [...imports.r3f],
297
+ dreiImports: [...imports.drei],
298
+ canvasProps,
299
+ sceneNodeCount: (sceneJSX.match(/<[a-z]/g) || []).length,
300
+ },
301
+ jsx: output,
302
+ fileStructure: {
303
+ recommended: [
304
+ 'src/components/canvas/Scene.tsx ← paste jsx above here',
305
+ 'src/components/canvas/index.ts ← re-export Scene',
306
+ 'src/hooks/useScroll.ts ← R3F scroll hook',
307
+ 'src/shaders/ ← GLSL from script 14',
308
+ 'public/models/ ← GLB files',
309
+ ],
310
+ install: [
311
+ 'pnpm add @react-three/fiber @react-three/drei three',
312
+ 'pnpm add -D @types/three',
313
+ ],
314
+ },
315
+ };
316
+ })()
@@ -0,0 +1,290 @@
1
+ /**
2
+ * SCRIPT 18 — Font Extractor
3
+ * webgl-forensics skill | Enhancement #8
4
+ *
5
+ * PURPOSE:
6
+ * Extract every font used on the site:
7
+ * - Family names, weights, styles, formats
8
+ * - Source URLs (download-ready)
9
+ * - Unicode ranges and subsets
10
+ * - Variable font axes (weight, width, optical size, etc.)
11
+ * - Which elements use which fonts
12
+ * - Self-hosted vs. Google Fonts vs. Adobe Fonts vs. other CDN
13
+ * - CSS @font-face reconstruction for cloning
14
+ *
15
+ * RUN VIA:
16
+ * evaluate_script — run after page is fully loaded
17
+ *
18
+ * OUTPUT:
19
+ * - Complete @font-face CSS to paste into clone
20
+ * - Download URLs for self-hosted fonts
21
+ * - Google Fonts API URL if Google Fonts detected
22
+ * - Variable font axis ranges for dynamic typography
23
+ */
24
+ (() => {
25
+ const result = {
26
+ meta: {
27
+ url: window.location.href,
28
+ timestamp: new Date().toISOString(),
29
+ totalFontsFound: 0,
30
+ },
31
+ fonts: [],
32
+ variableFonts: [],
33
+ usedComputedFonts: [],
34
+ sourceBreakdown: {
35
+ selfHosted: [],
36
+ googleFonts: [],
37
+ adobeFonts: [],
38
+ cdnFonts: [],
39
+ systemFonts: [],
40
+ },
41
+ reconstructionCSS: '',
42
+ downloadInstructions: [],
43
+ };
44
+
45
+ // ═══════════════════════════════════════════════
46
+ // STEP 1 — Parse @font-face rules from all stylesheets
47
+ // ═══════════════════════════════════════════════
48
+ const fontFaceRules = [];
49
+
50
+ for (const sheet of document.styleSheets) {
51
+ try {
52
+ const rules = [...sheet.cssRules || []];
53
+ rules.forEach(rule => {
54
+ if (rule instanceof CSSFontFaceRule) {
55
+ fontFaceRules.push(rule);
56
+ }
57
+ });
58
+ } catch(e) {
59
+ // Cross-origin stylesheet — can't read rules
60
+ if (sheet.href) {
61
+ result.fonts.push({
62
+ source: sheet.href,
63
+ accessible: false,
64
+ note: 'Cross-origin stylesheet — check Network tab for font requests',
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════
71
+ // STEP 2 — Extract font metadata from @font-face rules
72
+ // ═══════════════════════════════════════════════
73
+ const processedFamilies = new Map();
74
+
75
+ fontFaceRules.forEach((rule, idx) => {
76
+ const style = rule.style;
77
+ const family = style.getPropertyValue('font-family')?.replace(/['"]/g, '').trim();
78
+ const src = style.getPropertyValue('src') || '';
79
+ const weight = style.getPropertyValue('font-weight') || '400';
80
+ const fontStyle = style.getPropertyValue('font-style') || 'normal';
81
+ const display = style.getPropertyValue('font-display') || 'auto';
82
+ const unicodeRange = style.getPropertyValue('unicode-range') || '';
83
+ const variationSettings = style.getPropertyValue('font-variation-settings') || '';
84
+
85
+ // Extract URLs from src
86
+ const urlMatches = [...src.matchAll(/url\(['"]?([^'")\s]+)['"]?\)/g)];
87
+ const formatMatches = [...src.matchAll(/format\(['"]?([^'")\s]+)['"]?\)/g)];
88
+
89
+ const sources = urlMatches.map((match, i) => ({
90
+ url: match[1],
91
+ format: formatMatches[i]?.[1] || 'unknown',
92
+ isAbsolute: match[1].startsWith('http'),
93
+ fullUrl: match[1].startsWith('http')
94
+ ? match[1]
95
+ : new URL(match[1], window.location.href).href,
96
+ }));
97
+
98
+ // Classify source
99
+ let sourceType = 'self-hosted';
100
+ const allUrls = sources.map(s => s.url).join(' ');
101
+ if (/fonts\.googleapis\.com|fonts\.gstatic\.com/i.test(allUrls + src)) sourceType = 'google-fonts';
102
+ else if (/use\.typekit\.net|typekit\.com|adobe/i.test(allUrls + src)) sourceType = 'adobe-fonts';
103
+ else if (/fonts\.bunny\.net|bunnyfonts/i.test(allUrls + src)) sourceType = 'bunny-fonts';
104
+ else if (/cdnfonts\.com|fontawesome|fontcdn/i.test(allUrls + src)) sourceType = 'cdn';
105
+
106
+ // Check for variable font (weight range like "100 900")
107
+ const isVariable = /\d+\s+\d+/.test(weight) || variationSettings.includes('wght');
108
+ const weightRange = isVariable
109
+ ? weight.split(/\s+/).map(Number).filter(Boolean)
110
+ : [parseInt(weight, 10)];
111
+
112
+ const fontEntry = {
113
+ id: idx,
114
+ family,
115
+ weight,
116
+ weightRange,
117
+ style: fontStyle,
118
+ display,
119
+ unicodeRange: unicodeRange || 'all',
120
+ isVariable,
121
+ variationSettings,
122
+ sourceType,
123
+ sources,
124
+ raw: rule.cssText.slice(0, 500),
125
+ };
126
+
127
+ result.fonts.push(fontEntry);
128
+
129
+ // Group by family
130
+ if (!processedFamilies.has(family)) {
131
+ processedFamilies.set(family, { family, weights: [], styles: [], sourceType, sources: [], isVariable });
132
+ }
133
+ const fam = processedFamilies.get(family);
134
+ fam.weights.push(weight);
135
+ fam.styles.push(fontStyle);
136
+ fam.sources.push(...sources);
137
+ if (isVariable) fam.isVariable = true;
138
+
139
+ // Source breakdown
140
+ if (sourceType === 'google-fonts') result.sourceBreakdown.googleFonts.push(family);
141
+ else if (sourceType === 'adobe-fonts') result.sourceBreakdown.adobeFonts.push(family);
142
+ else if (sourceType === 'cdn' || sourceType === 'bunny-fonts') result.sourceBreakdown.cdnFonts.push(family);
143
+ else result.sourceBreakdown.selfHosted.push(family);
144
+
145
+ if (isVariable) result.variableFonts.push({ family, weightRange, variationSettings });
146
+ });
147
+
148
+ // ═══════════════════════════════════════════════
149
+ // STEP 3 — Detect fonts via computed styles
150
+ // (catches system fonts and fonts not declared in @font-face)
151
+ // ═══════════════════════════════════════════════
152
+ const computedFontMap = new Map();
153
+ const sampleElements = [
154
+ document.body,
155
+ document.querySelector('h1'),
156
+ document.querySelector('h2'),
157
+ document.querySelector('p'),
158
+ document.querySelector('a'),
159
+ document.querySelector('button'),
160
+ document.querySelector('nav'),
161
+ document.querySelector('.hero, [class*="hero"]'),
162
+ document.querySelector('[class*="title"]'),
163
+ document.querySelector('[class*="heading"]'),
164
+ ].filter(Boolean);
165
+
166
+ sampleElements.forEach(el => {
167
+ const computed = window.getComputedStyle(el);
168
+ const fontFamily = computed.fontFamily;
169
+ const fontSize = computed.fontSize;
170
+ const fontWeight = computed.fontWeight;
171
+ const letterSpacing = computed.letterSpacing;
172
+ const lineHeight = computed.lineHeight;
173
+
174
+ const tag = el.tagName.toLowerCase();
175
+ const cls = el.className && typeof el.className === 'string'
176
+ ? '.' + el.className.trim().split(/\s+/)[0]
177
+ : '';
178
+
179
+ if (!computedFontMap.has(fontFamily)) {
180
+ computedFontMap.set(fontFamily, {
181
+ fontFamily,
182
+ elements: [],
183
+ sizes: new Set(),
184
+ weights: new Set(),
185
+ });
186
+ }
187
+
188
+ const entry = computedFontMap.get(fontFamily);
189
+ entry.elements.push(`${tag}${cls}`);
190
+ entry.sizes.add(fontSize);
191
+ entry.weights.add(fontWeight);
192
+ });
193
+
194
+ result.usedComputedFonts = [...computedFontMap.values()].map(f => ({
195
+ ...f,
196
+ sizes: [...f.sizes],
197
+ weights: [...f.weights],
198
+ isSystemFont: !result.fonts.some(rf => rf.family === f.fontFamily.replace(/['"]/g, '').trim()),
199
+ }));
200
+
201
+ // System fonts
202
+ result.usedComputedFonts
203
+ .filter(f => f.isSystemFont)
204
+ .forEach(f => result.sourceBreakdown.systemFonts.push(f.fontFamily));
205
+
206
+ // ═══════════════════════════════════════════════
207
+ // STEP 4 — Font loading API status
208
+ // ═══════════════════════════════════════════════
209
+ const loadedFonts = [];
210
+ if (document.fonts) {
211
+ for (const font of document.fonts) {
212
+ loadedFonts.push({
213
+ family: font.family,
214
+ weight: font.weight,
215
+ style: font.style,
216
+ status: font.status, // 'loaded' | 'loading' | 'error'
217
+ unicodeRange: font.unicodeRange,
218
+ });
219
+ }
220
+ }
221
+ result.fontFaceAPI = {
222
+ total: loadedFonts.length,
223
+ loaded: loadedFonts.filter(f => f.status === 'loaded').length,
224
+ loading: loadedFonts.filter(f => f.status === 'loading').length,
225
+ error: loadedFonts.filter(f => f.status === 'error').length,
226
+ fonts: loadedFonts,
227
+ };
228
+
229
+ // ═══════════════════════════════════════════════
230
+ // STEP 5 — Reconstruct @font-face CSS
231
+ // ═══════════════════════════════════════════════
232
+ const cssLines = [
233
+ '/* ══════════════════════════════════════════════',
234
+ ` Fonts — webgl-forensics extraction`,
235
+ ` Source: ${window.location.href}`,
236
+ '══════════════════════════════════════════════ */',
237
+ '',
238
+ ];
239
+
240
+ result.fonts.forEach(font => {
241
+ if (font.sources.length === 0) return;
242
+
243
+ const srcParts = font.sources
244
+ .map(s => `url('${s.fullUrl}') format('${s.format}')`)
245
+ .join(',\n ');
246
+
247
+ cssLines.push('@font-face {');
248
+ cssLines.push(` font-family: '${font.family}';`);
249
+ cssLines.push(` font-weight: ${font.weight};`);
250
+ if (font.style !== 'normal') cssLines.push(` font-style: ${font.style};`);
251
+ cssLines.push(` font-display: ${font.display || 'swap'};`);
252
+ if (font.unicodeRange && font.unicodeRange !== 'all') cssLines.push(` unicode-range: ${font.unicodeRange};`);
253
+ cssLines.push(` src: ${srcParts};`);
254
+ cssLines.push('}');
255
+ cssLines.push('');
256
+ });
257
+
258
+ result.reconstructionCSS = cssLines.join('\n');
259
+
260
+ // ═══════════════════════════════════════════════
261
+ // STEP 6 — Download instructions
262
+ // ═══════════════════════════════════════════════
263
+ const selfHostedFonts = result.fonts.filter(f => f.sourceType === 'self-hosted');
264
+ if (selfHostedFonts.length > 0) {
265
+ result.downloadInstructions.push('# Self-Hosted Fonts — Copy these files to /public/fonts/');
266
+ selfHostedFonts.forEach(font => {
267
+ font.sources.forEach(src => {
268
+ result.downloadInstructions.push(`curl -o "public/fonts/${src.url.split('/').pop()}" "${src.fullUrl}"`);
269
+ });
270
+ });
271
+ }
272
+
273
+ const googleFamilies = [...new Set(result.sourceBreakdown.googleFonts)];
274
+ if (googleFamilies.length > 0) {
275
+ result.downloadInstructions.push('');
276
+ result.downloadInstructions.push('# Google Fonts — Add to Next.js layout.tsx:');
277
+ result.downloadInstructions.push(`import { ${googleFamilies.map(f => f.replace(/\s+/g, '_')).join(', ')} } from 'next/font/google'`);
278
+ googleFamilies.forEach(family => {
279
+ const varName = family.replace(/\s+/g, '_').toLowerCase();
280
+ const weights = [...new Set(result.fonts.filter(f => f.family === family).map(f => f.weight))];
281
+ result.downloadInstructions.push(`const ${varName} = ${family.replace(/\s+/g, '_')}({ weight: [${weights.map(w => `'${w}'`).join(', ')}], subsets: ['latin'] })`);
282
+ });
283
+ }
284
+
285
+ result.meta.totalFontsFound = result.fonts.length;
286
+ result.meta.familiesFound = processedFamilies.size;
287
+ result.meta.variableFontCount = result.variableFonts.length;
288
+
289
+ return result;
290
+ })()
@@ -0,0 +1,85 @@
1
+ const extractDesignTokens = () => {
2
+ const tokens = {
3
+ cssVariables: {},
4
+ sampledColors: [],
5
+ tailwindScaffold: { extend: { colors: {}, fontFamily: {}, spacing: {} } }
6
+ };
7
+
8
+ const rootStyles = getComputedStyle(document.documentElement);
9
+
10
+ const extractFromRule = (rule) => {
11
+ for (let i = 0; i < rule.style.length; i++) {
12
+ const prop = rule.style[i];
13
+ if (prop.startsWith('--')) {
14
+ tokens.cssVariables[prop] = rootStyles.getPropertyValue(prop).trim();
15
+ }
16
+ }
17
+ };
18
+
19
+ const scanStyleSheets = () => {
20
+ try {
21
+ for (let i = 0; i < document.styleSheets.length; i++) {
22
+ const sheet = document.styleSheets[i];
23
+ if (!sheet.href || sheet.href.startsWith(window.location.origin)) {
24
+ for (let j = 0; j < sheet.cssRules.length; j++) {
25
+ const rule = sheet.cssRules[j];
26
+ if (rule.type === 1 && (rule.selectorText === ':root' || rule.selectorText === 'body' || rule.selectorText === 'html')) {
27
+ extractFromRule(rule);
28
+ }
29
+ }
30
+ }
31
+ }
32
+ } catch (e) {
33
+ // Ignore CORS errors
34
+ }
35
+ };
36
+
37
+ scanStyleSheets();
38
+
39
+ // If no variables found, fallback to inline styles on document body
40
+ if (Object.keys(tokens.cssVariables).length === 0 && document.body) {
41
+ if (document.body.style) {
42
+ extractFromRule({ style: document.body.style });
43
+ }
44
+ // Search inline style tags for :root
45
+ const inlineStyles = document.querySelectorAll('style');
46
+ inlineStyles.forEach(style => {
47
+ const text = style.textContent;
48
+ const matches = text.match(/--[\w-]+:[^;]+;/g);
49
+ if (matches) {
50
+ matches.forEach(m => {
51
+ const [key, val] = m.split(':');
52
+ if (key && val) tokens.cssVariables[key.trim()] = val.replace(';', '').trim();
53
+ });
54
+ }
55
+ });
56
+ }
57
+
58
+ // Sample colors from DOM
59
+ const colorSet = new Set();
60
+ const els = document.querySelectorAll('h1, h2, p, a, button, div, section, header, footer');
61
+ els.forEach(el => {
62
+ const style = getComputedStyle(el);
63
+ if (style.color !== 'rgba(0, 0, 0, 0)') colorSet.add(style.color);
64
+ if (style.backgroundColor !== 'rgba(0, 0, 0, 0)' && !style.backgroundColor.includes('transparent')) colorSet.add(style.backgroundColor);
65
+ });
66
+ tokens.sampledColors = Array.from(colorSet);
67
+
68
+ // Generate Tailwind Scaffold
69
+ for (const [key, value] of Object.entries(tokens.cssVariables)) {
70
+ const cleanKey = key.replace('--', '');
71
+ if (cleanKey.includes('color') || cleanKey.includes('text') || cleanKey.includes('bg')) {
72
+ const name = cleanKey.replace('color-', '').replace('bg-', '').replace('text-', '');
73
+ tokens.tailwindScaffold.extend.colors[name] = `var(${key})`;
74
+ } else if (cleanKey.includes('font')) {
75
+ const name = cleanKey.replace('font-', '').replace('family-', '');
76
+ tokens.tailwindScaffold.extend.fontFamily[name] = `var(${key})`;
77
+ } else if (cleanKey.includes('space') || cleanKey.includes('gap') || cleanKey.includes('pad')) {
78
+ tokens.tailwindScaffold.extend.spacing[cleanKey] = `var(${key})`;
79
+ }
80
+ }
81
+
82
+ return tokens;
83
+ };
84
+
85
+ return extractDesignTokens();