smippo 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smippo",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "S.M.I.P.P.O. — Structured Mirroring of Internet Pages and Public Objects. Modern website copier that captures sites exactly as they appear in your browser.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -130,6 +130,11 @@ export function run() {
130
130
  '--no-reveal-all',
131
131
  'Disable force-reveal of scroll-triggered content',
132
132
  )
133
+ .option(
134
+ '--reduced-motion',
135
+ 'Use prefers-reduced-motion for accessibility (default: true)',
136
+ )
137
+ .option('--no-reduced-motion', 'Disable reduced motion preference')
133
138
  .option('--user-agent <string>', 'Custom user agent')
134
139
  .option('--viewport <WxH>', 'Viewport size', '1920x1080')
135
140
  .option('--device <name>', 'Emulate device (e.g., "iPhone 13")')
@@ -256,6 +261,18 @@ export function run() {
256
261
  });
257
262
  });
258
263
 
264
+ // Delete command - remove captured sites
265
+ program
266
+ .command('delete')
267
+ .alias('rm')
268
+ .description('Delete captured sites from local storage')
269
+ .option('-a, --all', 'Delete all captured sites')
270
+ .option('-y, --yes', 'Skip confirmation prompt')
271
+ .action(async options => {
272
+ const {deleteSites} = await import('./delete.js');
273
+ await deleteSites(options);
274
+ });
275
+
259
276
  // Screenshot capture command
260
277
  program
261
278
  .command('capture <url>')
@@ -357,6 +374,7 @@ async function capture(url, options) {
357
374
  scrollDelay: parseInt(options.scrollDelay, 10),
358
375
  scrollBehavior: options.scrollBehavior,
359
376
  revealAll: options.revealAll,
377
+ reducedMotion: options.reducedMotion,
360
378
  userAgent: options.userAgent,
361
379
  viewport: parseViewport(options.viewport),
362
380
  device: options.device,
package/src/crawler.js CHANGED
@@ -276,6 +276,7 @@ export class Crawler extends EventEmitter {
276
276
  scrollDelay: this.options.scrollDelay,
277
277
  scrollBehavior: this.options.scrollBehavior,
278
278
  revealAll: this.options.revealAll,
279
+ reducedMotion: this.options.reducedMotion,
279
280
  });
280
281
 
281
282
  const result = await capture.capture(url);
package/src/delete.js ADDED
@@ -0,0 +1,188 @@
1
+ // @flow
2
+ import * as p from '@clack/prompts';
3
+ import chalk from 'chalk';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import {readGlobalManifest, writeGlobalManifest} from './utils/home.js';
7
+
8
+ /**
9
+ * Format file size for display
10
+ */
11
+ function formatSize(bytes) {
12
+ if (bytes < 1024) return `${bytes} B`;
13
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
14
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
15
+ }
16
+
17
+ /**
18
+ * Calculate directory size recursively
19
+ */
20
+ async function getDirectorySize(dirPath) {
21
+ let totalSize = 0;
22
+
23
+ try {
24
+ const entries = await fs.readdir(dirPath, {withFileTypes: true});
25
+ for (const entry of entries) {
26
+ const fullPath = path.join(dirPath, entry.name);
27
+ if (entry.isDirectory()) {
28
+ totalSize += await getDirectorySize(fullPath);
29
+ } else {
30
+ const stats = await fs.stat(fullPath);
31
+ totalSize += stats.size;
32
+ }
33
+ }
34
+ } catch (e) {
35
+ // Ignore errors
36
+ }
37
+
38
+ return totalSize;
39
+ }
40
+
41
+ /**
42
+ * Interactive delete command for captured sites
43
+ */
44
+ export async function deleteSites(options = {}) {
45
+ // Read global manifest
46
+ const manifest = await readGlobalManifest();
47
+
48
+ if (!manifest.sites || manifest.sites.length === 0) {
49
+ console.log(chalk.yellow('\nNo captured sites found.\n'));
50
+ return;
51
+ }
52
+
53
+ // Show header
54
+ console.log('');
55
+ p.intro(chalk.bgRed.white(' Delete Captured Sites '));
56
+
57
+ // Get site info with sizes
58
+ const sitesWithInfo = await Promise.all(
59
+ manifest.sites.map(async site => {
60
+ const exists = await fs.pathExists(site.path);
61
+ let size = 0;
62
+ if (exists) {
63
+ size = await getDirectorySize(site.path);
64
+ }
65
+ return {
66
+ ...site,
67
+ exists,
68
+ size,
69
+ displaySize: formatSize(size),
70
+ };
71
+ }),
72
+ );
73
+
74
+ // Filter to only existing sites
75
+ const existingSites = sitesWithInfo.filter(s => s.exists);
76
+
77
+ if (existingSites.length === 0) {
78
+ console.log(chalk.yellow('No captured sites found on disk.\n'));
79
+
80
+ // Clean up manifest
81
+ manifest.sites = [];
82
+ await writeGlobalManifest(manifest);
83
+ console.log(chalk.dim('Cleaned up manifest.\n'));
84
+ return;
85
+ }
86
+
87
+ let sitesToDelete = [];
88
+
89
+ if (options.all) {
90
+ // Delete all sites
91
+ sitesToDelete = existingSites;
92
+ } else {
93
+ // Interactive selection
94
+ const selected = await p.multiselect({
95
+ message: 'Select sites to delete (space to select, enter to confirm):',
96
+ options: existingSites.map(site => ({
97
+ value: site,
98
+ label: `${site.domain}`,
99
+ hint: `${site.displaySize} • ${site.path}`,
100
+ })),
101
+ required: false,
102
+ });
103
+
104
+ if (p.isCancel(selected) || !selected || selected.length === 0) {
105
+ p.cancel('No sites selected.');
106
+ return;
107
+ }
108
+
109
+ sitesToDelete = selected;
110
+ }
111
+
112
+ // Show what will be deleted
113
+ console.log('');
114
+ console.log(chalk.bold('Sites to delete:'));
115
+ for (const site of sitesToDelete) {
116
+ console.log(chalk.red(` • ${site.domain} (${site.displaySize})`));
117
+ console.log(chalk.dim(` ${site.path}`));
118
+ }
119
+ console.log('');
120
+
121
+ // Calculate total size
122
+ const totalSize = sitesToDelete.reduce((sum, s) => sum + s.size, 0);
123
+ console.log(
124
+ chalk.bold(
125
+ `Total: ${sitesToDelete.length} site(s), ${formatSize(totalSize)}`,
126
+ ),
127
+ );
128
+ console.log('');
129
+
130
+ // Confirmation
131
+ let confirmed = options.yes;
132
+ if (!confirmed) {
133
+ confirmed = await p.confirm({
134
+ message: 'Are you sure you want to delete these sites?',
135
+ initialValue: false,
136
+ });
137
+ }
138
+
139
+ if (p.isCancel(confirmed) || !confirmed) {
140
+ p.cancel('Deletion cancelled.');
141
+ return;
142
+ }
143
+
144
+ // Delete the sites
145
+ const spinner = p.spinner();
146
+ spinner.start('Deleting sites...');
147
+
148
+ let deletedCount = 0;
149
+ let failedCount = 0;
150
+ const deletedDomains = new Set();
151
+
152
+ for (const site of sitesToDelete) {
153
+ try {
154
+ await fs.remove(site.path);
155
+ deletedCount++;
156
+ deletedDomains.add(site.domain);
157
+ } catch (error) {
158
+ failedCount++;
159
+ console.error(
160
+ chalk.red(`\n Failed to delete ${site.domain}: ${error.message}`),
161
+ );
162
+ }
163
+ }
164
+
165
+ // Update manifest - remove deleted sites
166
+ manifest.sites = manifest.sites.filter(
167
+ s =>
168
+ !deletedDomains.has(s.domain) ||
169
+ !sitesToDelete.some(d => d.path === s.path),
170
+ );
171
+ await writeGlobalManifest(manifest);
172
+
173
+ spinner.stop('Deletion complete!');
174
+
175
+ // Summary
176
+ console.log('');
177
+ if (deletedCount > 0) {
178
+ console.log(
179
+ chalk.green(
180
+ `✓ Deleted ${deletedCount} site(s), freed ${formatSize(totalSize)}`,
181
+ ),
182
+ );
183
+ }
184
+ if (failedCount > 0) {
185
+ console.log(chalk.red(`✗ Failed to delete ${failedCount} site(s)`));
186
+ }
187
+ console.log('');
188
+ }
@@ -3,6 +3,8 @@ import {extractLinks} from './link-extractor.js';
3
3
 
4
4
  /**
5
5
  * Capture a single page with all its rendered content
6
+ * Uses best-in-class techniques including accessibility features,
7
+ * reduced motion, and human-like scrolling behavior
6
8
  */
7
9
  export class PageCapture {
8
10
  constructor(page, options = {}) {
@@ -22,6 +24,12 @@ export class PageCapture {
22
24
  await this._collectResource(response);
23
25
  });
24
26
 
27
+ // Step 0: Enable reduced motion to disable CSS animations (accessibility feature)
28
+ // This causes many sites to show final animation states immediately
29
+ if (this.options.reducedMotion !== false) {
30
+ await this.page.emulateMedia({reducedMotion: 'reduce'});
31
+ }
32
+
25
33
  // Navigate to the page
26
34
  try {
27
35
  await this.page.goto(url, {
@@ -41,22 +49,34 @@ export class PageCapture {
41
49
  await this.page.waitForTimeout(waitTime);
42
50
  }
43
51
 
44
- // Step 1: Force reveal all scroll-triggered content
52
+ // Step 1: Force all scroll-triggered animations to their END state (100% progress)
53
+ // This is different from killing them - we want their final visual state
45
54
  if (this.options.revealAll !== false) {
46
- await this._revealAllContent();
55
+ await this._completeAllAnimations();
47
56
  }
48
57
 
49
- // Step 2: Pre-scroll the page to trigger scroll animations
58
+ // Step 2: Human-like scrolling to trigger lazy content and intersection observers
50
59
  if (this.options.scroll !== false) {
51
- await this._scrollPage();
60
+ await this._humanLikeScroll();
52
61
  }
53
62
 
54
- // Step 3: Additional wait after scroll for animations to complete
63
+ // Step 3: Complete animations again after scroll (new elements may have appeared)
64
+ if (this.options.revealAll !== false) {
65
+ await this._completeAllAnimations();
66
+ }
67
+
68
+ // Step 4: Final wait for any remaining animations/network requests
55
69
  const scrollWait = this.options.scrollWait ?? 1000;
56
- if (scrollWait > 0 && this.options.scroll !== false) {
70
+ if (scrollWait > 0) {
57
71
  await this.page.waitForTimeout(scrollWait);
58
72
  }
59
73
 
74
+ // Step 5: Wait for network to be truly idle (no pending requests)
75
+ await this._waitForNetworkIdle();
76
+
77
+ // Step 6: Force reveal any remaining hidden elements
78
+ await this._forceRevealHiddenElements();
79
+
60
80
  // Get the rendered HTML
61
81
  const html = await this.page.content();
62
82
 
@@ -167,30 +187,41 @@ export class PageCapture {
167
187
  }
168
188
 
169
189
  /**
170
- * Pre-scroll the page to trigger scroll-based animations and lazy loading
171
- * Performs smooth, incremental scrolling to trigger all scroll-based content
190
+ * Wait for network to be truly idle (no pending requests for a period)
191
+ */
192
+ async _waitForNetworkIdle() {
193
+ try {
194
+ await this.page.waitForLoadState('networkidle', {timeout: 5000});
195
+ } catch {
196
+ // Timeout is ok, continue anyway
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Human-like scrolling behavior with pauses and mouse movements
202
+ * Triggers intersection observers and lazy loading more naturally
172
203
  */
173
- async _scrollPage() {
174
- const scrollBehavior = this.options.scrollBehavior || 'smooth';
175
- const scrollStep = this.options.scrollStep || 200; // pixels per step
176
- const scrollDelay = this.options.scrollDelay || 50; // ms between steps
204
+ async _humanLikeScroll() {
205
+ const scrollStep = this.options.scrollStep || 300;
206
+ const scrollDelay = this.options.scrollDelay || 100;
177
207
 
178
208
  /* eslint-disable no-undef */
179
209
  await this.page.evaluate(
180
- async ({step, delay, behavior}) => {
181
- // Helper for smooth scrolling with requestAnimationFrame
182
- const smoothScroll = (targetY, duration = 300) => {
210
+ async ({step, delay}) => {
211
+ // Human-like smooth scroll with easing
212
+ const smoothScrollTo = (targetY, duration = 500) => {
183
213
  return new Promise(resolve => {
184
214
  const startY = window.scrollY;
185
215
  const distance = targetY - startY;
186
216
  const startTime = performance.now();
187
217
 
188
- const animate = currentTime => {
189
- const elapsed = currentTime - startTime;
190
- const progress = Math.min(elapsed / duration, 1);
218
+ const easeInOutCubic = t =>
219
+ t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
191
220
 
192
- // Easing function (ease-out-cubic)
193
- const eased = 1 - Math.pow(1 - progress, 3);
221
+ const animate = () => {
222
+ const elapsed = performance.now() - startTime;
223
+ const progress = Math.min(elapsed / duration, 1);
224
+ const eased = easeInOutCubic(progress);
194
225
 
195
226
  window.scrollTo(0, startY + distance * eased);
196
227
 
@@ -200,80 +231,69 @@ export class PageCapture {
200
231
  resolve();
201
232
  }
202
233
  };
203
-
204
234
  requestAnimationFrame(animate);
205
235
  });
206
236
  };
207
237
 
208
- // Get initial page height
209
- let lastHeight = document.body.scrollHeight;
238
+ // Wait helper
239
+ const wait = ms => new Promise(r => setTimeout(r, ms));
240
+
241
+ // Get viewport and page dimensions
242
+ const viewportHeight = window.innerHeight;
243
+ let pageHeight = document.body.scrollHeight;
210
244
  let currentY = 0;
245
+ let lastHeight = pageHeight;
211
246
 
212
- // Phase 1: Scroll down incrementally
213
- while (currentY < document.body.scrollHeight) {
214
- const targetY = Math.min(currentY + step, document.body.scrollHeight);
247
+ // Phase 1: Scroll down slowly, pausing at each "section"
248
+ while (currentY < pageHeight) {
249
+ // Scroll one viewport at a time (like a human reading)
250
+ const targetY = Math.min(currentY + step, pageHeight);
251
+ await smoothScrollTo(targetY, delay * 3);
215
252
 
216
- if (behavior === 'smooth') {
217
- await smoothScroll(targetY, delay * 2);
253
+ // Pause longer at section boundaries (every viewport height)
254
+ if (
255
+ Math.floor(currentY / viewportHeight) !==
256
+ Math.floor(targetY / viewportHeight)
257
+ ) {
258
+ await wait(delay * 2); // Pause to "read" the section
218
259
  } else {
219
- window.scrollTo(0, targetY);
260
+ await wait(delay);
220
261
  }
221
262
 
222
263
  currentY = targetY;
223
- await new Promise(r => setTimeout(r, delay));
224
264
 
225
- // Check if page height increased (lazy content loaded)
265
+ // Check for lazy-loaded content increasing page height
226
266
  const newHeight = document.body.scrollHeight;
227
267
  if (newHeight > lastHeight) {
268
+ pageHeight = newHeight;
228
269
  lastHeight = newHeight;
229
270
  }
230
271
  }
231
272
 
232
- // Phase 2: Wait at bottom for any pending lazy loads
233
- await new Promise(r => setTimeout(r, 500));
273
+ // Phase 2: Ensure we're at the very bottom
274
+ await smoothScrollTo(document.body.scrollHeight, 300);
275
+ await wait(500); // Wait at bottom
234
276
 
235
- // Check if more content loaded while waiting
236
- if (document.body.scrollHeight > lastHeight) {
237
- // Scroll to the new bottom
238
- if (behavior === 'smooth') {
239
- await smoothScroll(document.body.scrollHeight, 300);
240
- } else {
241
- window.scrollTo(0, document.body.scrollHeight);
242
- }
243
- await new Promise(r => setTimeout(r, 300));
277
+ // Check one more time for lazy content
278
+ if (document.body.scrollHeight > pageHeight) {
279
+ await smoothScrollTo(document.body.scrollHeight, 300);
280
+ await wait(300);
244
281
  }
245
282
 
246
- // Phase 3: Scroll back up slowly (some sites have scroll-up animations)
247
- const scrollUpStep = step * 2; // Faster on the way up
248
- currentY = window.scrollY;
249
-
250
- while (currentY > 0) {
251
- const targetY = Math.max(currentY - scrollUpStep, 0);
252
-
253
- if (behavior === 'smooth') {
254
- await smoothScroll(targetY, delay);
255
- } else {
256
- window.scrollTo(0, targetY);
257
- }
258
-
259
- currentY = targetY;
260
- await new Promise(r => setTimeout(r, delay / 2));
261
- }
262
-
263
- // Phase 4: Return to top and wait
264
- window.scrollTo(0, 0);
265
- await new Promise(r => setTimeout(r, 200));
283
+ // Phase 3: Scroll back to top (faster)
284
+ await smoothScrollTo(0, 800);
285
+ await wait(300);
266
286
  },
267
- {step: scrollStep, delay: scrollDelay, behavior: scrollBehavior},
287
+ {step: scrollStep, delay: scrollDelay},
268
288
  );
269
289
  /* eslint-enable no-undef */
270
290
  }
271
291
 
272
292
  /**
273
- * Force reveal all scroll-triggered content by disabling/triggering
274
- * common animation libraries like GSAP ScrollTrigger, AOS, etc.
293
+ * Complete all animations to their final state (100% progress)
294
+ * Instead of killing animations, we progress them to completion
275
295
  */
276
- async _revealAllContent() {
296
+ async _completeAllAnimations() {
277
297
  /* eslint-disable no-undef */
278
298
  await this.page.evaluate(() => {
279
299
  // Helper to safely access nested properties
@@ -285,178 +305,224 @@ export class PageCapture {
285
305
  }
286
306
  };
287
307
 
288
- // 1. GSAP ScrollTrigger - kill all triggers and show content
289
- const ScrollTrigger = safeGet(window, 'ScrollTrigger');
290
- if (ScrollTrigger) {
308
+ // 1. GSAP - Progress ALL tweens and timelines to 100%
309
+ const gsap = safeGet(window, 'gsap');
310
+ if (gsap) {
291
311
  try {
292
- // Get all ScrollTrigger instances
293
- const triggers = ScrollTrigger.getAll?.() || [];
294
- triggers.forEach(trigger => {
312
+ // Get all global tweens and progress them to end
313
+ const tweens =
314
+ gsap.globalTimeline?.getChildren?.(true, true, true) || [];
315
+ tweens.forEach(tween => {
295
316
  try {
296
- // Kill the trigger to prevent it from hiding content
297
- trigger.kill?.();
317
+ // Progress to 100% (end state)
318
+ tween.progress?.(1, true);
319
+ // Also try totalProgress for timelines
320
+ tween.totalProgress?.(1, true);
298
321
  } catch (e) {
299
322
  /* ignore */
300
323
  }
301
324
  });
302
- // Refresh to ensure proper state
303
- ScrollTrigger.refresh?.();
304
325
  } catch (e) {
305
326
  /* ignore */
306
327
  }
307
328
  }
308
329
 
309
- // Also check for gsap.ScrollTrigger
310
- const gsapScrollTrigger = safeGet(window, 'gsap.ScrollTrigger');
311
- if (gsapScrollTrigger && gsapScrollTrigger !== ScrollTrigger) {
330
+ // 2. GSAP ScrollTrigger - Progress all triggers to their END state
331
+ const ScrollTrigger =
332
+ safeGet(window, 'ScrollTrigger') ||
333
+ safeGet(window, 'gsap.ScrollTrigger');
334
+ if (ScrollTrigger) {
312
335
  try {
313
- const triggers = gsapScrollTrigger.getAll?.() || [];
314
- triggers.forEach(trigger => trigger.kill?.());
336
+ const triggers = ScrollTrigger.getAll?.() || [];
337
+ triggers.forEach(trigger => {
338
+ try {
339
+ // Progress the trigger to 100% (fully scrolled past)
340
+ if (trigger.animation) {
341
+ trigger.animation.progress?.(1, true);
342
+ trigger.animation.totalProgress?.(1, true);
343
+ }
344
+ // Also set the trigger's own progress
345
+ trigger.progress?.(1, true);
346
+
347
+ // Disable the trigger so it doesn't reset
348
+ trigger.disable?.();
349
+ } catch (e) {
350
+ /* ignore */
351
+ }
352
+ });
353
+
354
+ // Don't refresh - we want to keep the end states
315
355
  } catch (e) {
316
356
  /* ignore */
317
357
  }
318
358
  }
319
359
 
320
- // 2. AOS (Animate On Scroll) - reveal all elements
321
- const AOS = safeGet(window, 'AOS');
322
- if (AOS) {
360
+ // 3. Web Animations API - Complete all animations
361
+ document.getAnimations?.().forEach(animation => {
323
362
  try {
324
- // Disable AOS and show all elements
325
- document.querySelectorAll('[data-aos]').forEach(el => {
326
- el.classList.add('aos-animate');
327
- el.style.opacity = '1';
328
- el.style.transform = 'none';
329
- el.style.visibility = 'visible';
330
- });
363
+ animation.finish();
331
364
  } catch (e) {
332
- /* ignore */
365
+ try {
366
+ animation.currentTime =
367
+ animation.effect?.getTiming?.()?.duration || 0;
368
+ } catch (e2) {
369
+ /* ignore */
370
+ }
333
371
  }
334
- }
372
+ });
335
373
 
336
- // 3. WOW.js - reveal all elements
374
+ // 4. AOS - Mark all as animated
375
+ document.querySelectorAll('[data-aos]').forEach(el => {
376
+ el.classList.add('aos-animate');
377
+ el.setAttribute('data-aos-animate', 'true');
378
+ });
379
+
380
+ // 5. WOW.js elements
337
381
  document.querySelectorAll('.wow').forEach(el => {
338
382
  el.classList.add('animated');
339
383
  el.style.visibility = 'visible';
384
+ });
385
+
386
+ // 6. ScrollReveal elements
387
+ document.querySelectorAll('[data-sr-id]').forEach(el => {
388
+ el.style.visibility = 'visible';
340
389
  el.style.opacity = '1';
341
- el.style.animationName = 'none';
342
390
  });
343
391
 
344
- // 4. ScrollReveal - reveal all elements
345
- const ScrollReveal = safeGet(window, 'ScrollReveal');
346
- if (ScrollReveal) {
392
+ // 7. Anime.js - Complete all animations
393
+ const anime = safeGet(window, 'anime');
394
+ if (anime?.running) {
395
+ anime.running.forEach(anim => {
396
+ try {
397
+ anim.seek?.(anim.duration);
398
+ } catch (e) {
399
+ /* ignore */
400
+ }
401
+ });
402
+ }
403
+
404
+ // 8. Lottie animations - Go to last frame
405
+ document.querySelectorAll('lottie-player, [data-lottie]').forEach(el => {
347
406
  try {
348
- document.querySelectorAll('[data-sr-id]').forEach(el => {
349
- el.style.visibility = 'visible';
350
- el.style.opacity = '1';
351
- el.style.transform = 'none';
352
- });
407
+ el.goToAndStop?.(el.totalFrames - 1, true);
408
+ el.pause?.();
353
409
  } catch (e) {
354
410
  /* ignore */
355
411
  }
356
- }
412
+ });
413
+ });
414
+ /* eslint-enable no-undef */
415
+ }
357
416
 
358
- // 5. Intersection Observer based lazy loading - trigger all observers
359
- // This is tricky since we can't access observers directly,
360
- // but we can trigger the elements they're watching
417
+ /**
418
+ * Force reveal any remaining hidden elements
419
+ * This is the final cleanup pass
420
+ */
421
+ async _forceRevealHiddenElements() {
422
+ /* eslint-disable no-undef */
423
+ await this.page.evaluate(() => {
424
+ // 1. Force load all lazy images
425
+ document.querySelectorAll('img').forEach(img => {
426
+ // Load from data attributes
427
+ const lazySrc =
428
+ img.dataset.src || img.dataset.lazy || img.dataset.lazySrc;
429
+ if (lazySrc && !img.src.includes(lazySrc)) {
430
+ img.src = lazySrc;
431
+ }
432
+ // Remove lazy loading attribute
433
+ img.removeAttribute('loading');
434
+ // Trigger load if not loaded
435
+ if (!img.complete) {
436
+ img.loading = 'eager';
437
+ }
438
+ });
361
439
 
362
- // 6. Generic fixes for common hidden patterns
363
- // Elements with opacity: 0 that are meant to fade in
364
- document
365
- .querySelectorAll('[style*="opacity: 0"], [style*="opacity:0"]')
366
- .forEach(el => {
367
- // Only reveal if it seems intentionally hidden for animation
368
- const computedStyle = window.getComputedStyle(el);
369
- if (
370
- computedStyle.opacity === '0' &&
371
- !el.hasAttribute('aria-hidden')
372
- ) {
373
- el.style.opacity = '1';
374
- }
375
- });
440
+ // 2. Load lazy srcset
441
+ document.querySelectorAll('source[data-srcset]').forEach(source => {
442
+ if (source.dataset.srcset) {
443
+ source.srcset = source.dataset.srcset;
444
+ }
445
+ });
376
446
 
377
- // Elements with visibility: hidden that may animate in
447
+ // 3. Load lazy background images
378
448
  document
379
449
  .querySelectorAll(
380
- '[style*="visibility: hidden"], [style*="visibility:hidden"]',
450
+ '[data-bg], [data-background], [data-background-image]',
381
451
  )
382
452
  .forEach(el => {
383
- el.style.visibility = 'visible';
384
- });
385
-
386
- // Elements with transform: translateY that slide in
387
- document.querySelectorAll('[style*="translateY"]').forEach(el => {
388
- const style = el.getAttribute('style') || '';
389
- // Only fix if it looks like a scroll animation starting position
390
- if (
391
- style.includes('translateY(') &&
392
- (style.includes('opacity') || el.classList.length > 0)
393
- ) {
394
- el.style.transform = 'none';
395
- }
396
- });
397
-
398
- // 7. Lazy-loaded images - force load
399
- document
400
- .querySelectorAll('img[data-src], img[data-lazy], img[loading="lazy"]')
401
- .forEach(img => {
402
- const src =
403
- img.getAttribute('data-src') || img.getAttribute('data-lazy');
404
- if (src && !img.src) {
405
- img.src = src;
453
+ const bg =
454
+ el.dataset.bg ||
455
+ el.dataset.background ||
456
+ el.dataset.backgroundImage;
457
+ if (bg) {
458
+ el.style.backgroundImage = `url("${bg}")`;
406
459
  }
407
- // Remove lazy loading to ensure images load
408
- img.removeAttribute('loading');
409
460
  });
410
461
 
411
- // 8. Lazy-loaded iframes
462
+ // 4. Load lazy iframes
412
463
  document.querySelectorAll('iframe[data-src]').forEach(iframe => {
413
- const src = iframe.getAttribute('data-src');
414
- if (src && !iframe.src) {
415
- iframe.src = src;
464
+ if (iframe.dataset.src) {
465
+ iframe.src = iframe.dataset.src;
416
466
  }
417
467
  });
418
468
 
419
- // 9. Picture elements with lazy loading
420
- document
421
- .querySelectorAll('picture source[data-srcset]')
422
- .forEach(source => {
423
- const srcset = source.getAttribute('data-srcset');
424
- if (srcset) {
425
- source.srcset = srcset;
469
+ // 5. Fix elements with opacity: 0 (likely animation end states)
470
+ document.querySelectorAll('*').forEach(el => {
471
+ const computed = window.getComputedStyle(el);
472
+
473
+ // Only fix visible containers that have hidden content
474
+ if (computed.opacity === '0' && !el.getAttribute('aria-hidden')) {
475
+ // Check if this is likely an animated element
476
+ const hasAnimClass =
477
+ el.className && /anim|fade|slide|reveal|show/i.test(el.className);
478
+ const hasAnimAttr =
479
+ el.hasAttribute('data-aos') ||
480
+ el.hasAttribute('data-animate') ||
481
+ el.hasAttribute('data-scroll');
482
+
483
+ if (hasAnimClass || hasAnimAttr || el.style.opacity === '0') {
484
+ el.style.setProperty('opacity', '1', 'important');
426
485
  }
427
- });
486
+ }
428
487
 
429
- // 10. Background images in data attributes
430
- document.querySelectorAll('[data-bg], [data-background]').forEach(el => {
431
- const bg =
432
- el.getAttribute('data-bg') || el.getAttribute('data-background');
433
- if (bg && !el.style.backgroundImage) {
434
- el.style.backgroundImage = `url(${bg})`;
488
+ // Fix visibility
489
+ if (
490
+ computed.visibility === 'hidden' &&
491
+ !el.getAttribute('aria-hidden')
492
+ ) {
493
+ const hasAnimClass =
494
+ el.className && /anim|fade|slide|reveal|show/i.test(el.className);
495
+ if (hasAnimClass || el.style.visibility === 'hidden') {
496
+ el.style.setProperty('visibility', 'visible', 'important');
497
+ }
435
498
  }
436
- });
437
499
 
438
- // 11. Lottie animations - try to advance to final state
439
- const lottieElements = document.querySelectorAll(
440
- 'lottie-player, [data-lottie]',
441
- );
442
- lottieElements.forEach(el => {
443
- try {
444
- if (el.goToAndStop) {
445
- el.goToAndStop(el.totalFrames - 1, true);
500
+ // Fix transforms that look like animation starting positions
501
+ if (computed.transform && computed.transform !== 'none') {
502
+ const transform = computed.transform;
503
+ // Check for translateY/translateX that might be animation starting positions
504
+ if (/translate[XY]\s*\(\s*[+-]?\d+/.test(transform)) {
505
+ const hasAnimClass =
506
+ el.className && /anim|fade|slide|reveal|show/i.test(el.className);
507
+ if (hasAnimClass) {
508
+ el.style.setProperty('transform', 'none', 'important');
509
+ }
446
510
  }
447
- } catch (e) {
448
- /* ignore */
449
511
  }
450
512
  });
451
513
 
452
- // 12. Force all CSS animations to complete
514
+ // 6. Force CSS animations to end state
453
515
  document.querySelectorAll('*').forEach(el => {
454
- const style = window.getComputedStyle(el);
455
- if (style.animationName && style.animationName !== 'none') {
456
- // Set animation to end state
457
- el.style.animationPlayState = 'paused';
458
- el.style.animationDelay = '0s';
459
- el.style.animationDuration = '0.001s';
516
+ const computed = window.getComputedStyle(el);
517
+ if (computed.animationName && computed.animationName !== 'none') {
518
+ el.style.setProperty('animation', 'none', 'important');
519
+ }
520
+ if (
521
+ computed.transition &&
522
+ computed.transition !== 'none' &&
523
+ computed.transition !== 'all 0s ease 0s'
524
+ ) {
525
+ el.style.setProperty('transition', 'none', 'important');
460
526
  }
461
527
  });
462
528
  });