# Guía de Digital Signage HTML · Lecciones aprendidas

> Documento interno para Claude. Recoge todo lo aprendido creando pantallas de digital signage HTML para Arbitrade (2025), pensado para arrancar proyectos futuros sin repetir errores.

---

## 1. Contexto y entorno real

Las pantallas son reproductores de digital signage (Android TV, BrightSign, Samsung SSP, LG webOS, etc.) que renderizan una URL HTML en un **WebView o Chromium embebido antiguo**. No es un navegador moderno controlado por el usuario. Esto implica:

- La versión de Chromium suele ser **antigua** (2018–2021). No soporta todo ES2020+.
- El WebView puede reportar dimensiones de viewport **incorrectas** (distinto de la resolución física real).
- No hay consola de desarrollo accesible fácilmente en producción.
- Las fuentes externas (Google Fonts) pueden no cargar si el reproductor no tiene acceso a internet o si el DNS es lento. Hay que tener siempre un fallback.
- `position: fixed` a veces se ancla al WebView container, no a la pantalla física.

---

## 2. Orientación y canvas

### Formato vertical (portrait)
- Resolución de referencia: **1080 × 1920 px** (Full HD vertical).
- Ratio 9:16 en vertical (mismo que un móvil en portrait).
- **Nunca usar un canvas fijo de 1080 × 1920 px con `width/height` en píxeles** sobre `#app`. El reproductor puede presentar un viewport diferente y el canvas queda recortado o desescalado mal.

### Solución que funciona: `vw` / `vh` + `position: fixed; inset: 0`
```css
#app {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;  /* inset: 0 en navegadores modernos */
  overflow: hidden;
}
```
Con esto, `#app` ocupa siempre el 100 % del viewport que el reproductor proporcione, sea cual sea su tamaño real.

Todas las medidas del diseño se expresan en `vw`/`vh` calculadas sobre la base 1080 × 1920:
```
1 vw = 10,8 px   (base 1080 px ancho)
1 vh = 19,2 px   (base 1920 px alto)

Fórmula: valor_vw = px / 10.8     valor_vh = px / 19.2
```

### Por qué NO usar `transform: scale()` con JS
La alternativa clásica es medir `window.innerWidth` y `window.innerHeight` en JS y aplicar `transform: scale(factor)` al canvas. **Esto falla en signage** porque:
- El reproductor puede devolver dimensiones incorrectas en `window.inner*`.
- El canvas queda en una esquina en lugar de centrarse.
- Al arrancar la animación counter-up, el ancho del canvas varía visualmente porque el scale se recalcula o el contenido empuja el layout antes de que el JS lo corrija.

---

## 3. JavaScript: compatibilidad con Chromium antiguo

### Sintaxis prohibida
| Sintaxis moderna | Alternativa compatible |
|---|---|
| `obj?.prop` (optional chaining) | `obj && obj.prop` o `if (obj) { ... }` |
| `valor ?? fallback` (nullish coalescing) | `valor !== null && valor !== undefined ? valor : fallback` o simplemente `valor \|\| fallback` |
| `async/await` en nivel top | Envolver en función async autoejecutada |
| Template literals complejos | Funcionan bien en general, pero cuidado con anidado complejo |
| Arrow functions | Funcionan, pero usar `function` en callbacks críticos por seguridad |

### Fetch y red
- `fetch()` puede **colgar indefinidamente** sin rechazar si la red está bloqueada o lenta. **Siempre usar un timeout manual**:

```javascript
function fetchWithTimeout(url, ms) {
  return new Promise(function(resolve, reject) {
    var tid = setTimeout(function() { reject(new Error('timeout')); }, ms);
    fetch(url).then(function(r) {
      clearTimeout(tid); resolve(r);
    }).catch(function(e) {
      clearTimeout(tid); reject(e);
    });
  });
}
```

- **Siempre tener datos de reserva (`DEFAULT_DATA`)** hardcodeados. Si el fetch falla o hace timeout, la pantalla debe arrancar igualmente con los últimos datos conocidos.
- Para refrescos periódicos de datos (cada 5 min), los errores deben silenciarse con `try/catch` y un `console.warn`. Nunca dejar que un fallo de red rompa el ciclo de slides.

---

## 4. Google Sheets como fuente de datos

- Publicar la hoja como CSV: **Archivo → Compartir → Publicar en la web → CSV**.
- La URL tiene el formato:
  ```
  https://docs.google.com/spreadsheets/d/e/XXXXX/pub?gid=0&single=true&output=csv
  ```
- Para evitar caché, añadir `&_t=Date.now()` al final (OJO: usar `&`, no `?`).
- El CSV puede incluir valores con comas dentro de comillas (`"2,85"`). Usar un **parser CSV robusto** que maneje campos quoted, no un simple `split(',')`.

```javascript
function parseCSVLine(line) {
  var fields = [], cur = '', inQ = false;
  for (var i = 0; i < line.length; i++) {
    var ch = line[i];
    if (ch === '"') { inQ = !inQ; }
    else if (ch === ',' && !inQ) { fields.push(cur.trim()); cur = ''; }
    else { cur += ch; }
  }
  fields.push(cur.trim());
  return fields;
}
```

- Los números españoles usan `.` como separador de miles y `,` como decimal (`41.322.835`, `2,85`). El parser numérico debe normalizar esto:
```javascript
function parseNum(s) {
  return parseFloat(String(s).replace(/\./g, '').replace(',', '.')) || 0;
}
```

---

## 5. Fuentes tipográficas

### El problema
Los reproductores Android TV antiguos pueden:
- No cargar Google Fonts por falta de acceso a internet o restricciones de red.
- Renderizar el `font-weight: 700` como negrita falsa (el navegador sintetiza la negrita, lo que produce números más gruesos y distorsionados, con el punto de miles cuadrado en lugar de redondo).

### Solución recomendada
1. **Cargar Google Fonts con pesos estáticos** (no el eje variable `opsz,wght`):
   ```html
   <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
   ```

2. **Añadir `@font-face` local como fallback**, apuntando a un archivo `.woff2` en la misma carpeta que el HTML:
   ```css
   @font-face {
     font-family: 'DM Sans';
     font-style: normal;
     font-weight: 400 700;
     font-display: swap;
     src: local('DM Sans'), url('DM_Sans.woff2') format('woff2');
   }
   ```
   Así, si Google Fonts no carga, se usará el archivo local.

3. **Añadir `font-synthesis: weight`** en `html`, `body` y `#app` para que el navegador pueda sintetizar pesos si es necesario, en vez de renderizar el texto mal.

4. **Declarar `font-family: 'DM Sans', sans-serif` en los elementos numéricos** (`.num`, `.s0-year`, `.s7-maq-num`, etc.) explícitamente, no solo heredarlo.

5. **Incluir una pantalla de diagnóstico** accesible con `?fuente=1` en la URL para verificar en la pantalla real si la fuente carga correctamente, sin tocar el contenido de producción.

---

## 6. Emojis

### Compatibilidad
Los reproductores más antiguos solo soportan **Unicode ≤ 6.0** (circa 2010). Emojis modernos simplemente no renderizan o aparecen como rectángulo vacío.

**Seguros (Unicode 1.1 – 6.0):**
`☕ ❄️ ★ ♥ ♦ 🍎 🍊 🍋 🍇 🍓 🍒 🍑 🍌 🍉 🍈 🍄 🌰 🍞 🍖 🍗 🍔 🍟 🍕 🍣 🍱 🍜 🍝 🍛 🍲 🍢 🍡 🍧 🍨 🍦 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🍺 🍻 🍷 🍸 🍹 🍵 🍶 🍼`

**Evitar (Unicode 13.0+, 2020):** `🧊 🫗 🧁 🥐 🧋 🫖`

---

## 7. Animaciones y rendimiento

### Lo que funciona bien
- **CSS transitions** (`opacity`, `transform`) — muy fluidas, el GPU las accelera.
- **`requestAnimationFrame`** para counters animados (easeOutQuart).
- **`setInterval`** para contadores en tiempo real (live counter cada 80 ms es suficiente).
- **CSS `@keyframes`** para animaciones de loop (pelota, lluvia de emojis, carga).

### Lo que hay que evitar
- Muchos elementos DOM creados y destruidos muy rápido (puede causar stuttering en hardware antiguo).
- `setInterval` con intervalos muy cortos (<50 ms) para actualizaciones visuales.
- Efectos de blur/filter CSS pesados en elementos grandes.
- Manipulación de scroll o `window.scrollTo` — no tiene sentido en signage.

### Patrón de slides recomendado
- Usar `position: absolute; inset: 0` en cada `.slide`.
- Transición con `opacity` + `translateY` + `scale` para entrada/salida.
- **`setTimeout`-based auto-advance** (no `setInterval`) para poder variar la duración por slide.
- Array `SLIDE_DURATIONS` con la duración en ms de cada slide.

---

## 8. Barra de progreso y dots de navegación

- Posicionarlos con `position: absolute` (no `fixed`) dentro de `#app`, porque `position: fixed` en algunos WebViews se ancla al container del WebView, no a la pantalla física.
- Los **dots** deben tener `flex-shrink: 0` y un `margin` lateral como fallback de `gap`, ya que `gap` en flexbox puede no funcionar en Chromium muy antiguo.
- Usar `vw` para el tamaño de los dots para que sean siempre cuadrados (si se usa `vh` para el alto, pueden aparecer ovalados si el ratio no es exactamente 9:16).

---

## 9. Estructura HTML mínima recomendada

```html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <!-- viewport fijo al canvas de diseño: ayuda en reproductores que calculan mal el vw/vh -->
  <meta name="viewport" content="width=1080, height=1920, initial-scale=1.0">
  <title>Signage</title>
  <!-- Google Fonts con pesos estáticos -->
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
  <style>
    @font-face { /* fuente local de respaldo */ }
    :root { /* variables de color y ease */ }
    html, body { width: 100%; height: 100%; overflow: hidden; font-synthesis: weight; }
    #app { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden; font-synthesis: weight; }
    /* Todas las medidas en vw/vh */
  </style>
</head>
<body>
  <div id="loading"><!-- dots de carga --></div>
  <div id="error"><!-- mensaje de error --></div>
  <div id="app">
    <div id="prog"></div>   <!-- barra de progreso -->
    <div id="dots"></div>   <!-- navegación dots -->
    <!-- los slides se insertan aquí por JS -->
  </div>
  <script>
    // CONFIG: URL CSV, duraciones por slide, datos de reserva
    // UTILS: parseCSV, parseNum, fmt
    // COUNTERS: countUp() con requestAnimationFrame
    // FETCH: fetchWithTimeout() + DEFAULT_DATA fallback
    // SLIDES: buildSlides(), goTo(), next()
    // INIT: init() async con try/catch
    init();
  </script>
</body>
</html>
```

---

## 10. Viewport meta: truco para reproductores con vw/vh roto

Algunos reproductores calculan `vw`/`vh` basándose en su resolución interna incorrecta. Fijar el viewport al tamaño de diseño puede ayudar:

```html
<meta name="viewport" content="width=1080, height=1920, initial-scale=1.0">
```

Esto le dice al navegador que el viewport "lógico" es 1080 × 1920, con lo que `1vw = 10.8 px` y `1vh = 19.2 px` de forma fiable.

---

## 11. Logo SVG

- Incrustar el SVG **inline** en el HTML para evitar peticiones HTTP adicionales.
- Quitar cualquier `<style>` interno del SVG (p.ej. `.cls-1{fill:#fff}`) para evitar que afecte al CSS global del documento. En su lugar, poner `fill="white"` directamente en el `<svg>`.
- Posicionar el logo con `position: absolute; top: Xvh; left: 0; right: 0` dentro de cada slide, centrado con flexbox.
- Dimensionar con `height: Xvw` (no px) para que escale con el viewport.

---

## 12. Checklist antes de subir a producción

- [ ] Todos los valores CSS en `vw`/`vh` (sin `px` salvo en `min-width`/`min-height` de fallback).
- [ ] No hay uso de `fitApp()` ni `transform: scale()` basado en `window.innerWidth`.
- [ ] `fetchWithTimeout()` con timeout de 8 s y `DEFAULT_DATA` hardcodeado.
- [ ] No hay `?.` ni `??` en el JS (usar `&&` y `||`).
- [ ] Emojis solo de Unicode ≤ 6.0.
- [ ] Fuente con fallback local (`.woff2` en la misma carpeta) o al menos `font-synthesis: weight`.
- [ ] Todos los `position: fixed` dentro de `#app` cambiados a `position: absolute`.
- [ ] `#loading` y `#error` pueden quedar como `position: fixed` (están fuera de `#app`).
- [ ] `#dots` y `#prog` dentro de `#app` con `position: absolute`.
- [ ] Pantalla de diagnóstico (`?fuente=1`) funcional para verificar in-situ.
- [ ] Datos refrescados cada 5 min con `setInterval(refreshData, 300000)`.

---

## 13. Tabla de conversión px → vw/vh (referencia rápida)

| px   | vw (÷10.8) | vh (÷19.2) |
|------|------------|------------|
| 10   | 0.93vw     | 0.52vh     |
| 12   | 1.11vw     | 0.63vh     |
| 18   | 1.67vw     | 0.94vh     |
| 20   | 1.85vw     | 1.04vh     |
| 24   | 2.22vw     | 1.25vh     |
| 28   | 2.59vw     | 1.46vh     |
| 30   | 2.78vw     | 1.56vh     |
| 36   | 3.33vw     | 1.88vh     |
| 38   | 3.52vw     | 1.98vh     |
| 40   | 3.70vw     | 2.08vh     |
| 44   | 4.07vw     | 2.29vh     |
| 46   | 4.26vw     | 2.40vh     |
| 52   | 4.81vw     | 2.71vh     |
| 54   | 5.00vw     | 2.81vh     |
| 56   | 5.19vw     | 2.92vh     |
| 62   | 5.74vw     | 3.23vh     |
| 68   | 6.30vw     | 3.54vh     |
| 72   | 6.67vw     | 3.75vh     |
| 76   | 7.04vw     | 3.96vh     |
| 80   | 7.41vw     | 4.17vh     |
| 84   | 7.78vw     | 4.38vh     |
| 96   | 8.89vw     | 5.00vh     |
| 112  | 10.37vw    | 5.83vh     |
| 150  | 13.89vw    | 7.81vh     |
| 185  | 17.13vw    | 9.64vh     |
| 210  | 19.44vw    | 10.94vh    |
| 220  | 20.37vw    | 11.46vh    |
| 240  | 22.22vw    | 12.50vh    |
| 260  | 24.07vw    | 13.54vh    |
| 300  | 27.78vw    | 15.63vh    |
| 310  | 28.70vw    | 16.15vh    |
| 400  | 37.04vw    | 20.83vh    |
| 900  | 83.33vw    | 46.88vh    |
| 1080 | 100vw      | 56.25vh    |
| 1920 | 177.78vw   | 100vh      |

---

*Última actualización: marzo 2026 · Proyecto Arbitrade Signage V.10*
