smippo 0.1.0 → 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.0",
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
@@ -89,8 +89,52 @@ export function run() {
89
89
  'Wait strategy: networkidle|load|domcontentloaded',
90
90
  'networkidle',
91
91
  )
92
- .option('--wait-time <ms>', 'Additional wait time after network idle', '0')
92
+ .option(
93
+ '--wait-time <ms>',
94
+ 'Additional wait time after network idle',
95
+ '500',
96
+ )
93
97
  .option('--timeout <ms>', 'Page load timeout', '30000')
98
+
99
+ // Scroll and reveal options (for capturing dynamic content)
100
+ .option(
101
+ '--scroll',
102
+ 'Pre-scroll page to trigger lazy content (default: true)',
103
+ )
104
+ .option('--no-scroll', 'Disable pre-scroll behavior')
105
+ .option(
106
+ '--scroll-wait <ms>',
107
+ 'Wait time after scrolling for animations',
108
+ '1000',
109
+ )
110
+ .option(
111
+ '--scroll-step <px>',
112
+ 'Pixels per scroll increment (default: 200)',
113
+ '200',
114
+ )
115
+ .option(
116
+ '--scroll-delay <ms>',
117
+ 'Delay between scroll steps (default: 50)',
118
+ '50',
119
+ )
120
+ .option(
121
+ '--scroll-behavior <type>',
122
+ 'Scroll behavior: smooth|instant (default: smooth)',
123
+ 'smooth',
124
+ )
125
+ .option(
126
+ '--reveal-all',
127
+ 'Force reveal scroll-triggered content like GSAP, AOS (default: true)',
128
+ )
129
+ .option(
130
+ '--no-reveal-all',
131
+ 'Disable force-reveal of scroll-triggered content',
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')
94
138
  .option('--user-agent <string>', 'Custom user agent')
95
139
  .option('--viewport <WxH>', 'Viewport size', '1920x1080')
96
140
  .option('--device <name>', 'Emulate device (e.g., "iPhone 13")')
@@ -217,6 +261,18 @@ export function run() {
217
261
  });
218
262
  });
219
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
+
220
276
  // Screenshot capture command
221
277
  program
222
278
  .command('capture <url>')
@@ -312,6 +368,13 @@ async function capture(url, options) {
312
368
  wait: options.wait,
313
369
  waitTime: parseInt(options.waitTime, 10),
314
370
  timeout: parseInt(options.timeout, 10),
371
+ scroll: options.scroll,
372
+ scrollWait: parseInt(options.scrollWait, 10),
373
+ scrollStep: parseInt(options.scrollStep, 10),
374
+ scrollDelay: parseInt(options.scrollDelay, 10),
375
+ scrollBehavior: options.scrollBehavior,
376
+ revealAll: options.revealAll,
377
+ reducedMotion: options.reducedMotion,
315
378
  userAgent: options.userAgent,
316
379
  viewport: parseViewport(options.viewport),
317
380
  device: options.device,
package/src/crawler.js CHANGED
@@ -270,6 +270,13 @@ export class Crawler extends EventEmitter {
270
270
  mimeExclude: this.options.mimeExclude,
271
271
  maxSize: this.options.maxSize,
272
272
  minSize: this.options.minSize,
273
+ scroll: this.options.scroll,
274
+ scrollWait: this.options.scrollWait,
275
+ scrollStep: this.options.scrollStep,
276
+ scrollDelay: this.options.scrollDelay,
277
+ scrollBehavior: this.options.scrollBehavior,
278
+ revealAll: this.options.revealAll,
279
+ reducedMotion: this.options.reducedMotion,
273
280
  });
274
281
 
275
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, {
@@ -35,11 +43,40 @@ export class PageCapture {
35
43
  }
36
44
  }
37
45
 
38
- // Additional wait time if specified
39
- if (this.options.waitTime > 0) {
40
- await this.page.waitForTimeout(this.options.waitTime);
46
+ // Additional wait time if specified (default 500ms for animations to start)
47
+ const waitTime = this.options.waitTime ?? 500;
48
+ if (waitTime > 0) {
49
+ await this.page.waitForTimeout(waitTime);
50
+ }
51
+
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
54
+ if (this.options.revealAll !== false) {
55
+ await this._completeAllAnimations();
56
+ }
57
+
58
+ // Step 2: Human-like scrolling to trigger lazy content and intersection observers
59
+ if (this.options.scroll !== false) {
60
+ await this._humanLikeScroll();
41
61
  }
42
62
 
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
69
+ const scrollWait = this.options.scrollWait ?? 1000;
70
+ if (scrollWait > 0) {
71
+ await this.page.waitForTimeout(scrollWait);
72
+ }
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
+
43
80
  // Get the rendered HTML
44
81
  const html = await this.page.content();
45
82
 
@@ -148,4 +185,347 @@ export class PageCapture {
148
185
  return type === filter || type.startsWith(filter + ';');
149
186
  });
150
187
  }
188
+
189
+ /**
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
203
+ */
204
+ async _humanLikeScroll() {
205
+ const scrollStep = this.options.scrollStep || 300;
206
+ const scrollDelay = this.options.scrollDelay || 100;
207
+
208
+ /* eslint-disable no-undef */
209
+ await this.page.evaluate(
210
+ async ({step, delay}) => {
211
+ // Human-like smooth scroll with easing
212
+ const smoothScrollTo = (targetY, duration = 500) => {
213
+ return new Promise(resolve => {
214
+ const startY = window.scrollY;
215
+ const distance = targetY - startY;
216
+ const startTime = performance.now();
217
+
218
+ const easeInOutCubic = t =>
219
+ t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
220
+
221
+ const animate = () => {
222
+ const elapsed = performance.now() - startTime;
223
+ const progress = Math.min(elapsed / duration, 1);
224
+ const eased = easeInOutCubic(progress);
225
+
226
+ window.scrollTo(0, startY + distance * eased);
227
+
228
+ if (progress < 1) {
229
+ requestAnimationFrame(animate);
230
+ } else {
231
+ resolve();
232
+ }
233
+ };
234
+ requestAnimationFrame(animate);
235
+ });
236
+ };
237
+
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;
244
+ let currentY = 0;
245
+ let lastHeight = pageHeight;
246
+
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);
252
+
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
259
+ } else {
260
+ await wait(delay);
261
+ }
262
+
263
+ currentY = targetY;
264
+
265
+ // Check for lazy-loaded content increasing page height
266
+ const newHeight = document.body.scrollHeight;
267
+ if (newHeight > lastHeight) {
268
+ pageHeight = newHeight;
269
+ lastHeight = newHeight;
270
+ }
271
+ }
272
+
273
+ // Phase 2: Ensure we're at the very bottom
274
+ await smoothScrollTo(document.body.scrollHeight, 300);
275
+ await wait(500); // Wait at bottom
276
+
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);
281
+ }
282
+
283
+ // Phase 3: Scroll back to top (faster)
284
+ await smoothScrollTo(0, 800);
285
+ await wait(300);
286
+ },
287
+ {step: scrollStep, delay: scrollDelay},
288
+ );
289
+ /* eslint-enable no-undef */
290
+ }
291
+
292
+ /**
293
+ * Complete all animations to their final state (100% progress)
294
+ * Instead of killing animations, we progress them to completion
295
+ */
296
+ async _completeAllAnimations() {
297
+ /* eslint-disable no-undef */
298
+ await this.page.evaluate(() => {
299
+ // Helper to safely access nested properties
300
+ const safeGet = (obj, path) => {
301
+ try {
302
+ return path.split('.').reduce((o, k) => o?.[k], obj);
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ };
307
+
308
+ // 1. GSAP - Progress ALL tweens and timelines to 100%
309
+ const gsap = safeGet(window, 'gsap');
310
+ if (gsap) {
311
+ try {
312
+ // Get all global tweens and progress them to end
313
+ const tweens =
314
+ gsap.globalTimeline?.getChildren?.(true, true, true) || [];
315
+ tweens.forEach(tween => {
316
+ try {
317
+ // Progress to 100% (end state)
318
+ tween.progress?.(1, true);
319
+ // Also try totalProgress for timelines
320
+ tween.totalProgress?.(1, true);
321
+ } catch (e) {
322
+ /* ignore */
323
+ }
324
+ });
325
+ } catch (e) {
326
+ /* ignore */
327
+ }
328
+ }
329
+
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) {
335
+ try {
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
355
+ } catch (e) {
356
+ /* ignore */
357
+ }
358
+ }
359
+
360
+ // 3. Web Animations API - Complete all animations
361
+ document.getAnimations?.().forEach(animation => {
362
+ try {
363
+ animation.finish();
364
+ } catch (e) {
365
+ try {
366
+ animation.currentTime =
367
+ animation.effect?.getTiming?.()?.duration || 0;
368
+ } catch (e2) {
369
+ /* ignore */
370
+ }
371
+ }
372
+ });
373
+
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
381
+ document.querySelectorAll('.wow').forEach(el => {
382
+ el.classList.add('animated');
383
+ el.style.visibility = 'visible';
384
+ });
385
+
386
+ // 6. ScrollReveal elements
387
+ document.querySelectorAll('[data-sr-id]').forEach(el => {
388
+ el.style.visibility = 'visible';
389
+ el.style.opacity = '1';
390
+ });
391
+
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 => {
406
+ try {
407
+ el.goToAndStop?.(el.totalFrames - 1, true);
408
+ el.pause?.();
409
+ } catch (e) {
410
+ /* ignore */
411
+ }
412
+ });
413
+ });
414
+ /* eslint-enable no-undef */
415
+ }
416
+
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
+ });
439
+
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
+ });
446
+
447
+ // 3. Load lazy background images
448
+ document
449
+ .querySelectorAll(
450
+ '[data-bg], [data-background], [data-background-image]',
451
+ )
452
+ .forEach(el => {
453
+ const bg =
454
+ el.dataset.bg ||
455
+ el.dataset.background ||
456
+ el.dataset.backgroundImage;
457
+ if (bg) {
458
+ el.style.backgroundImage = `url("${bg}")`;
459
+ }
460
+ });
461
+
462
+ // 4. Load lazy iframes
463
+ document.querySelectorAll('iframe[data-src]').forEach(iframe => {
464
+ if (iframe.dataset.src) {
465
+ iframe.src = iframe.dataset.src;
466
+ }
467
+ });
468
+
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');
485
+ }
486
+ }
487
+
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
+ }
498
+ }
499
+
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
+ }
510
+ }
511
+ }
512
+ });
513
+
514
+ // 6. Force CSS animations to end state
515
+ document.querySelectorAll('*').forEach(el => {
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');
526
+ }
527
+ });
528
+ });
529
+ /* eslint-enable no-undef */
530
+ }
151
531
  }