Files
roi-theme/openspec/changes/refactor-adsense-lazy-loading/specs/adsense-lazy-loading/spec.md
FrankZamora 179a83e9cd feat(js): implement intersection observer lazy loading for adsense
- Add per-slot lazy loading with Intersection Observer API
- Implement fill detection via MutationObserver and data-ad-status
- Add configurable rootMargin and fillTimeout from database
- Generate dynamic CSS based on lazy_loading_enabled setting
- Add legacy mode fallback for browsers without IO support
- Include backup of previous implementation (adsense-loader.legacy.js)
- Add OpenSpec documentation with test plan (72 tests verified)

Schema changes:
- Add lazy_loading_enabled (boolean, default: true)
- Add lazy_rootmargin (select: 0-500px, default: 200)
- Add lazy_fill_timeout (select: 3000-10000ms, default: 5000)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:48:20 -06:00

361 lines
13 KiB
Markdown

# Especificacion: AdSense Lazy Loading
## Purpose
Define el comportamiento del sistema de carga diferida de anuncios AdSense usando Intersection Observer para cargar ads individualmente cuando entran al viewport, ocultando slots que no reciben contenido.
## ADDED Requirements
### Requirement: Carga Individual por Visibilidad
The system MUST load each AdSense ad slot individually when it enters the viewport, NOT all at once.
#### Scenario: Slot entra al viewport por primera vez
- **WHEN** un elemento `.roi-ad-slot[data-ad-lazy="true"]` entra al viewport (considerando rootMargin)
- **THEN** el sistema DEBE ejecutar `adsbygoogle.push({})` SOLO para ese slot
- **AND** el sistema DEBE marcar el slot como "activado" para no procesarlo de nuevo
- **AND** el sistema DEBE observar el `<ins>` interno para detectar contenido
#### Scenario: Multiples slots en viewport inicial
- **GIVEN** la pagina tiene 3 slots visibles en el viewport inicial
- **WHEN** la pagina termina de cargar
- **THEN** el sistema DEBE activar los 3 slots en orden DOM (sin delay entre ellos)
- **AND** la activacion es sincrona: push() → siguiente push() inmediatamente
- **AND** el sistema NO DEBE activar slots que estan fuera del viewport
**Clarificacion:** "Secuencial" significa en orden DOM, uno tras otro sin delay artificial. NO hay setTimeout entre activaciones. El Intersection Observer dispara callbacks para todos los elementos visibles en el mismo frame.
#### Scenario: Usuario hace scroll rapido
- **GIVEN** el usuario hace scroll rapido pasando varios slots
- **WHEN** los slots entran y salen del viewport rapidamente
- **THEN** el sistema DEBE activar cada slot que entre al viewport
- **AND** el sistema NO DEBE cancelar la activacion si el slot sale del viewport
---
### Requirement: Biblioteca Cargada Una Sola Vez
The system MUST load the `adsbygoogle.js` library only once, when the first slot is activated.
#### Scenario: Primer slot activado
- **GIVEN** la biblioteca `adsbygoogle.js` NO ha sido cargada
- **WHEN** el primer slot entra al viewport
- **THEN** el sistema DEBE cargar la biblioteca
- **AND** el sistema DEBE esperar a que la biblioteca cargue (onload callback)
- **AND** ENTONCES ejecutar el push para ese slot
#### Scenario: Slots subsecuentes
- **GIVEN** la biblioteca `adsbygoogle.js` YA fue cargada
- **WHEN** otro slot entra al viewport
- **THEN** el sistema DEBE ejecutar el push inmediatamente
- **AND** el sistema NO DEBE intentar cargar la biblioteca de nuevo
---
### Requirement: Slots Ocultos por Defecto
The system MUST hide ad slots by default and show them only when they have content.
#### Scenario: Slot en estado inicial
- **WHEN** la pagina renderiza un `.roi-ad-slot[data-ad-lazy="true"]`
- **THEN** el slot DEBE tener `display: none` via CSS dinamico
- **AND** el slot NO DEBE ocupar espacio en el layout
#### Scenario: Slot recibe contenido de Google
- **GIVEN** un slot fue activado con push()
- **WHEN** Google inyecta contenido dentro del `<ins class="adsbygoogle">`
- **THEN** el sistema DEBE agregar clase `roi-ad-filled` al slot
- **AND** el slot DEBE hacerse visible (`display: block`)
#### Scenario: Slot NO recibe contenido (timeout)
- **GIVEN** un slot fue activado con push()
- **WHEN** pasa el tiempo configurado en `lazy_fill_timeout` sin que Google inyecte contenido
- **THEN** el sistema DEBE agregar clase `roi-ad-empty` al slot
- **AND** el slot DEBE permanecer oculto
- **AND** el sistema DEBE dejar de observar ese slot
---
### Requirement: Pre-carga con rootMargin
The system MUST pre-load ads before they enter the visible viewport to ensure smooth UX.
#### Scenario: Configuracion de rootMargin
- **WHEN** se inicializa el Intersection Observer
- **THEN** DEBE usar el valor de `lazy_rootmargin` desde configuracion
- **AND** el formato DEBE ser `'{value}px 0px'`
#### Scenario: Slot dentro del rootMargin
- **GIVEN** un slot esta 150px debajo del viewport visible
- **AND** `lazy_rootmargin` es 200
- **WHEN** el Intersection Observer evalua visibilidad
- **THEN** el slot DEBE considerarse "visible" y activarse
---
### Requirement: Deteccion de Contenido con Criterios Concretos
The system MUST use specific criteria to determine when an ad slot has been filled.
#### Scenario: Google agrega atributo data-ad-status="filled"
- **GIVEN** un slot fue activado
- **WHEN** Google agrega `data-ad-status="filled"` al `<ins>`
- **THEN** el sistema DEBE marcar inmediatamente como `roi-ad-filled`
- **AND** el sistema DEBE desconectar observadores de ese slot
#### Scenario: Google agrega atributo data-ad-status="unfilled"
- **GIVEN** un slot fue activado
- **WHEN** Google agrega `data-ad-status="unfilled"` al `<ins>`
- **THEN** el sistema DEBE marcar inmediatamente como `roi-ad-empty`
- **AND** el sistema DEBE desconectar observadores de ese slot
#### Scenario: Fallback - Google inyecta iframe sin atributo
- **GIVEN** un slot fue activado
- **AND** el `<ins>` NO tiene atributo `data-ad-status`
- **WHEN** Google agrega un `<iframe>` dentro del `<ins>`
- **THEN** el sistema DEBE marcar como `roi-ad-filled`
#### Scenario: Fallback - Google agrega div con id
- **GIVEN** un slot fue activado
- **AND** el `<ins>` NO tiene atributo `data-ad-status`
- **WHEN** Google agrega un `<div id="...">` dentro del `<ins>`
- **THEN** el sistema DEBE marcar como `roi-ad-filled`
#### Scenario: Limpieza de observadores
- **GIVEN** un slot fue marcado como `roi-ad-filled` o `roi-ad-empty`
- **WHEN** el estado final es determinado
- **THEN** el sistema DEBE desconectar el MutationObserver de ese slot
- **AND** el sistema DEBE desconectar el IntersectionObserver de ese slot
---
### Requirement: Manejo de Errores de Red
The system MUST handle network errors when loading the AdSense library.
#### Scenario: Error de carga de biblioteca - primer intento
- **GIVEN** el sistema intenta cargar `adsbygoogle.js`
- **WHEN** la carga falla (onerror)
- **THEN** el sistema DEBE esperar 2 segundos
- **AND** el sistema DEBE reintentar la carga UNA vez
#### Scenario: Error de carga de biblioteca - segundo intento fallido
- **GIVEN** el primer intento de carga fallo
- **AND** el segundo intento tambien falla
- **WHEN** el onerror se dispara por segunda vez
- **THEN** el sistema DEBE marcar TODOS los slots como `roi-ad-error`
- **AND** el sistema DEBE registrar error en consola si debug habilitado
- **AND** el sistema NO DEBE intentar mas recargas
#### Scenario: Slots permanecen ocultos tras error
- **GIVEN** la biblioteca fallo en cargar
- **WHEN** los slots tienen clase `roi-ad-error`
- **THEN** los slots DEBEN permanecer ocultos
- **AND** NO DEBEN mostrar espacios vacios en la pagina
---
### Requirement: Fallback para Navegadores Sin Soporte
The system MUST provide fallback for browsers without Intersection Observer support.
#### Scenario: Navegador sin Intersection Observer
- **GIVEN** `window.IntersectionObserver` es undefined
- **WHEN** el script se inicializa
- **THEN** el sistema DEBE usar el modo legacy (cargar todos despues de interaccion/timeout)
- **AND** el sistema DEBE registrar un mensaje de debug indicando fallback
#### Scenario: Navegador con soporte parcial
- **GIVEN** el navegador soporta Intersection Observer pero no MutationObserver
- **WHEN** el script se inicializa
- **THEN** el sistema DEBE usar Intersection Observer para activacion
- **AND** el sistema DEBE usar timeout fijo para determinar fill (sin deteccion dinamica)
---
### Requirement: Compatibilidad con Ads Dinamicos
The system MUST support ads injected dynamically after page load.
#### Scenario: Contenido cargado via AJAX
- **GIVEN** la pagina carga contenido adicional via AJAX con nuevos slots
- **WHEN** el evento `roi-adsense-activate` es disparado
- **THEN** el sistema DEBE buscar nuevos slots `.roi-ad-slot[data-ad-lazy="true"]` no observados
- **AND** el sistema DEBE agregarlos al Intersection Observer
#### Scenario: Infinite scroll
- **GIVEN** la pagina implementa infinite scroll
- **WHEN** nuevos slots son agregados al DOM
- **THEN** el sistema DEBE detectarlos automaticamente (MutationObserver en body)
- **OR** esperar evento `roi-adsense-activate` para procesarlos
---
### Requirement: Configuracion desde Base de Datos
The system MUST read configuration from database via wp_localize_script, NOT from hardcoded values.
#### Scenario: Configuracion disponible en JS
- **WHEN** el script `adsense-loader.js` se ejecuta
- **THEN** DEBE leer configuracion de `window.roiAdsenseConfig`
- **AND** los valores DEBEN incluir:
- `lazyEnabled` (boolean) - desde campo `lazy_loading_enabled`
- `rootMargin` (string) - desde campo `lazy_rootmargin` + 'px 0px'
- `fillTimeout` (number) - desde campo `lazy_fill_timeout`
- `debug` (boolean) - desde WP_DEBUG
#### Scenario: Modo lazy deshabilitado
- **GIVEN** `roiAdsenseConfig.lazyEnabled` es false
- **WHEN** el script se inicializa
- **THEN** el sistema DEBE usar el modo legacy (cargar todos al inicio)
- **AND** los slots DEBEN ser visibles por defecto (sin display:none)
---
### Requirement: No Manipular Ads Cargados
The system MUST NOT remove, recycle, or manipulate ads after they are loaded.
#### Scenario: Usuario scrollea pasando un ad
- **GIVEN** un ad fue cargado y mostrado
- **WHEN** el usuario scrollea y el ad sale del viewport
- **THEN** el sistema NO DEBE remover el ad del DOM
- **AND** el sistema NO DEBE ocultar el ad
- **AND** el sistema NO DEBE intentar "reciclar" el slot
#### Scenario: Ad permanece en pagina
- **GIVEN** un ad fue cargado exitosamente
- **WHEN** la sesion del usuario continua
- **THEN** el ad DEBE permanecer en su posicion original
- **AND** el ad DEBE mantener su contenido intacto
---
### Requirement: Logging de Debug Condicional
The system MUST provide debug logging only when enabled via WP_DEBUG.
#### Scenario: Debug habilitado
- **GIVEN** `roiAdsenseConfig.debug` es true
- **WHEN** ocurre cualquier evento significativo
- **THEN** el sistema DEBE registrar en console.log con prefijo `[AdSense Lazy]`
- **AND** los eventos incluyen: inicializacion, activacion de slot, deteccion de fill, timeout, error
#### Scenario: Debug deshabilitado
- **GIVEN** `roiAdsenseConfig.debug` es false
- **WHEN** el script ejecuta
- **THEN** el sistema NO DEBE generar output en consola
---
### Requirement: CSS Generado Dinamicamente
The system MUST generate CSS via CSSGeneratorService, NOT static CSS files.
#### Scenario: Lazy loading habilitado
- **GIVEN** `lazy_loading_enabled` es true en BD
- **WHEN** `AdsensePlacementRenderer` genera output
- **THEN** DEBE usar `CSSGeneratorService` para generar:
- `.roi-ad-slot { display: none }`
- `.roi-ad-slot.roi-ad-filled { display: block }`
- `.roi-ad-slot.roi-ad-empty { display: none }`
#### Scenario: Lazy loading deshabilitado
- **GIVEN** `lazy_loading_enabled` es false en BD
- **WHEN** `AdsensePlacementRenderer` genera output
- **THEN** NO DEBE agregar `display: none` a `.roi-ad-slot`
- **AND** los slots DEBEN ser visibles por defecto
---
### Requirement: Integracion con Schema JSON
The system MUST store lazy loading configuration in the existing adsense-placement.json schema.
#### Scenario: Campos en grupo behavior
- **WHEN** el schema `adsense-placement.json` es leido
- **THEN** el grupo `behavior` DEBE contener:
- `lazy_loading_enabled` (boolean, default: true)
- `lazy_rootmargin` (select, default: "200")
- `lazy_fill_timeout` (select, default: "5000")
#### Scenario: Sincronizacion a BD
- **WHEN** se ejecuta `wp roi-theme sync-component adsense-placement`
- **THEN** los campos de lazy loading DEBEN crearse en BD
- **AND** los valores default DEBEN aplicarse si no existen
---
### Requirement: Accesibilidad de Slots Ocultos
The system MUST ensure hidden ad slots do not interfere with assistive technologies.
#### Scenario: Slot oculto no interfiere con lectores de pantalla
- **GIVEN** un slot tiene `display: none` (estado inicial o roi-ad-empty)
- **WHEN** un lector de pantalla procesa la pagina
- **THEN** el slot NO DEBE ser anunciado ni navegable
- **AND** el contenido oculto NO DEBE aparecer en el arbol de accesibilidad
#### Scenario: Slot visible es accesible
- **GIVEN** un slot fue marcado como `roi-ad-filled`
- **WHEN** el slot se hace visible (`display: block`)
- **THEN** el contenido del ad DEBE ser accesible para lectores de pantalla
- **AND** el iframe de Google conserva su propia accesibilidad
**Nota tecnica:** `display: none` automaticamente remueve elementos del arbol de accesibilidad. No se requiere `aria-hidden` adicional.
---
### Requirement: Interaccion con Cache
The system MUST document cache implications when lazy loading settings change.
#### Scenario: Cambio de configuracion requiere cache flush
- **GIVEN** `lazy_loading_enabled` cambia de true a false (o viceversa)
- **WHEN** el administrador guarda la configuracion
- **THEN** el FormBuilder DEBE mostrar aviso de que se requiere vaciar cache
- **AND** el CSS dinamico cambiara en el proximo render sin cache
#### Scenario: Usuario con cache obsoleto
- **GIVEN** un usuario tiene HTML cacheado con `display: none` en slots
- **AND** el admin deshabilito lazy loading
- **WHEN** el usuario visita la pagina
- **THEN** los slots permaneceran ocultos hasta que el cache expire
- **AND** esto es comportamiento esperado (no es un bug)