HTML de cero a experto
El esqueleto de toda la web: semántica que los buscadores premian, formularios listos para hablar con PHP — incluida la subida de archivos —, multimedia nativa y accesibilidad real hasta construir una página completa lista para producción.
1 · Qué es HTML5 y cómo piensa el navegador
Básico ~10 minAntes de escribir una sola etiqueta: qué es realmente un documento HTML y qué hace el navegador con él.
HTML no es un lenguaje de programación: es un lenguaje de marcado. No ejecuta lógica — describe estructura y significado. Cada etiqueta le dice al navegador «esto es un párrafo», «esto es un enlace», «esto es lo más importante de la página». CSS lo pinta, JavaScript le da comportamiento; sin tu markup, no hay nada que pintar ni controlar.
Estándar vivo, versión 5 enterrada
El nombre «HTML5» quedó como marca de época. Desde el 2019 quien define el lenguaje es el WHATWG con un HTML Living Standard: ya no hay «HTML6» que esperar — los navegadores incorporan mejoras continuamente. Lo que verás en este manual (dialog, popover, loading lazy) funciona hoy en todos los navegadores modernos.
Anatomía de una etiqueta
<p class="intro">Hola, mundo</p>
└┬┘ └────┬─────┘ └───┬───┘ └┬┘
etiqueta atributo contenido cierre
- Etiqueta de apertura:
<p>; cierre:</p>. - Atributos: pares nombre="valor" que configuran el elemento.
- Void elements: algunos no tienen cierre —
<img>,<br>,<input>.
Del texto al árbol: el DOM
Cuando el navegador recibe tu archivo, lo parsea y construye un árbol de objetos llamado DOM (Document Object Model). Ahí viven los errores típicos del principiante:
<ul>
<li>Café</li>
<li>Té
</ul> <!-- error: falta cerrar el segundo li -->
El navegador no avisa: adivina tu intención, cierra el
<li> por ti y sigue. HTML es perdonador — demasiado. Por eso
existe el validador del W3C (capítulo 30): encuentra lo que el navegador
calla. Y por eso un elemento mal anidado puede romper estilos sin que
veas nada raro en el código fuente: el DOM real no siempre es tu HTML.
Las tres capas de una página web
| Capa | Tecnología | Responsabilidad |
|---|---|---|
| Estructura + significado | HTML | qué ES cada cosa |
| Presentación | CSS | cómo SE VE |
| Comportamiento | JavaScript | qué HACE |
Este manual te da la primera capa sólida. La tercera ya la tienes si vienes del manual de JavaScript — aquí conectarás formularios con esa SPA y con la API PHP del curso anterior.
Puntos clave
- HTML describe significado; no programa comportamiento ni pinta.
- Living Standard WHATWG: sin versiones que esperar.
- El navegador corrige tus errores en silencio al construir el DOM.
- Semántica primero: la vista es trabajo de CSS.
2 · Tu primera página
Básico ~10 minEl esqueleto mínimo obligatorio y cómo servirlo sin instalar nada.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mi primera página</title>
</head>
<body>
<h1>Funciona</h1>
<p>Esta es mi primera página HTML.</p>
</body>
</html>
Línea por línea
| Pieza | Para qué existe |
|---|---|
<!DOCTYPE html> | Activa el modo estándar. Sin él, el navegador imita bugs de 1998 (quirks mode) y tu CSS enloquece. |
lang="es" | Idioma del contenido: lectores de pantalla pronuncian bien; Google segmenta mejor. |
<head> | Metadatos: NO se ven, describen la página (charset, viewport, título). |
charset="utf-8" | Primer hijo del head SIEMPRE: permite ñ, tildes, emoji. Sin esto: «Ã±» por todas partes. |
viewport | Hace que el móvil use su ancho real. Sin esto, Bootstrap y el responsive no existen. |
<title> | Texto de la pestaña y PRIMER factor SEO on-page (cap. 24). |
<body> | Todo lo visible. |
El mapa del documento: qué vive en cada zona
Tres preguntas que todo principiante se hace — ¿dónde va el CSS?, ¿dónde va el JS?, ¿por qué ahí? — y que conviene resolver desde hoy. El mapa completo:
<html lang="es">
├─ head → zona INVISIBLE: metadatos de la página
│ ├─ meta charset / viewport
│ ├─ title
│ ├─ link rel="stylesheet" ← el CSS vive aquí
│ └─ script defer ← el JS moderno también
└─ body → zona VISIBLE: el contenido
├─ header / nav / main / footer
├─ textos, imágenes, tablas, formularios…
└─ script al final ← la escuela clásica
- El CSS en el head: los estilos deben estar listos ANTES del primer render; si llegan tarde, la página aparece un instante sin estilo (el famoso FOUC) y luego «salta».
- El JS al final del body — escuela clásica: un script sin defer DETIENE el parseo justo donde aparece; colocarlos últimos dejaba ver primero todo el contenido.
- La escuela moderna:
<script src="..." defer>en el head descarga en paralelo y ejecuta cuando el DOM está listo — mismo efecto que el truco clásico, pero mejor. Es la regla de la casa; se demuestra a fondo en el capítulo 26. - Ambas escuelas coinciden en lo esencial: el JS NUNCA bloquea tu contenido visible ni deja la página muerta mientras carga.
defer (o al final del body si es un script inline corto,
como el registro del service worker del capítulo 30).Ejemplo completo aplicando el mapa
La misma idea de la primera página, ahora con las tres zonas del mapa ocupadas como corresponde. Tres archivos, un solo resultado:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mi tienda</title>
<!-- ZONA INVISIBLE: estilos y comportamiento -->
<link rel="stylesheet" href="estilos.css">
<script src="app.js" defer></script>
</head>
<body>
<!-- ZONA VISIBLE: el contenido -->
<header>
<h1>Mi tienda</h1>
</header>
<main>
<p id="mensaje">Bienvenido. Hay una oferta esperándote.</p>
<button id="btn-oferta" type="button">Ver oferta</button>
</main>
<footer>
<small>© 2026 Mi tienda</small>
</footer>
</body>
</html>
/* estilos.css — vive en el head via link */
header {
background: #0d9488;
color: #fff;
padding: 1rem;
}
// app.js — declarado con defer en el head
document.getElementById("btn-oferta").addEventListener("click", () => {
document.getElementById("mensaje").textContent =
"¡Hoy: 20% de descuento en cafés!";
});
estilos.cssentra por<link>en el head: los estilos están listos ANTES del primer render.app.jsse declara ARRIBA pero ejecuta ABAJO: gracias adefer, cuando corre, el botón ya existe aunque esté «más abajo».- El body solo contiene contenido semántico: header/main/footer del mapa.
- Abre DevTools → Network y verás CSS y JS descargando EN PARALELO, ninguno tapando al otro.
Variante clásica equivalente: si app.js
NO llevara defer, habría que moverlo al final del body para no
bloquear el render:
<footer>
<small>© 2026 Mi tienda</small>
</footer>
<!-- escuela clasica: ultimo hijo del body -->
<script src="app.js"></script>
</body>
</html>
Servir la página
Un doble clic al archivo funciona para practicar (file://), pero
limita: algunas APIs exigen http. Usa un servidor estático — el mismo hábito
que en el manual de JavaScript:
$ cd mi-proyecto
$ npx serve .
# → http://localhost:3000
Código fuente vs. DOM: no son lo mismo
- Clic derecho → Ver código fuente: muestra TU archivo, intacto.
- F12 → Elements/Inspector: muestra el DOM tras las correcciones del parser.
Escribe un <table> sin
<tbody> y mira Elements: ahí aparece uno inventado. El inspector
enseña el HTML REAL; el código fuente, el que tú escribiste. Depurar es comparar ambos.
Indentación y mayúsculas: la convención de la casa
- Minúsculas en etiquetas y atributos:
<img src="...">, nunca<IMG SRC=...>. - Atributos SIEMPRE entre comillas dobles.
- Un nivel de indentación por profundidad: el DOM se lee como árbol.
- Cierre opcional omitido = bug mañana. Cierra TODO, incluso donde no es obligatorio.
Puntos clave
- DOCTYPE + lang + charset + viewport = los 4 imprescindibles.
- npx serve desde el día uno: mismo entorno que producción.
- Fuente ≠ DOM: el inspector manda al depurar.
- Convenciones uniformes ahora evitan diffs basura después.
3 · Texto: encabezados y párrafos
Básico ~12 minEl 80% de una página es texto bien marcado. La jerarquía de encabezados es donde se gana o se pierde el SEO.
h1–h6: una escalera, no un catálogo de tamaños
<article>
<h1>Café de origen peruano</h1> <!-- UNO solo por vista -->
<h2>Variedades</h2>
<h3>Typica</h3>
<h3>Bourbon</h3>
<h2>Preparación</h2>
<h3>V60</h3>
</article>
- Un solo
<h1>por página: el tema central (Google lo usa). - Nunca saltes niveles (h2 → h4) ni los elijas por tamaño: el tamaño se cambia con CSS.
- La jerarquía forma el índice semántico que usan lectores de pantalla (navegación por tecla H) y el featured snippet de Google.
Énfasis con significado
<p><strong>Atención:</strong> el pedido vence <em>mañana</em>.</p>
<p><b>Nota:</b> negrita SIN importancia (estilo).</p>
<p><i>Coffea arabiga</i> — nombre científico, sin énfasis.</p>
| Etiqueta | Semántica | Úsalo para… |
|---|---|---|
<strong> | importancia fuerte | avisos, advertencias |
<em> | énfasis de voz | cambiar el sentido de la frase |
<b> | solo visual | palabras clave, nombres de UI |
<i> | solo visual | términos técnicos, idiomas extranjeros |
<small> | letra pequeña legal | disclaimers, letra chica |
<mark> | resaltado contextual | coincidencias de búsqueda |
<abbr title=""> | abreviatura | IGV, SUNAT con tooltip explicativo |
<time datetime="2026-08-23"> | fecha legible por máquinas | fechas y horas en contenido |
Entidades: los caracteres especiales
<p>Panadería & Cafetería <El Trigal> — 5 kg</p>
| Escribe… | Se ve… | Cuándo |
|---|---|---|
< / > | < / > | mostrar etiquetas como texto |
& | & | ampersand literal |
" | " | comillas dentro de atributos |
| espacio duro | S/ 150.00 sin salto de línea |
© | © | pies de página (o el carácter directo) |
Saltos y separaciones honestas
<p>Jr. Unión 123,<br>Cusco</p> <!-- br SOLO para dirección/poesía -->
<hr> <!-- cambio de TEMA, no una linea decorativa -->
<br><br> para «dar espacio».
El espacio es CSS (margin), no HTML. Y <p> ya trae su separación propia.Puntos clave
- Un h1, sin saltos de nivel: la jerarquía es SEO + accesibilidad.
- strong/em significan; b/i solo estilizan.
- <, > y & SIEMPRE como entidades cuando son contenido.
- br para direcciones/poemas; hr para cambios de tema; nada más.
4 · Enlaces: el superpoder de la web
Básico ~12 minLa etiqueta que convierte documentos sueltos en una web: dominar href es dominar la mitad del HTML práctico.
Las cuatro formas de href
<a href="paginas/contacto.html">relativo a la carpeta actual</a>
<a href="/api/productos">desde la raiz del sitio</a>
<a href="https://developer.mozilla.org">absoluto: otro sitio</a>
<a href="#precios">ancla interna: baja a esa seccion</a>
- Relativo sin /: hermano o hijo de la página actual — ideal para maquetas locales.
- /raíz: sobrevive reorganizaciones; así linken los sitios reales y las SPAs.
- Ancla: cualquier elemento con ese id recibe el salto. Con CSS
scroll-behavior: smooth, queda navegación de una página gratis.
target="_blank" nunca viene solo
<a href="https://banco.ejemplo.pe" target="_blank"
rel="noopener noreferrer">Ver estado de cuenta</a>
Sin rel="noopener", la pestaña nueva puede manipular
TU página vía window.opener (phishing de tab). Los navegadores modernos lo
aplican solos, pero explícito = auditoría limpia.
Esquemas útiles más allá de http
<a href="mailto:percy@3soft.pe?subject=Cotizacion">Escríbenos</a>
<a href="tel:+51987654321">Llámanos</a>
<a href="/catalogo-2026.pdf" download>Descargar catálogo</a>
tel:en móvil abre el teclado: oro para negocios locales.downloadfuerza descarga aunque sea un PDF visible en navegador.
Texto del enlace: la regla del contexto fuera de línea
| MAL | BIEN | Por qué |
|---|---|---|
| clic aquí | Ver precios de envíos | Los lectores de pantalla listan enlaces SIN contexto |
| más info | Requisitos del RUC nuevo | «Más» ¿de qué? Google también lee ese texto |
| URL cruda larga | Normativa SUNAT 2026 | Inpronunciable e inútil para SEO |
Prueba rápida: lee tu página imaginando solo la lista de enlaces. Si no entiendes a dónde va cada uno, reescríbelos.
Enlaces que parecen botones (y viceversa)
<a class="btn btn-primary" href="/registro">Crear cuenta</a> <!-- NAVEGA -->
<button type="button" onclick="guardar()">Guardar borrador</button> <!-- ACTUA -->
<a>= cambio de ubicación (con href siempre).<button>= acción en la página (submit, JS).- Un div con onclick NO es clicable para teclado ni lectores: nunca lo uses como enlace.
productos.php?cat=cafe&pag=2) alimentan $_GET.
En el capítulo 12 formalizaremos GET vs POST; por ahora nota cómo el enlace
YA pasa parámetros sin formulario.Puntos clave
- 4 formas de href: relativo, raíz, absoluto, ancla.
- _blank exige rel="noopener noreferrer".
- Texto de enlace descriptivo: SEO y lectores de pantalla.
- a navega, button actúa — jamás div-clicable.
5 · Imágenes básicas
Básico ~12 minEl elemento que más rompe layouts y más penaliza el rendimiento si se usa sin cuidado.
La receta obligatoria
<img src="img/taza-v60.webp"
alt="Taza de ceramica con cafe pasado en filtro V60"
width="800" height="600">
| Atributo | Para qué | Si falta… |
|---|---|---|
src | ruta de la imagen | nada se ve (obvio) |
alt | texto alternativo OBLIGATORIO | lector de pantalla anuncia la ruta cruda; si la imagen falla, caja vacía |
width + height | proporciones reservadas ANTES de cargar | saltos de layout: CLS rojo en Lighthouse (cap. 26) |
Los atributos width/height NO fijan el tamaño visual — solo
informan la PROPORCIÓN. El tamaño final lo decide tu CSS
(max-width: 100%; height: auto;). Con ambos, el navegador reserva el espacio exacto.
alt que sirve de verdad
- Imagen informativa: describe lo que aporta — «Molinillo manual con café molido fino» no «foto1.png» ni «imagen de un molinillo» (redundante: ya sabe que es imagen).
- Imagen decorativa:
alt=""(vacío, pero PRESENTE) — el lector de pantalla la salta. Sin atributo ≠ vacío: sin atributo es error. - Imagen-enlace: el alt describe el DESTINO — «Ir al carrito».
- Captura de texto (gráfico, factura): transcribe lo esencial.
Formatos: cuál y por qué
| Formato | Fuerza | Úsalo para… |
|---|---|---|
| AVIF | máxima compresión moderna | fotos cuando puedas servirlo (con fallback) |
| WebP | 30% menor que JPEG, universal hoy | default de fotos e ilustraciones |
| JPEG/PNG | compatibilidad total | fallbacks y casos muy específicos (PNG: transparencia dura) |
| SVG | vector, escala infinito | logos, iconos (cap. 21) |
La elección fina entre formatos con fallback automático es
trabajo de <picture> — capítulo 20.
figure: imagen CON pie formal
<figure>
<img src="img/curva-tueste.webp" alt="Curva de tueste: temperatura vs tiempo"
width="640" height="360" loading="lazy">
<figcaption>Fig. 1 — Perfil de tueste medio usado en el taller.</figcaption>
</figure>
loading="lazy"nativo: descarga solo cuando va a entrar al viewport. NUNCA en la imagen principal (hero) — atrasaría el LCP.figcaptionqueda asociado semánticamente a su imagen aunque los separes con CSS.- Adelanto: imágenes responsivas completas (srcset/picture) en cap. 20.
Puntos clave
- src + alt SIEMPRE + width/height para reservar espacio.
- Decorativa = alt="" presente; informativa = descripción útil.
- WebP/AVIF como default, SVG para vectores.
- lazy para todo MENOS la imagen principal.
6 · HTML semántico: adiós a la divitis
Intermedio ~13 minLas etiquetas que le dan un MAPA a tu página — para Google, para lectores de pantalla y para ti dentro de seis meses.
El diagnóstico: divitis
<div class="top">
<div class="menu">...</div>
</div>
<div class="contenido">
<div class="post">...</div>
</div>
<div class="pie">...</div>
<!-- funciona, pero no SIGNIFICA nada: un mar de cajas iguales -->
La cura: landmarks estructurales
<header> <!-- intro de la pagina o de una seccion -->
<nav aria-label="Principal">...</nav>
</header>
<main> <!-- UNO solo por pagina: el contenido unico -->
<article> <!-- pieza autocontenida: post, producto, noticia -->
<header><h1>...</h1></header>
<p>...</p>
<section> <!-- agrupacion tematica CON encabezado propio -->
<h2>Comentarios</h2>
...
</section>
</article>
<aside> <!-- tangencial: relacionados, publicidad, filtros -->
...
</aside>
</main>
<footer> <!-- cierre: contacto, legal, sitemap -->
...
</footer>
article vs section vs div: la decisión
| Etiqueta | Prueba mental |
|---|---|
<article> | ¿Tendría sentido en un RSS o compartida sola? Sí → article |
<section> | ¿Agrupa contenido por tema Y tiene su h2/h3? Sí → section |
<aside> | ¿Se puede quitar sin que el contenido principal pierda sentido? Sí → aside |
<div> | Solo para layout/estilo cuando NINGUNA semántica aplica — no es pecado, es último recurso |
nav y header/footer: pueden repetirse
- Varios
<nav>son válidos (principal, breadcrumbs, paginación): distínguelos conaria-label. - Cada
<article>puede tener su header/footer propios (autor y fecha del post, no del sitio). <main>es único y NO vive dentro de article/aside/nav/header/footer.
Quién consume tu semántica
| Consumidor | Qué aprovecha |
|---|---|
| main vs aside: qué es contenido central; nav para sitelinks | |
| Lectores de pantalla | landmarks = navegación rápida por secciones (tecla D en NVDA) |
| Modo lector (Safari/Firefox) | article + jerarquía limpia = lectura sin ruido |
| Tu equipo | class="div2-copia" no necesita documentación si el tag ya explica |
<main>. Esa frontera existe GRACIAS a esta semántica: el router
sabe exactamente dónde pintar.Puntos clave
- Landmarks: header/nav/main/aside/footer dibujan el mapa.
- article = autónomo; section = temático con encabezado; div = último recurso.
- main único por página; nav con aria-label si hay varios.
- Semántica hoy = SEO, accesibilidad y mantenimiento gratis.
7 · Listas
Intermedio ~10 minTres tipos para tres significados — y la lista es la columna vertebral invisible de toda navegación.
Los tres tipos
<ul> <!-- desordenada: el ORDEN no importa -->
<li>Leche evaporada</li>
<li>Café molido</li>
</ul>
<ol> <!-- ordenada: la SECUENCIA importa -->
<li>Moler el café</li>
<li value="3">Calentar el agua a 92°C</li> <!-- continuidad controlada -->
<li>Servir</li>
</ol>
<dl> <!-- descripcion: termino y definicion -->
<dt>V60</dt>
<dd>Derivador cono en V, filtro de papel.</dd>
<dt>French press</dt>
<dd>Inmersion total, filtro metalico.</dd>
</dl>
| Tipo | Significado | Ejemplos de uso real |
|---|---|---|
<ul> | conjunto sin orden | menús, características, tags |
<ol> | secuencia con pasos | recetas, rankings, instrucciones |
<dl> | pares término-definición | glosarios, FAQ (pregunta/respuesta), metadatos clave-valor |
Anidación correcta
<ul>
<li>
Café
<ul> <!-- la lista hija vive DENTRO del li padre -->
<li>Origen</li>
<li>Mezcla</li>
</ul>
</li>
<li>Té</li>
</ul>
Regla de oro: entre <ul>/<ol>
y su <li> NO va nada más. Cualquier texto suelto ahí es HTML inválido que el
navegador recolocará a su antojo (y el validador del cap. 30 te lo cobrará).
ol avanzada: start, reversed y value
<ol start="9" reversed> <!-- cuenta regresiva desde 9 -->
<li>Podio de finalistas</li>
<li>Semifinalistas</li>
</ol>
Listas de navegación: el patrón universal
<nav aria-label="Principal">
<ul>
<li><a href="/">Inicio</a></li>
<li><a href="/catalogo">Catálogo</a></li>
<li><a href="/contacto">Contacto</a></li>
</ul>
</nav>
Los menús SON listas aunque el CSS las convierta en barras horizontales. El marcado semántico da estructura; la presentación la quita. Un menú hecho de divs sueltos pierde la lectura «lista con 3 elementos» en lectores de pantalla.
Puntos clave
- ul = conjunto, ol = secuencia, dl = término-definición.
- Listas hijas SOLO dentro del li correspondiente.
- start/reversed/value controlan numeración sin trucos.
- Todo menú de navegación merece ser una ul dentro de nav.
8 · Tablas de datos
Intermedio ~12 minPara DATOS tabulares — nunca para maquetar. Bien armada, una tabla se lee sola incluso sin CSS.
La anatomía completa
<table>
<caption>Stock de café por origen (kg)</caption>
<thead>
<tr>
<th scope="col">Origen</th>
<th scope="col">Kg</th>
<th scope="col">Precio S/ kg</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Chanchamayo</th>
<td>120</td>
<td>28.00</td>
</tr>
<tr>
<th scope="row">Cajamarca</th>
<td>85</td>
<td>32.50</td>
</tr>
</tbody>
</table>
Piezas que la mayoría omite
<caption>: título OFICIAL de la tabla — primer contacto del lector de pantalla («tabla con 3 columnas, stock de café…»). No lo sustituye un h2 cercano.scope="col"/"row": declara si ese th nombra columna o fila. Es lo que permite a NVDA decir «Cajamarca, 85» en vez de «85, 85, 85».<thead>/<tbody>/<tfoot>: agrupan para estilos fijos, scroll y para que el total (tfoot) quede semánticamente separado.
Combinar celdas: colspan y rowspan
<tr>
<th colspan="2">Ventas por canal</th> <!-- ocupa 2 columnas -->
<td>S/ 15 400</td>
</tr>
<tr>
<td>Tienda web</td>
<td rowspan="2">Ver detalle consolidado</td> <!-- baja 2 filas -->
<td>8 200</td>
</tr>
Con celdas combinadas complejas, los lectores de pantalla pierden el mapa aunque uses scope — para datos muy anidados considera varias tablas simples.
Cuándo NO usar tabla
| Tentación | Herramienta correcta |
|---|---|
| «Maquetar» el formulario o la página | CSS Grid/Flexbox (era pre-historia) |
| Lista de tarjetas de productos | ul + CSS grid |
| Datos que en móvil no caben y «se arreglan» con scroll horizontal eterno | replantear como dl/tarjetas; o tabla solo en desktop + resumen en móvil |
La prueba: ¿tus datos tienen RELACIÓN fila-columna real (cruce de valores)? Sí → table. Son solo ítems apilados → otra cosa.
Móvil sin romper nada: patrón mínimo
<div class="table-responsive"> <!-- wrapper con overflow-x auto (Bootstrap) -->
<table>...</table>
</div>
Puntos clave
- Solo para datos tabulares reales; caption siempre.
- scope col/row convierte celdas en frases comprensibles.
- tfoot para totales; thead para encabezados persistentes.
- Móvil: wrapper responsive, no tablas maquetadoras.
9 · Contenido embebido: iframes seguros
Intermedio ~10 minPáginas vivas dentro de tu página: mapas, videos y formularios de terceros — sin abrirle la puerta a todo.
<iframe src="https://www.google.com/maps/embed?pb=..."
title="Mapa de la tienda en San Isidro"
width="600" height="450"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allowfullscreen></iframe>
Los atributos que importan
| Atributo | Por qué |
|---|---|
title | el iframe es un elemento interactivo SIN texto: los lectores de pantalla necesitan saber qué contiene |
loading="lazy" | el mapa NO se carga hasta acercarse al scroll: cada embed pesa megas |
referrerpolicy | controla cuánto de tu URL recibe el tercero |
sandbox | jaula de permisos (ver abajo) — crítica con contenido no confiable |
sandbox: permisos uno por uno
<!-- jaula maxima: scripts bloqueados, forms bloqueados, mismo origen NO -->
<iframe sandbox src="https://demo.ejemplo.pe/widget.html"></iframe>
<!-- conceder SOLO lo necesario -->
<iframe sandbox="allow-scripts allow-forms"
src="https://encuestas.ejemplo.pe/valoracion"></iframe>
allow-scripts: ejecuta JS (sin él, un widget muere).allow-forms: permite enviar formularios internos.allow-same-origin: PELIGROSO junto a allow-scripts — el embed podría quitarse su propio sandbox. Solo con contenido de confianza absoluta.- Todo lo no listado queda DENEGADO: popups, descargas, navegación de tu página.
Embeds típicos y sus patrones
| Servicio | Patrón seguro |
|---|---|
| YouTube | usa el código «Insertar» oficial (ya trae lazy si activas la casilla); considera la fachada de miniatura + clic (carga real solo al pedirlo) |
| Google Maps | Compartir → Insertar un mapa; añade loading="lazy" y title descriptivo |
| Widgets de pago/redes | revisa SIEMPRE qué piden; si exigen allow-same-origin + scripts, aísla esa página aparte |
Fachada (facade): el truco de rendimiento
<!-- 1. solo una miniatura local (KB, no MB) -->
<a href="https://www.youtube.com/watch?v=XXXX" target="_blank" rel="noopener">
<img src="img/video-portada.webp" alt="Ver: perfil de tueste explicado"
width="1280" height="720">
</a>
<!-- 2. el iframe REAL se inyecta con JS solo tras el clic -->
Un embed de YouTube puede costar ~2 MB de JS propio. Con fachada, tu LCP ni se entera. Es el patrón que usan los sitios serios.
Puntos clave
- Iframe = página ajena corriendo en la tuya: title + lazy obligatorios.
- sandbox niega TODO por defecto; concede mínimo necesario.
- Fachada de imagen para embeds pesados: MB→KB.
- allow-same-origin + allow-scripts juntos = sandbox inútil.
10 · Interactivos modernos sin JavaScript
Intermedio ~13 minAcordeones, modales y popups que antes pedían librerías: hoy son etiquetas nativas, accesibles y sin dependencias.
details/summary: el acordeón gratis
<details>
<summary>¿Hacen envíos a provincia?</summary>
<p>Sí — Lima 24 h, provincias 2 a 5 días vía Olva.</p>
</details>
<details open> <!-- abierto por defecto -->
<summary>Medios de pago</summary>
<ul><li>Yape/Plin</li><li>Tarjetas</li></ul>
</details>
- Teclado y lectores de pantalla: funcionan solos. Cero JS.
- El contenido NO indexado-cerrado igual pertenece al DOM (Google lo lee).
- Ideal FAQ, filtros colapsables, «leer más» técnicos.
dialog: el modal nativo
<button id="abrir">Eliminar producto</button>
<dialog id="confirmar">
<h2>¿Seguro?</h2>
<p>Esta acción no se puede deshacer.</p>
<form method="dialog"> <!-- cierra el dialog sin recargar -->
<button value="cancelar">Cancelar</button>
<button value="si" class="btn-danger">Sí, eliminar</button>
</form>
</dialog>
// TODO el JS necesario:
const dlg = document.getElementById("confirmar");
document.getElementById("abrir").onclick = () => dlg.showModal();
dlg.addEventListener("close", () => console.log("elegido:", dlg.returnValue));
showModal(): fondo oscurecido (::backdrop), foco ATRAPADO dentro, tecla ESC cierra — las tres cosas que toda librería de modales reimplementa mal.form method="dialog"devuelve el value del botón pulsado enreturnValue— confirmaciones sin estados extra.
popover: tooltips y menús declarativos
<button popovertarget="carrito-menu">Mi carrito (2)</button>
<div id="carrito-menu" popover> <!-- light-dismiss: clic fuera cierra -->
<ul>
<li>2x Café Chanchamayo</li>
<li>1x Prensa francesa</li>
</ul>
<a href="/carrito">Ir al carrito</a>
</div>
- Cero JS para abrir/cerrar; clic fuera y ESC cierran (light dismiss).
- Vive en la capa top-layer: adiós z-index wars.
- popover="manual" para control fino desde JS cuando haga falta.
search: agrupar lo buscable
<search> <!-- landmark: region de busqueda -->
<form action="/buscar">
<label for="q">Buscar productos</label>
<input id="q" name="q" type="search">
<button>Buscar</button>
</form>
</search>
| Necesidad | Nativo | JS requerido |
|---|---|---|
| Acordeón / FAQ | details+summary | ninguno |
| Modal con confirmación | dialog | 2 líneas (open/close) |
| Menú contextual / tooltip rico | popover | ninguno |
| Zona de búsqueda | search | ninguno |
Puntos clave
- Antes librería, hoy etiqueta: details, dialog, popover.
- showModal trae foco atrapado + ESC + backdrop gratis.
- popover con light-dismiss vive sobre todos los z-index.
- Menos JS = menos peso, menos bugs, mejor accesibilidad.
11 · Atributos globales y data-*
Intermedio ~12 minAtributos que CUALQUIER elemento acepta — y el puente oficial entre tu markup y JavaScript.
El kit global
| Atributo | Función real | Ojo con… |
|---|---|---|
id | identidad ÚNICA: anclas, label-for, getElementById | duplicados = bugs silenciosos; no uses id para estilar (eso es class) |
class | pertenencia a grupos: estilos y selección múltiple | nombres por ROL (.btn-danger), no por aspecto (.rojo) |
hidden | ocultar semánticamente (no se renderiza) | si tu CSS display:block le «gana», rompes su contrato — evita sobreescribirlo |
title | tooltip nativo al hover | solo info complementaria; NO accesible por teclado/táctil |
lang | idioma de ese fragmento | citas en otro idioma: <q lang="en">less is more</q> |
dir="rtl" | dirección de escritura | contenidos árabe/hebreo embebidos |
contenteditable | editable en vivo | editores rápidos, demos; guarda validando siempre |
tabindex="0" | mete un no-interactivo al orden de tabulación | con moderación: cada tabindex positivo custom = orden roto |
data-*: atributos TUYOS sin romper nada
<button class="btn-agregar"
data-sku="CAF-01"
data-nombre="Café Chanchamayo"
data-precio="28.00">
Agregar al carrito
</button>
HTML ignora cualquier atributo que empiece con data-: son tuyos.
JavaScript los lee como propiedades camelCase del dataset:
// eco directo del cap. 32 de js_01
document.querySelectorAll(".btn-agregar").forEach(btn => {
btn.addEventListener("click", () => {
carrito.agregar({
sku: btn.dataset.sku, // data-sku
nombre: btn.dataset.nombre, // data-nombre
precio: parseFloat(btn.dataset.precio),
});
});
});
- Convención de nombres:
data-id-producto→dataset.idProducto. - Perfectos para estado INICIAL; para estado que cambia mucho, mejor JS puro.
- Son públicos e inspeccionables: NUNCA tokens, precios sensibles ni PII.
El patrón Bootstrap que ya usas
<button data-bs-toggle="collapse" data-bs-target="#filtros">
Filtros
</button>
<div id="filtros" class="collapse">...</div>
Bootstrap 5 entero funciona sobre data-*: declaras el comportamiento EN EL MARKUP y la librería lo interpreta. Escribir data-* propios sigue exactamente esa filosofía — HTML descriptivo, JS genérico.
Estilo vs significado: el test del renombre
Cambia tu paleta completa: ¿.btn-rojo se volvió mentira? Los nombres
por rol (.btn-peligro, .tarjeta-destacada) sobreviven a
cualquier rediseño. El atributo class describe QUÉ ES, no CÓMO SE PINTA.
Puntos clave
- id único para identidad; class para grupos por rol.
- hidden es semántico; title nunca lleva info crítica.
- data-* = metadatos privados tuyos; dataset los lee en camelCase.
- Jamás secretos ni datos personales en data-*.
12 · Formularios I: el contrato form-label-name
Intermedio ~13 minEl puente oficial HTML→PHP: tres piezas que todo formulario necesita para que los datos LLEGUEN.
<form action="/api/contacto.php" method="POST">
<label for="correo">Correo</label>
<input id="correo" name="correo" type="email">
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje"></textarea>
<button type="submit">Enviar consulta</button>
</form>
La regla de oro: sin name, no viaja
Lo que llega al servidor es el par nombre=valor — y ese nombre sale
EXCLUSIVAMENTE del atributo name. El id sirve para label/CSS/JS,
pero al servidor no le importa. Un input sin name es invisible
para PHP:
| Marcas… | Llega a $_POST |
|---|---|
<input name="correo"> | $_POST["correo"] |
<input id="correo"> (sin name) | NADA — no existe para el servidor |
<input name="user[correo]"> | $_POST["user"]["correo"] — arrays automáticos |
label: obligatorio y bien amarrado
<!-- forma explicita (preferida): for apunta al id -->
<label for="tel">Celular</label>
<input id="tel" name="tel" type="tel">
<forma implicita: el input vive dentro del label -->
<label>Ciudad <input name="ciudad"></label>
- Clic en el label enfoca el campo (área táctil más grande).
- Sin label, el lector de pantalla dice «editar texto» sin saber de qué.
- Un label POR campo; el texto descriptivo va FUERA del placeholder.
method GET vs POST: la decisión de arquitectura
| GET | POST | |
|---|---|---|
| Datos viajan en… | URL (?q=cafe&pag=2) | cuerpo HTTP (invisible en URL) |
| PHP lo lee en… | $_GET | $_POST |
| Se puede marcar/recargar | sí (búsqueda, filtros) | recargar re-envía (pedidos, pagos) |
| Volumen | URLs limitadas ~2 KB | megas (y archivos: solo POST) |
| Usa para… | CONSULTAR/buscar/filtrar | CAMBIAR estado (crear, borrar, pagar) |
Regla del manual PHP repetida aquí: GET nunca cambia nada; POST nunca debería poder pedirse dos veces por accidente.
Los tres tipos de button
<button type="submit">Enviar</button> <!-- default: dispara el form -->
<button type="reset">Limpiar</button> <!-- restaura valores iniciales -->
<button type="button">Vista previa</button> <!-- no hace NADA sin JS -->
type="button"
dentro del form envía TODO por accidente. Decláralo siempre.El flujo completo visto desde arriba
- Usuario llena y pulsa submit → navegador arma pares nombre=valor.
- Los codifica (urlencoded o multipart — cap. 16 para archivos).
- PHP recibe en $_GET/$_POST → valida DE NUEVO → responde.
- La respuesta puede ser página completa (clásico) o JSON (fetch, cap. 36 js_01).
Puntos clave
- name es el contrato con el servidor; sin name no hay dato.
- label-for amarrado: accesibilidad + área táctil.
- GET consulta, POST cambia; archivos exigen POST.
- Auxiliares SIEMPRE type="button".
13 · Controles de texto a fondo
Intermedio ~14 minSiete sabores de input de texto y las propiedades que los doman — con la diferencia readonly/disabled que casi nadie explica bien.
Los siete tipos y su especialidad
| Tipo | Especialidad | Validación gratis |
|---|---|---|
text | texto libre general | ninguna |
email | correos (uno o varios con multiple) | formato básico de correo |
url | direcciones web | debe tener esquema (https://…) |
tel | télefonos (sin formato fijo internacional) | ninguna — combínala con pattern (cap. 15) |
search | búsquedas: borra con una X, Enter dispara | ninguna |
password | oculta caracteres (NO cifra: eso hace HTTPS) | ninguna |
hidden | valor invisible que viaja (token CSRF, ids) | el usuario no lo ve ni edita |
El tipo correcto te regala teclado adecuado en móvil, autocompletado del navegador y primera línea de validación — todo por escribir una palabra.
Propiedades que importan
<input type="text" name="titulo"
value="Pedido pendiente" <!-- valor INICIAL (editable) -->
maxlength="80" <!-- tope duro de caracteres -->
placeholder="Ej: entrega Jockey Plaza"
required>
| Propiedad | Hace… | Nota fina |
|---|---|---|
value | valor inicial del control | en PHP: repoblar tras error de validación |
placeholder | ejemplo/hint mientras está vacío | NUNCA sustituye al label (desaparece al escribir) |
minlength / maxlength | rango de caracteres | maxlength corta; minlength VALIDA (con required) |
spellcheck | corrector nativo on/off | off para correos, códigos, nombres propios raros |
autocomplete | autollenado semántico | tokens estándar: name, email, tel, postal-code… |
inputmode | teclado móvil preferido | numeric, decimal, tel, email, url, search |
readonly | solo lectura PERO viaja al servidor | datos calculados que deben llegar a PHP |
disabled | apagado total: NO viaja al servidor | ideal para «lo desactivo hasta que…» |
autocomplete: menos tipeo, más conversiones
<input name="nombre" autocomplete="name">
<input name="correo" type="email" autocomplete="email">
<input name="celular" type="tel" autocomplete="tel national">
<input name="dir" autocomplete="street-address">
<input name="cp" autocomplete="postal-code" inputmode="numeric">
Con tokens correctos, el celular propone SUS datos en un toque y tu formulario deja de perder gente en el paso de pago. Es UX pura gratis.
textarea: el primo multilinea
<textarea name="direccion" rows="3"
placeholder="Av. Arequipa 1234, dpto 501"
maxlength="250">Valor inicial aqui</textarea>
- No tiene value: el valor inicial es su CONTENIDO.
- rows sugiere altura; el usuario puede redimensionar (resize con CSS si molesta).
- Preserva saltos de línea: en PHP, nl2br() los muestra de vuelta.
Puntos clave
- type correcto = teclado + autocompletar + validación base.
- placeholder ejemplifica; label etiqueta. Nunca lo mismo.
- readonly viaja, disabled NO — decide con el envío en mente.
- autocomplete + inputmode son conversión pura en móvil.
14 · Números, fechas y selección
Intermedio ~13 minControles con estado propio: sliders, calendarios nativos y la mecánica exacta de radio/checkbox/select hacia PHP.
Números y rangos
<input type="number" name="cantidad"
min="1" max="99" step="1" inputmode="numeric">
<input type="range" name="distancia" min="0" max="50" step="5"
oninput="this.nextElementSibling.value = this.value + ' km'">
<output>10 km</output>
min/max/stepvalidan solos: 7 con step=5 es inválido.- number sigue aceptando «e» (notación científica): valida también en servidor.
<output>es el compañero semántico del range.
Fechas y horas nativas
<input type="date" name="desde" min="2026-01-01">
<input type="time" name="hora">
<input type="datetime-local" name="reserva">
<input type="month" name="corte">
El navegador muestra el CALENDARIO en el formato regional
del usuario, pero ENVÍA siempre ISO: 2026-08-23. PHP lo parsea sin dramas
(new DateTime($_POST["desde"])). Cero librerías de datepicker.
checkbox: casillas independientes
<input type="checkbox" id="promo" name="promo" value="si">
<label for="promo">Quiero promociones</label>
<!-- varios con el MISMO nombre + corchetes = array en PHP -->
<input type="checkbox" name="extras[]" value="leche">
<input type="checkbox" name="extras[]" value="edulcorante">
| Estado | Lo que llega a PHP |
|---|---|
| marcado, value="si" | $_POST["promo"] === "si" |
| desmarcado | NO llega — ni siquiera existe la clave (isset!) |
| extras[]: dos marcados | $_POST["extras"] = ["leche","edulcorante"] |
El detalle que rompe principiantes: un checkbox apagado
NO envía nada. En PHP comprueba con isset(), nunca esperes cadena vacía.
radio: UNA opción por grupo
<fieldset>
<legend>Tamaño</legend>
<input type="radio" id="t-s" name="tamano" value="S">
<label for="t-s">Small S/12</label>
<input type="radio" id="t-m" name="tamano" value="M" checked>
<label for="t-m">Medium S/16</label>
<input type="radio" id="t-l" name="tamano" value="L">
<label for="t-l">Large S/19</label>
</fieldset>
- Mismo
name= mismo grupo: elegir uno desmarca los demás. - Sin opción marcada, el grupo no viaja — usa
checkedpara proponer default. fieldset+legendnombra al grupo para lectores de pantalla.
select y datalist
<label for="cat">Categoría</label>
<select id="cat" name="categoria">
<option value="" disabled selected>Elegir…</option>
<optgroup label="Bebidas">
<option value="cafe">Café</option>
<option value="te">Té</option>
</optgroup>
<optgroup label="Accesorios">
<option value="prensa">Prensas</option>
</optgroup>
</select>
<!-- datalist: texto libre CON sugerencias -->
<input name="ciudad" list="ciudades-pe">
<datalist id="ciudades-pe">
<option value="Lima"><option value="Arequipa"><option value="Cusco">
</datalist>
- select: respuesta cerrada garantizada;
multiplepermite varias opciones → nómbralocategorias[]para recibir array en PHP. - datalist: el usuario puede escribir algo NO listado — úsalo cuando la lista es ayuda, no regla.
Puntos clave
- Fechas: UI regional, envío ISO — PHP feliz.
- Checkbox off NO viaja: isset() en PHP.
- name[]=arrays: extras[] y categorias[] llegan como listas.
- fieldset/legend nombran grupos de radio para a11y.
15 · Validación nativa y expresiones regulares
Avanzado ~16 minEl navegador valida ANTES de enviar si tú sabes pedirlo: required, min/max y sobre todo pattern con regex.
Las reglas declarativas
<form>
<input name="sku" required minlength="6" maxlength="12"
pattern="[A-Z]{3}-[0-9]{4}"
title="Formato: tres mayusculas, guion y 4 digitos. Ej: CAF-0001">
<input name="precio" type="number" min="0.50" max="9999" step="0.10">
<textarea name="nota" required minlength="10"></textarea>
<button>Registrar</button>
</form>
- Pulsar submit con algo inválido: el navegador BLOQUEA el envío, enfoca el campo
y muestra su globo de error (que usa el atributo
titlecomo explicación). - Es UX instantánea — pero NUNCA seguridad: el servidor revalida SIEMPRE (cualquiera abre DevTools y borra tus patterns).
pattern: regex con dos reglas propias
El patrón se compila con sintaxis JavaScript (como /…/u) pero:
- Sin barras: escribes
[0-9]{8}, no/[0-9]{8}/. - Anclaje implícito: se compara contra el valor COMPLETO — es como si
llevara
^(…)$invisible. Un pattern[0-9]{8}rechaza «123456789» aunque contenga ocho dígitos.
Mini-guía de lectura
| Pieza | Significa… |
|---|---|
[0-9] o \d | un dígito cualquiera |
[A-Za-z] | cualquier letra (sin tildes: cuidado con ñ) |
{8} / {2,4} / {2,} | exactamente 8 / entre 2 y 4 / al menos 2 |
* / + / ? | 0 o más / 1 o más / opcional (una vez) |
| | alternativa: (10|20) — diez o veinte |
() | grupo: aplica cuantificadores y alternancias al bloque |
\. | punto literal (sin escape, "." significa «cualquier carácter») |
\s | espacio, tab o salto de línea |
Patrones peruanos listos para copiar
<!-- DNI: exactamente 8 digitos -->
<input name="dni" inputmode="numeric" pattern="[0-9]{8}"
title="DNI son 8 digitos">
<!-- Celular: empieza en 9 y tiene 9 digitos -->
<input name="cel" type="tel" inputmode="numeric" pattern="9[0-9]{8}"
title="Celular: 9 digitos empezando en 9. Ej: 987654321">
<!-- Placa auto (formato clasico): ABC-123 -->
<input name="placa" pattern="[A-Z0-9]{3}-[0-9]{3}"
title="Formato AAA-000" style="text-transform:uppercase">
<!-- RUC: 11 digitos empezando en 10 (natural) o 20 (juridica) -->
<input name="ruc" inputmode="numeric" pattern="(10|20)[0-9]{9}"
title="RUC: 11 digitos, inicia en 10 o 20">
<!-- SKU interno del curso: CAF-0001 -->
<input name="sku" pattern="[A-Z]{3}-[0-9]{4}">
<!-- Hora 24h: 08:30, 23:59 -->
<input name="hora" pattern="([01][0-9]|2[0-3]):[0-5][0-9]" placeholder="HH:MM">
^/$ dentro
del pattern (sobran: ya están implícitos); dejar espacios accidentales tras las comillas
(¡el espacio cuenta!); y confiar en [A-Z] para nombres — la ñ y tildes
viven FUERA de A-Z (usa clases Unicode o valida en servidor con mb_).Mensajes de error humanos
// el globo nativo es feo pero funcional; personalizar cuando valga la pena:
const dni = document.forms.registro.dni;
dni.addEventListener("invalid", () =>
dni.setCustomValidity(dni.value === "" ? "El DNI es obligatorio"
: "Un DNI tiene 8 dígitos"));
dni.addEventListener("input", () => dni.setCustomValidity("")); // limpiar al tipear
En CSS, :user-invalid pinta SOLO los campos
que el usuario ya tocó mal — evita pintar todo de rojo antes de que escriba.
Puntos clave
- pattern sin barras y con anclas implícitas: casa el valor COMPLETO.
- title explica el formato esperado: alimenta el globo nativo.
- Cliente valida para UX; servidor revalida por seguridad.
- A-Z no incluye ñ/tildes: piensa en español real.
16 · Subida de archivos I: el input file
Intermedio ~13 minEl único control que transporta BITS completos al servidor — con su enctype obligatorio y su mapa hacia $_FILES.
La receta mínima que SÍ funciona
<form action="/api/foto.php" method="POST"
enctype="multipart/form-data"> <!-- OBLIGATORIO para archivos -->
<label for="foto">Foto del producto</label>
<input type="file" id="foto" name="foto" accept="image/*">
<button>Subir</button>
</form>
$_FILES aparece vacío.
Es el error de subida más común de la historia de PHP.Los tres atributos que moldean el control
| Atributo | Función | Ejemplo |
|---|---|---|
accept | filtra lo que el diálogo muestra (SUGERENCIA, no seguridad) | .pdf,.docx / image/png,image/jpeg / image/* |
multiple | permite varios archivos a la vez | nómbralo array: name="fotos[]" |
capture | en móvil abre cámara directa | capture="environment" (trasera) | "user" (frontal) |
<!-- galeria multiple -->
<input type="file" name="fotos[]" multiple accept="image/*">
<!-- comprobante PDF desde apps de archivos o escaner -->
<input type="file" name="comprobante" accept=".pdf,image/*">
<!-- selfie directa de camara trasera -->
<input type="file" name="selfie" accept="image/*" capture="environment">
Qué recibe PHP: el mapa de $_FILES
Para el input anterior (name="foto"), PHP arma:
| Clave | Contenido | Uso típico |
|---|---|---|
name | nombre ORIGINAL en la máquina del usuario | solo informativo — NUNCA confíes ni reuses tal cual |
type | MIME DECLARADO por el navegador | se puede fingir: valida contenido real (cap. 17) |
size | bytes recibidos | tu segunda línea contra archivos monstruo |
tmp_name | ruta temporal donde PHP ya guardó el archivo | move_uploaded_file() lo lleva a su destino final |
error | código UPLOAD_ERR_* (0 = OK) | SIEMPRE revisarlo antes de mover nada |
Con name="fotos[]" + multiple, cada clave
se vuelve un ARRAY paralelo: fotos["name"][0], fotos["tmp_name"][0]… El manual PHP
cubre move_uploaded_file y validación profunda — aquí dominas la mitad HTML.
Enviar con fetch: FormData hace el multipart
// eco del cap. 36 de js_01 — SIN tocar Content-Type manualmente
form.addEventListener("submit", async (e) => {
e.preventDefault();
const r = await fetch("/api/foto.php", {
method: "POST",
body: new FormData(form), // detecta archivos y arma el multipart
});
aviso.textContent = r.ok ? "Subida exitosa" : `Error ${r.status}`;
});
Puntos clave
- enctype multipart/form-data o no hay archivo.
- accept filtra el diálogo; multiple + name[] da arrays.
- $_FILES trae 5 claves; error y tmp_name son tus amigas.
- FormData + fetch = subida AJAX sin librerías.
17 · Subida de archivos II: variantes y límites reales
Avanzado ~15 minDrag & drop, previsualización y la verdad incómoda: quien decide cuánto pesa un archivo es el SERVIDOR, nunca tu HTML.
Zona drag & drop
<label id="zona" for="arrastre"
style="border:2px dashed #b23117; padding:2rem; display:block;">
Arrastra tus fotos aqui o haz clic para elegirlas
</label>
<input type="file" id="arrastre" name="fotos[]" multiple accept="image/*" hidden>
const zona = document.getElementById("zona");
const input = document.getElementById("arrastre");
zona.addEventListener("dragover", e => e.preventDefault()); // NECESARIO para soltar
zona.addEventListener("drop", e => {
e.preventDefault();
const dt = new DataTransfer(); // contenedor de FileList
[...e.dataTransfer.files].forEach(f => {
if (f.type.startsWith("image/") && f.size <= 5 * 1024 * 1024) dt.items.add(f);
});
input.files = dt.files; // el form queda listo
});
input.addEventListener("change", () => input.form.requestSubmit());
- Sin
preventDefault()en dragover, el navegador ABRE el archivo en lugar de soltarlo — bug número uno de toda zona de drop. - Filtramos peso/tipo AL MOMENTO de aceptar el drop: UX honesta.
Preview instantáneo sin servidor
input.addEventListener("change", () => {
preview.innerHTML = "";
for (const f of input.files) {
if (!f.type.startsWith("image/")) continue;
const url = URL.createObjectURL(f); // referencia LOCAL al File
const img = new Image(120, 90);
img.src = url;
img.onload = () => URL.revokeObjectURL(url); // liberar memoria al cargar
preview.append(img);
}
});
createObjectURL crea un enlace local al archivo SIN subir nada: la vista previa es instantánea y gratis. revokeObjectURL evita fugas de memoria.
El límite de peso LO PONE EL SERVIDOR
upload_max_filesize=8M,
el POST muere ahí — ni llega a tu código.| Directiva php.ini | Controla… | Regla práctica |
|---|---|---|
upload_max_filesize | peso máximo POR archivo | tu tope real por archivo |
post_max_size | TODO el POST (archivos + campos) | ≥ suma esperada; si te pasas, $_POST y $_Files llegan VACÍOS |
max_file_uploads | archivos simultáneos por request | default 20; cuenta los name[] |
memory_limit | memoria del script | solo importa si procesas imágenes en memoria |
Síntomas clásicos: «$_FILES vacío» = post_max_size reventado; «error=1» = superaste upload_max_filesize; «error=2» = superaste el MAX_FILE_SIZE del formulario (campo oculto que PHP respeta).
Estrategia profesional en tres capas
- HTML (UX): accept correcto, hint de tamaño en el label, validación JS amable antes de gastar la subida del usuario.
- Cliente JS (cortesía): f.size y f.type filtrados en change/drop. Nunca como ÚNICO control.
- Servidor (seguridad): revisa error, size, tipo REAL del contenido (finfo/mime_content_type — un .exe renombrado a .jpg declara image/jpeg), renombra el archivo, guarda FUERA del raíz público o con nombre aleatorio.
Puntos clave
- dragover con preventDefault o el drop muere.
- createObjectURL/revoke: previews gratis y limpias.
- upload_max_filesize y post_max_size mandan; errores 1/2/3/4 tienen significado.
- El MIME declarado se finge fácil: valida contenido real en servidor.
18 · Formularios accesibles y con buena UX
Intermedio ~13 minLa diferencia entre un formulario que la gente ABANDONA y uno que completa: etiquetas honestas, errores visibles y foco que no se pierde.
Las cinco reglas de oro
- Todo campo con label visible — placeholder NO es label: desaparece al escribir, suele ser gris de bajo contraste y el lector de pantalla lo anuncia como «ejemplo», no como nombre.
- Errores junto al campo, en texto (no solo color), con el foco movido al primero.
- Grupos nombrados: fieldset+legend para radios/checkboxes relacionados; los lectores anuncian la legend con cada opción.
- autocomplete correcto: autollenado fiable = formularios más cortos.
- Orden natural: el tabulado sigue el orden del DOM — si tu CSS reordena visualmente, prueba navegar SOLO con Tab.
Errores que sí ayudan: aria-invalid + aria-describedby
<label for="ruc">RUC</label>
<input id="ruc" name="ruc" inputmode="numeric"
pattern="(10|20)[0-9]{9}"
aria-describedby="ruc-ayuda ruc-error"
aria-invalid="true">
<p id="ruc-ayuda" class="small text-secondary">11 dígitos, inicia en 10 o 20.</p>
<p id="ruc-error" class="small text-danger fw-bold">
El RUC debe tener exactamente 11 dígitos.
</p>
aria-describedbyenlaza ayuda y error al campo: NVDA los lee juntos.aria-invalid="true"se pone por JS al fallar y SE QUITA al corregir.- Error en rojo + icono + texto: nunca SOLO color (daltonismo, impresión).
// mover el foco al primer campo invalido tras validar:
form.addEventListener("submit", (e) => {
if (!form.checkValidity()) {
e.preventDefault();
const malo = form.querySelector(":invalid");
malo.focus(); // teclado listo para corregir
malo.scrollIntoView({ block: "center" });
}
});
UX que reduce abandono
| Práctica | Impacto real |
|---|---|
| Una columna, campos visibles sin scroll raro | completado más rápido en móvil |
| required mínimo indispensable | cada campo opcional = menos fricción |
| Deshabilitar submit UNA vez enviado + texto «Enviando…» | mata el doble-pedido clásico |
| Repoblar valores tras error de servidor (value=) | nadie reescribe 10 campos por un RUC malo |
| Botón con verbo concreto («Registrar pedido») | «Enviar» genérico genera desconfianza |
Cuando conviene novalidate
<form novalidate>
Apaga los globales nativos del navegador para usar TU UI de errores consistente (como la del cap. 34 de js_01). La validación nativa sigue disponible vía checkValidity()/reportValidity() — novalidate solo cambia QUIÉN muestra.
Checklist final de formulario profesional
- Cada input tiene label-for amarrado y name pensado para PHP.
- Tipos correctos + pattern donde aplique + title explicativo.
- fieldset para grupos; autocomplete/inputmode en cada campo personal.
- Errores descritos con aria-describedby, foco al primero, texto además de color.
- Servidor revalida TODO — el cliente solo adelanta el feedback.
Puntos clave
- Placeholder jamás reemplaza label visible.
- aria-describedby une campo, ayuda y error en una sola voz.
- Foco al primer error: el usuario sabe dónde actuar.
- Repoblar + botones con verbo + anti doble-submit.
19 · Audio y video nativos
Intermedio ~12 minLa etiqueta que mató a Flash: multimedia con controles propios, subtítulos y políticas de reproducción serias.
<video src="tueste.webm" controls
width="960" height="540"
poster="tueste-portada.webp"
preload="metadata">
<track kind="captions" src="tueste-es.vtt" srclang="es" label="Espanol" default>
Tu navegador no soporta video nativo.
</video>
Los atributos que gobiernan
| Atributo | Función | Cuándo |
|---|---|---|
controls | muestra la barra nativa (play, volumen, pantalla completa) | SIEMPRE en video visible — un video sin controles es una trampa |
poster | imagen de portada ANTES de cargar | siempre: evita primer frame negro |
preload | "none" | "metadata" | "auto" | "metadata" es el default sensato; "auto" solo si SABES que lo verán |
muted + autoplay | arranque automático PERMITIDO solo silenciado | videos de fondo decorativos (y con loop) |
loop / playsinline | repetir / no abrir fullscreen en iOS | fondos animados: muted+loop+playsinline juntos |
source múltiple: formatos en cascada
<video controls width="960" height="540">
<source src="tueste.webm" type="video/webm">
<source src="tueste.mp4" type="video/mp4">
El navegador toma el PRIMERO que soporte. mp4 (H.264) como ultimo = universal.
</video>
<audio controls preload="metadata">
<source src="entrevista.ogg" type="audio/ogg">
<source src="entrevista.mp3" type="audio/mpeg">
</audio>
Subtítulos con track + WebVTT
<track kind="captions" src="subtitulos/es.vtt" srclang="es" label="Espanol" default>
<track kind="subtitles" src="subtitulos/en.vtt" srclang="en" label="English">
# formato WebVTT: marca de tiempo flecha texto
$ cat tueste-es.vtt
WEBVTT
00:00.000 --> 00:04.200
Granos verdes entrando al tambor.
00:04.201 --> 00:08.900
El primer crujido llega cerca de 196 grados.
- Son ACCESIBILIDAD y SEO a la vez: Google indexa tus transcripciones.
- kind="captions" incluye efectos sonoros; "subtitles" solo diálogo.
Video propio vs YouTube embed
| Criterio | <video> propio | YouTube/Vimeo embed |
|---|---|---|
| Control total (sin logos ni anuncios) | sí | no |
| Costo de ancho de banda | tuyo (¡los videos pesan!) | del servicio |
| Peso en tu página | lo que cargues (lazy/poster) | ~1–2 MB de su JS (usa fachada, cap. 9) |
| Descubrimiento | tu SEO | motor del platform |
Puntos clave
- controls siempre; poster siempre; preload metadata.
- autoplay exige muted (política de los navegadores).
- track/WebVTT: accesibilidad + SEO indexable.
- Fondos decorativos: muted + loop + playsinline.
20 · Imágenes responsivas
Intermedio ~13 minLa misma foto servida a un iPhone SE y a un monitor 4K NO debería pesar lo mismo. Aquí se corrige eso.
srcset/sizes: resolución automática
<img
src="taza-800.webp"
srcset="taza-400.webp 400w,
taza-800.webp 800w,
taza-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="Taza de cafe con metodo V60"
width="1600" height="1067">
| Atributo | Qué dice… |
|---|---|
srcset | «tengo estas variantes y sus anchos reales EN PIXELES» |
sizes | «esta imagen ocupará ~X del viewport según el breakpoint» |
src | fallback para navegadores prehistóricos (y bots sin srcset) |
Con ambos datos, EL NAVEGADOR elige el archivo óptimo considerando ancho de pantalla Y densidad de píxeles (DPR). Tú declaras; él decide. Un móvil 390px con DPR=3 descarga ~1170w efectivos — no el 1600 completo.
picture: cambiar la IMAGEN misma (art direction)
<picture>
<source type="image/avif" srcset="hero.avif">
<source type="image/webp" srcset="hero.webp">
<img src="hero.jpg" alt="Barista latte-art en barra"
width="1920" height="1080">
</picture>
<!-- encuadres distintos por dispositivo -->
<picture>
<source media="(max-width: 600px)" srcset="hero-celular-crop.webp">
<img src="hero-wide.webp" alt="Mostrador de la cafeteria"
width="1600" height="900">
</picture>
| Necesidad | Herramienta |
|---|---|
| Misma imagen, pesos distintos | srcset/sizes solos |
| Formatos nuevos con fallback (AVIF/WebP) | picture + source type |
| Encuadre/recorte distinto en móvil | picture + source media |
Las dos leyes del peso
- Lazy para todo lo que esté bajo el pliegue:
loading="lazy"(visto en cap. 5) combinado con srcset funciona igual. - Hero NUNCA lazy — hero con prioridad:
fetchpriority="high"le grita al navegador «descarga ESTA primero» y mejora tu LCP medido (Core Web Vitals).
<img src="hero-1200.webp" fetchpriority="high"
alt="..." width="1200" height="675">
npx sharp-cli resize …, cwebp, o el pipeline de tu bundler) y
nombra por ancho: taza-400.webp. Escribir srcset a mano es fácil;
producir archivos gigantes «porque sí» es lo caro.Puntos clave
- srcset+w declara variantes; sizes declara layout; navegador decide.
- picture para formatos y art direction; img hijo SIEMPRE.
- lazy abajo del pliegue; hero con fetchpriority high.
- Ancho real en px de cada variante: datos, no suposiciones.
21 · SVG inline
Intermedio ~11 minVectores dentro del HTML: nitidez infinita, peso mínimo y colores que obedecen a tu CSS.
Tres formas de usar un SVG
| Forma | Ventaja | Limitación |
|---|---|---|
<img src="icono.svg"> | cacheable como imagen | CSS/JS externos NO lo alcanzan (no cambia color) |
| inline en el HTML | el CSS de la página lo pinta; animable | no se cachea aparte; ensucia el markup si abusas |
sprite + <use> | un solo archivo con todos los iconos | requiere estructura id por símbolo |
Inline: el icono que hereda color
<svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
role="img" aria-hidden="true" focusable="false">
<path d="M6 6 L18 18 M6 18 L18 6"/> <!-- una X: cerrar -->
</svg>
currentColor: el trazo toma el color del TEXTO padre — modo claro/oscuro gratis (la clave tut-theme ya alterna color y el SVG obedece).viewBoxdefine el lienzo lógico: escala a cualquier size sin perder nada.aria-hidden="true"cuando el icono es DECORATIVO junto a texto visible; si va SOLO, usarole="img"+<title>descriptivo.focusable="false"evita stops fantasma de Tab en IE antiguo — hábito sano.
Sprite: todos los iconos, una descarga
<svg style="display:none"> <!-- libreria invisible -->
<symbol id="ic-carrito" viewBox="0 0 24 24">
<path d="M3 3h2l2.6 12.4A2 2 0 0 0 9.6 17H19"/>
</symbol>
<symbol id="ic-estrella" viewBox="0 0 24 24">
<path d="M12 2l3 7h7l-5.5 4.5L18.5 21 12 16.8 5.5 21l2-7.5L2 9h7z"/>
</symbol>
</svg>
<svg class="ico" aria-hidden="true"><use href="#ic-carrito"/></svg>
<svg class="ico" aria-hidden="true"><use href="#ic-estrella"/></svg>
SVG vs img vs fuente de iconos
| Necesidad | Ganador | Por qué |
|---|---|---|
| Iconos UI que cambian de color | SVG inline/sprite | currentColor + cero peticiones |
| Ilustración compleja estática | img svg | cacheable, markup limpio |
| Fotos / imágenes ricas | WebP/AVIF (cap. 20) | SVG NO sirve para fotos: explota en peso |
| Miles de iconos con ligaduras | fuentes de iconos | válido, pero menos accesible y más frágil |
SVG interactivo: hover por pieza
<svg viewBox="0 0 100 60" class="plano">
<style>
.mesa { fill:#eee; cursor:pointer; }
.mesa:hover { fill:#f16529; }
</style>
<a href="/reserva?mesa=4">
<rect class="mesa" x="10" y="10" width="20" height="14" rx="3"/>
</a>
<text x="15" y="45" font-size="7">Mesa 4</text>
</svg>
Puntos clave
- Inline cuando necesitas control; img cuando necesitas cache.
- currentColor + aria-hidden: iconos decorativos perfectos.
- Sprites = una petición para toda la librería.
- Fotos nunca en SVG; uploads de SVG siempre sanitizados.
23 · Canvas: primer vistazo
Intermedio ~12 minUn lienzo vacío donde JavaScript dibuja píxeles: gráficos, juegos y visualización de datos — y cuándo NO usarlo.
<canvas id="grafico" width="600" height="300"
role="img" aria-label="Ventas semanales: maximo viernes S/ 4200">
Tu navegador no soporta canvas.
</canvas>
- Los atributos width/height definen la RESOLUCIÓN interna del bitmap (¡no el tamaño visual! ese lo da CSS como en cualquier img).
- El contenido del canvas es INVISIBLE para lectores de pantalla: aria-label con el resumen de los datos no es opcional.
El contexto 2D: tu API de dibujo
const cv = document.getElementById("grafico");
const ctx = cv.getContext("2d"); // TODO pasa por ctx
// fondo
ctx.fillStyle = "#faf7f2";
ctx.fillRect(0, 0, cv.width, cv.height);
// barras de ventas
const ventas = [2600, 3100, 2900, 3400, 4200];
ventas.forEach((v, i) => {
const alto = v / 4200 * 240; // escalar a 240px utiles
ctx.fillStyle = i === 4 ? "#b23117" : "#f16529";
ctx.fillRect(40 + i * 110, 280 - alto, 80, alto);
ctx.fillStyle = "#333";
ctx.font = "13px sans-serif";
ctx.fillText(`S/ ${v}`, 40 + i * 110, 275 - alto);
});
Modelo mental opuesto a SVG: canvas es «pintar encima» — lo dibujado deja de ser un elemento; para «mover» algo hay que BORRAR todo y repintar. SVG conserva objetos editables e indexables.
Animación básica: el bucle
function cuadro(t) {
ctx.clearRect(0, 0, cv.width, cv.height); // borrar TODO
actualizarFisica();
dibujarTodo(); // repintar TODO
requestAnimationFrame(cuadro); // eco cap. 39 js_01: rAF, no setInterval
}
requestAnimationFrame(cuadro);
¿Canvas, SVG, video o DOM?
| Caso | Elegir | Razón |
|---|---|---|
| Gráfico estático/simple | SVG o CSS | accesible, escalable, inspeccionable |
| Dashboards con miles de puntos | canvas | píxeles baratos: DOM moriría con 10k nodos |
| Juegos 2D / efectos | canvas | redibujo completo a 60fps sin costo DOM |
| Video real | video | codec nativo, controles, subtítulos |
| Gráficos de negocio serios | biblioteca sobre canvas/SVG | Chart.js/D3 resuelven ejes, tooltips, leyendas |
Puntos clave
- width/height = resolución del bitmap; aria-label obligatorio.
- ctx 2D: pintar encima; mover = clearRect + repintar.
- rAF para el bucle; miles de nodos → canvas gana.
- Para gráficos de negocio: biblioteca probada primero.
23 · El head bien hecho
Intermedio ~13 minLo invisible que decide cómo te ven Google, WhatsApp, el móvil y la pestaña del navegador.
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Café de origen peruano · Aroma & Punto</title>
<meta name="description" content="Tostamos cafés de Chanchamayo y
Cajamarca. Envíos a todo el Perú en 48 horas.">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="canonical" href="https://aromaypunto.pe/">
<!-- como se ve tu link compartido -->
<meta property="og:title" content="Aroma & Punto — Café de origen">
<meta property="og:description" content="Tostado fresco, de chacra a taza.">
<meta property="og:image" content="https://aromaypunto.pe/og-cafe.webp">
<meta name="twitter:card" content="summary_large_image">
</head>
title: la media frase más valiosa de tu sitio
- Fórmula probada:
Contenido · Marca(50–60 caracteres: lo que Google muestra sin cortar). - ÚNICO por página: «Inicio | Aroma», «Catálogo | Aroma», no todos iguales.
- Las palabras iniciales pesan más: contenido primero, marca después.
description: tu anuncio gratis en resultados
Google no la usa para rankear directamente, pero SÍ la muestra como resumen cuando es relevante: 150–160 caracteres con verbo y propuesta («Envíos a todo el Perú en 48 horas» convierte más que «Bienvenidos a nuestro sitio»).
Favicons modernos: uno basta
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <!-- 180x180 para iOS -->
Un SVG escala solo; el PNG touch-icon cubre iOS. La era de 6 archivos favicon.ico quedó atrás.
Open Graph: tu tarjeta al compartir
| Meta | Controla… |
|---|---|
og:title / og:description | título y texto de la tarjeta en WhatsApp/Facebook/Slack |
og:image | la miniatura — ABSOLUTA (https://…), ideal 1200×630 |
og:url + canonical | la dirección «oficial» ante duplicados (con y sin www, con query strings) |
Sin og:image, compartir tu producto en WhatsApp sale gris y anónimo. Con ella, parece tienda seria. Es la mejora más barata de CTR que existe.
Otros head útiles (y los que ya NO)
<meta name="theme-color" content="#b23117"> <!-- barra del navegador movil -->
<meta http-equiv="X-UA-Compatible" content="..."> <!-- MUERTO: borrar si lo ves -->
<meta name="keywords" content="..."> <!-- ignorado desde hace anos: fuera -->
Puntos clave
- title único 50–60 chars: contenido primero, marca después.
- description = anuncio persuasivo, no lista de palabras.
- og:image absoluta 1200×630 o tu link se ve muerto.
- canonical contra duplicados; keywords/X-UA-Compatible fuera.
24 · SEO técnico on-page
Avanzado ~14 minTodo lo que un crawler premia y puedes controlar SIN backlinks ni presupuesto: estructura, URLs y señales limpias.
La jerarquía que Google entiende
<h1>Café de origen Chanchamayo</h1> <!-- UN tema por pagina -->
<h2>Notas de cata</h2>
<h3>Acidez</h3>
<h2>Preparación recomendada</h2>
<!-- senales de relevancia natural:
title = tema oficial
h1 = desarrollo principal
h2/h3 = subtemas indexables
texto de enlaces internos hacia aqui: "cafe chanchamayo", no "clic aqui" -->
URLs limpias y estables
| MAL | BIEN | Por qué |
|---|---|---|
/prod.php?id=17&ref=x9 | /cafe/chanchamayo | legible por humanos Y crawlers; palabras clave en la URL |
/CAFE_Chanchamayo_FINAL2.html | /cafe/chanchamayo | minúsculas, guiones, sin versiones eternas |
| Cambiar URL cada rediseño | URLs eternas + redirect 301 cuando toque | cada URL acumula autoridad; tirarla es regalarla |
canonical: una sola verdad ante duplicados
<link rel="canonical" href="https://aromaypunto.pe/cafe/chanchamayo">
Estas tres URLs sirven el mismo producto:
?orden=precio, ?pag=2&color=rojo, con/sin www…
canonical le dice a Google cuál INDEXAR — evita diluir tu posicionamiento en copias.
robots: quién entra y qué se indexa
<!-- por pagina: no indexar carritos, gracias, pasos de checkout -->
<meta name="robots" content="noindex, follow">
<!-- public/robots.txt global:
User-agent: *
Allow: /
Disallow: /carrito/
Disallow: /api/
Sitemap: https://aromaypunto.pe/sitemap.xml -->
sitemap.xml: inventario de tus URLs reales — ayudas al crawler a no perderse.- noindex SOLO en páginas sin valor de búsqueda (formularios enviados, páginas de gracias).
Enlazado interno: tu reparto de autoridad
- Toda página importante alcanzable en ≤3 clics desde el inicio.
- Anclas descriptivas entre productos relacionados («compara con el Bourbon de Cajamarca»).
- Breadcrumbs visibles + marcadas (JSON-LD, cap. 27) — sitelinks bonitos en resultados.
Core Web Vitals: SEO técnico medible
| Vital | Qué mide | Tu palanca HTML |
|---|---|---|
| LCP | cuánto tarda el contenido principal | hero con fetchpriority high (cap. 20); preload (cap. 26) |
| CLS | saltos de layout | width/height SIEMPRE (cap. 5) |
| INP | respuesta a interacción | menos JS bloqueante (defer, cap. 26); dialog/popover nativos (cap. 10) |
/cafe/chanchamayo
son exactamente las que tu Router PHP (php_01 cap. 41) resuelve con regex — y la SPA
de js_01 cap. 41 replica con History API. Misma arquitectura, tres capas.Puntos clave
- Un tema por página: title + h1 + URL contando la misma historia.
- URLs minúsculas-guion-eternas; canonical ante duplicados.
- Vitals son ranking: LCP/CLS/INP tienen palanca HTML directa.
- Sitemap + breadcrumbs + enlazado interno ≤3 clics.
25 · Accesibilidad HTML primero
Avanzado ~14 minEl 90% de la accesibilidad sale de usar bien el HTML de este manual. ARIA es el remate, no el fundamento.
La pirámide: semántica > ARIA
<button> trae foco, Enter/Espacio y rol
anunciado — un div con role="button" y tabindex te obliga a reprogramar TODO eso.Checklist por pieza del manual
| Pieza (cap.) | Regla de oro a11y |
|---|---|
| Encabezados (3) | jerarquía sin saltos = índice navegable con tecla H |
| Imágenes (5) | alt informativo o alt="" decorativa — nunca ausente |
| Landmarks (6) | header/nav/main/footer: navegación por regiones (tecla D en NVDA) |
| Tablas (8) | caption + th scope: celdas leídas con contexto |
| Iframes (9) | title descriptivo obligatorio |
| Nativos (10) | details/dialog/popover ya son accesibles — otra razón para no reinventarlos |
| Formularios (12–18) | label-for, fieldset/legend, aria-describedby para errores |
Foco visible: nunca lo mates
<!-- MAL: outline none sin reemplazo = navegar a ciegas -->
<a href="/catalogo" style="outline:none">Catálogo</a>
<!-- BIEN: foco personalizado pero SIEMPRE visible -->
<a href="/catalogo" class="enlace">Catálogo</a>
.enlace:focus-visible {
outline: 3px solid #b23117;
outline-offset: 2px;
}
Contraste y tamaño objetivo
- Texto normal ≥ 4.5:1 contra su fondo; texto grande ≥ 3:1 (WCAG AA).
- Grises sobre blancos «elegantes» suelen fallar — mide con DevTools (el color picker muestra el ratio) o Lighthouse.
- Áreas táctiles ≈ 44×44 px mínimo: los links de 14px apretados castigan móviles.
ARIA donde sí hace falta
<!-- navs multiples se distinguen con label -->
<nav aria-label="Principal">...</nav>
<nav aria-label="Migas de pan">...</nav>
<!-- estado vivo para zonas que cambian solas -->
<p aria-live="polite" id="aviso"></p>
<!-- expandir/contraer hecho a mano SOLO si details no aplica -->
<button aria-expanded="false" aria-controls="menu-movil">Menú</button>
Prueba rápida de 60 segundos (hazla HOY en tu proyecto)
- Desconecta el mouse: ¿puedes operar TODO con Tab/Enter/Espacio?
- Activa NVDA (gratis) 30 segundos: ¿los landmarks y labels tienen sentido?
- Lighthouse → Accessibility: corre y corrige los rojos.
Puntos clave
- Semántica correcta > ARIA parche.
- :focus-visible para estilizar SIN matar el foco.
- Contraste 4.5:1 y targets de ~44px.
- aria-live para avisos dinámicos; labels en navs múltiples.
26 · Rendimiento desde el markup
Avanzado ~13 minAntes de tocar JS o servidor: los atributos y ordenes de carga que HTML pone a tu alcance.
defer vs async: el gráfico mental
<script src="analitica.js" async></script>
<script src="app.js" defer></script>
| Atributo | Descarga | Ejecuta | Úsalo para… |
|---|---|---|---|
| (nada) | bloquea el parseo | inmediato al llegar | nada, en 2026 |
async | paralela | en cuanto llegue (orden NO garantizado) | scripts independientes: analítica, widgets |
defer | paralela | tras parsear DOM, en orden | TU app: necesita todo el árbol listo |
Regla de la casa: defer por defecto; async solo cuando
el script no toca tu DOM. Los módulos ESM ya son defer por naturaleza
(<script type="module">).
preload y preconnect: adelantar la cola
<!-- conexion temprana a dominios criticos (fuentes, API) -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- descarga YA el recurso que LCP necesita y CSS descubrira tarde -->
<link rel="preload" as="image" href="hero-1200.webp" fetchpriority="high">
<link rel="preload" as="font" href="/fuentes/inter.woff2" type="font/woff2"
crossorigin>
- preconnect: ahorra el handshake TLS (~100–300 ms) del primer recurso cruzado.
- preload con moderación: adelantar 5 recursos es atrasar todos — solo lo crítico (hero, fuente principal).
- El combo hero: preload + fetchpriority high = LCP feliz (cap. 20 y 24).
lazy loading nativo generalizado
<img src="galeria/12.webp" loading="lazy" width="800" height="600" alt="...">
<iframe src="https://maps.google..." loading="lazy" title="Mapa"></iframe>
CSS crítico inline + el resto diferido
<head>
<style>/* SOLO estilos above-the-fold: ~10-15 KB */</style>
<link rel="stylesheet" href="/css/completo.css" media="print"
onload="this.media='all'">
</head>
El truco media="print"+onload carga el CSS completo sin bloquear el primer render. Úsalo con criterio: primero mide si tu CSS bloquea.
Presupuesto de rendimiento (budget)
| Métrica | Objetivo móvil 4G |
|---|---|
| Peso total primera carga | ≤ 500 KB |
| LCP | ≤ 2.5 s |
| Fuentes custom | ≤ 2 familias, woff2 subset |
| Scripts bloqueantes | 0 |
Puntos clave
- defer default; async solo para independientes; bloqueante nunca.
- preconnect para dominios clave; preload solo lo crítico.
- loading lazy en imágenes/iframes bajo el pliegue.
- Presupuesto escrito: lo que no se mide, se infla.
27 · Datos estructurados JSON-LD
Avanzado ~12 minHablarle a Google en su idioma: datos máquina-legibles que convierten tu resultado en una tarjeta rica.
El formato: JSON-LD en el head
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Café Chanchamayo tostado medio 500g",
"image": ["https://aromaypunto.pe/img/chanchamayo-800.webp"],
"description": "Tueste medio, notas de chocolate y naranja.",
"sku": "CAF-0001",
"brand": { "@type": "Brand", "name": "Aroma & Punto" },
"offers": {
"@type": "Offer",
"url": "https://aromaypunto.pe/cafe/chanchamayo",
"priceCurrency": "PEN",
"price": "28.00",
"availability": "https://schema.org/InStock"
}
}
</script>
- Un bloque JSON-LD describe la página EN DATOS: Google no tiene que adivinar qué número es precio ni qué texto es nombre.
- Resultado visible: estrellas de reseñas, precio y stock DIRECTO en resultados.
Tipos que más rinden para una tienda
| @type | Activa… |
|---|---|
Product + Offer | precio/stock en resultados (rich results) |
AggregateRating | estrellas de valoración — SOLO con reviews reales en la página |
BreadcrumbList | migas de pan visibles bajo el título del resultado |
LocalBusiness | ficha de tienda física: horario, dirección, teléfono |
FAQPage | preguntas desplegables en resultados |
Breadcrumbs completos
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1,
"name": "Inicio", "item": "https://aromaypunto.pe/" },
{ "@type": "ListItem", "position": 2,
"name": "Café", "item": "https://aromaypunto.pe/cafe" },
{ "@type": "ListItem", "position": 3,
"name": "Chanchamayo" }
]
}
Debe ESPEJAR tus migas visibles (cap. 24): lo marcado y lo mostrado cuentan la misma historia o Google ignora ambos.
Generación dinámica desde PHP/JS
Cuando el catálogo viene de base de datos, este bloque se GENERA:
en PHP con json_encode() de un array asociativo; en tu SPA con
JSON.stringify(). El markup es el mismo — solo cambia quién arma el objeto.
Cruce directo con php_01 cap. 41.
Puntos clave
- JSON-LD en script type application/ld+json: datos, no apariencia.
- Product+Offer/Breadcrumb son los de mayor retorno para tiendas.
- Solo marcar lo visible; validar en Rich Results Test.
- Se genera dinámicamente cuando el contenido vive en BD.
28 · Internacionalización
Avanzado ~12 minPreparar el markup para varios idiomas y formatos — empezando por hacerlo bien en español peruano.
lang: la base de todo
<html lang="es">
<!-- fragmento en otro idioma dentro de la pagina -->
<p>Nuestro lema: <q lang="en">From bean to cup</q>.</p>
- Los lectores de pantalla cambian de voz según lang — sin él, pronuncian tu español con acento inglés robótico.
- Google segmenta por idioma declarado; los correctores ortográficos también lo usan.
- Código compuesto regional si aplica:
es-PE,pt-BR.
Sitio multilingüe: hreflang
<!-- en cada version, apuntar a TODAS las alternativas incluida la propia -->
<link rel="alternate" hreflang="es-PE" href="https://aromaypunto.pe/cafe/chanchamayo">
<link rel="alternate" hreflang="en" href="https://aromaypunto.pe/en/coffee/chanchamayo">
<link rel="alternate" hreflang="x-default" href="https://aromaypunto.pe/cafe/chanchamayo">
| Pieza | Función |
|---|---|
hreflang | «esta página existe TAMBIÉN en estos idiomas» — Google muestra la correcta al usuario correcto |
x-default | la versión para idiomas no listados o usuarios sin preferencia |
| URLs propias por idioma | /en/… separadas (NO traducir con JS en vivo): cada versión indexable |
dir: idiomas de derecha a izquierda
<html lang="ar" dir="rtl"> <!-- pagina arabe completa -->
<p dir="rtl" lang="he">...</p> <!-- solo un parrafo hebreo -->
Formatos locales: HTML marca, no formatea
<time datetime="2026-08-23T15:30-05:00">domingo, 23 de agosto, 3:30 p.m.</time>
<data value="PEN">S/ 28.00</data>
- El atributo datetime lleva formato MÁQUINA ISO; el texto visible es humano. Lo mejor de ambos mundos para buscadores y calendarios.
- Fechas/números/moneda los FORMATEA tu backend o JS Intl
(
new Intl.NumberFormat("es-PE", {style:"currency", currency:"PEN"})) — el markup solo los envuelve con significado.
Contenido que no se traduce igual: checklist PE
- Moneda explícita siempre: «S/ 28.00» o «PEN 28.00», nunca «28».
- Teléfonos con código país en tel::
+51987654321. - Direcciones: formato local visible + estructura address semántica:
<address>
Aroma & Punto<br>
Av. Arequipa 1234, Lince<br>
Lima, Perú
</address>
Puntos clave
- lang en html y en fragmentos extranjeros: voz correcta + SEO.
- Multilingüe real = URLs separadas + hreflang completo.
- datetime/data: máquina ISO adentro, humano afuera.
- Moneda y teléfonos nunca ambiguos (S/, +51).
29 · Web Components: primer vistazo
Intermedio ~13 minEl estándar que te deja inventar tus propias etiquetas HTML — sin framework.
<tarjeta-precio moneda="PEN" precio="28">
Café Chanchamayo 500g
</tarjeta-precio>
Las tres piezas del estándar
| Pieza | Qué resuelve |
|---|---|
<template> | markup inerte que clonas con JS: se parsea pero NO se renderiza ni ejecuta hasta usarlo |
| Custom Elements | registrar tu etiqueta con comportamiento propio (customElements.define) |
| Shadow DOM | DOM privado: los estilos de afuera NO entran, los de adentro NO salen |
Componente mínimo completo
<template id="tpl-precio">
<style>
:host { display:inline-block; padding:.25rem .6rem;
background:#b23117; color:#fff; border-radius:.4rem; }
</style>
<span><slot></slot> — <b class="p"></b></span>
</template>
<script type="module">
const tpl = document.getElementById("tpl-precio");
class TarjetaPrecio extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" })
.appendChild(tpl.content.cloneNode(true));
this.shadowRoot.querySelector(".p").textContent =
`${this.getAttribute("moneda")} ${this.getAttribute("precio")}`;
}
}
customElements.define("tarjeta-precio", TarjetaPrecio);
</script>
:host: el componente mismo, estilizado desde DENTRO del shadow DOM.<slot>: la ventana por donde entra el contenido del usuario («Café Chanchamayo 500g» cae ahí).- El guion en
tarjeta-precioes OBLIGATORIO: las etiquetas custom deben llevar «-» para no chocar jamás con futuras etiquetas nativas.
¿Cuándo SÍ y cuándo NO?
| Escenario | Elegir |
|---|---|
| Widget reutilizable en sitios con stacks distintos (web corporativa, design systems) | Web Component: cero dependencias, corre en todas partes |
| App completa con estado, rutas, datos | framework (Vue/React): te dan reactividad y ecosistema |
| Botón o tarjeta simple de una página | HTML+CSS normal: un componente es over-engineering |
Puntos clave
- template + customElements.define + shadow DOM = etiqueta propia.
- Nombre SIEMPRE con guion; slot recibe contenido; :host se autostila.
- Ideal para widgets portables; apps grandes → framework.
30 · PWA: manifiesto e instalable
Intermedio ~13 minQue tu sitio se instale en el celular como app: icono, nombre, pantalla propia — con dos archivos HTML.
manifest.json: la ficha de tu app
{
"name": "Aroma & Punto",
"short_name": "AromaP",
"start_url": "/",
"display": "standalone",
"background_color": "#faf7f2",
"theme_color": "#b23117",
"icons": [
{ "src": "/icono-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icono-512.png", "sizes": "512x512", "type": "image/png" }
]
}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#b23117">
standalone: abre SIN barra de navegador — apariencia de app nativa.- Los dos tamaños de icono son el mínimo que Android exige para instalar.
theme_colorpinta la barra de estado del sistema (el mismo meta del cap. 23).
Service worker: el mayordomo de red
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
// sw.js — vive en la raiz para controlar TODO el sitio
const CACHE = "aroma-v1";
const ESENCIALES = ["/", "/css/completo.css", "/img/chanchamayo-800.webp"];
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE)
.then(c => c.addAll(ESENCIALES)));
});
self.addEventListener("fetch", e => {
e.respondWith(
caches.match(e.request)
.then(hit => hit || fetch(e.request))
);
});
- Es un JS que vive ENTRE tu página y la red: puede servir del caché sin conexión.
- Estrategia mínima «cache primero»: instantáneo para lo esencial; lo demás va a red.
- Cambias algo? Sube el nombre de caché (
aroma-v2) — el viejo se descarta.
Checklist instalable (Chrome/Android)
| Requisito | Dónde |
|---|---|
| manifest con name, icons 192+512, start_url, display | cap. actual |
| service worker registrado con fetch handler | cap. actual |
| Servido por HTTPS | requisito duro: sin TLS no hay SW |
Cumplido esto, Chrome ofrece «Instalar app» solo. iOS lo agrega desde Compartir → Añadir a inicio (usa apple-touch-icon del cap. 23).
Offline UX: avisar en lugar de fallar
window.addEventListener("offline", () =>
document.body.insertAdjacentHTML("afterbegin",
`<div class="alert alert-warning m-0 text-center">Sin conexión: mostrando datos guardados</div>`)
);
window.addEventListener("online", () =>
document.querySelector(".alert-warning")?.remove()
);
activate, y prueba siempre en ventana incógnito.Puntos clave
- manifest + SW + HTTPS = instalable; dos archivos bastan.
- Cache primero para esenciales; versiona el nombre al cambiar.
- Eventos online/offline para UX honesta sin conexión.
31 · Las APIs del navegador
Intermedio ~12 minEl navegador trae superpoderes de fábrica: ubicación, portapapeles, cámara y más — todos piden permiso primero.
Mapa rápido del arsenal
| API | Para qué | Permiso |
|---|---|---|
navigator.clipboard | copiar/pegar programático | solo para leer |
IntersectionObserver | «¿este elemento ya es visible?» sin scroll listeners | no |
localStorage / sessionStorage | persistencia clave-valor local | no |
geolocation | ubicación del usuario | SÍ, explícito |
getUserMedia | cámara/micrófono | SÍ, explícito |
Notification | notificaciones del sistema | SÍ, explícito |
Copiar al portapapeles (el clásico de cupones)
<p>Cupón: <output id="cupon">CAFEVERANO15</output></p>
<button id="btn-copiar" class="btn btn-outline-primary btn-sm">
Copiar cupón
</button>
document.getElementById("btn-copiar").addEventListener("click", async () => {
const cupon = document.getElementById("cupon").textContent;
await navigator.clipboard.writeText(cupon);
const btn = document.getElementById("btn-copiar");
btn.textContent = "¡Copiado!";
setTimeout(() => btn.textContent = "Copiar cupón", 2000);
});
IntersectionObserver: lazy real y animaciones al entrar
// revelar secciones cuando entran al viewport
const obs = new IntersectionObserver(entradas => {
entradas.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add("visible");
obs.unobserve(e.target); // una sola vez
}
});
}, { threshold: 0.15 });
document.querySelectorAll(".revelable").forEach(el => obs.observe(el));
Geolocalización con manejo honesto de errores
const opciones = { enableHighAccuracy: false, timeout: 8000 };
navigator.geolocation.getCurrentPosition(pos => {
console.log(`Lat ${pos.coords.latitude}, Lng ${pos.coords.longitude}`);
}, err => {
// el usuario dijo NO, o tardo demasiado: NUNCA asumir que saldra bien
aviso.textContent = "No pudimos obtener tu ubicacion: usaremos la ciudad principal.";
}, opciones);
- El permiso SIEMPRE es del usuario: diseña el flujo «sin ubicación» antes de pedirlo.
- Pide geolocalización SOLO en contexto (botón «sucursales cerca»), nunca al cargar la página.
Detección de capacidades: feature detection
if ("clipboard" in navigator) {
mostrarBotonCopiar();
} else {
mostrarCuponSeleccionable(); // plan B, no error
}
"X" in navigator) y ofrece alternativa.Puntos clave
- Cada API poderosa cuesta un permiso: diseño primero el «no».
- IntersectionObserver reemplaza scroll listeners caros.
- Feature detection con plan B; user-agent jamás.
32 · Proyecto integrador y cierre
Meta ~45 minUna página completa de producto que usa lo aprendido en los 31 capítulos — tu plantilla de despegue para todo proyecto web.
El encargo
Página de producto «Café Chanchamayo» para Aroma & Punto, con: ficha del producto con precio y botón de compra, formulario de suscripción validado, reseñas, mapa embebido y datos estructurados. Un solo archivo HTML (más CSS/JS externos si prefieres) servido localmente.
Esqueleto obligatorio
<!DOCTYPE html>
<html lang="es-PE">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Café Chanchamayo tostado medio 500g · Aroma & Punto</title> <!-- cap23 -->
<meta name="description" content="Tueste medio con notas de chocolate...">
<link rel="canonical" href="https://aromaypunto.pe/cafe/chanchamayo">
<meta property="og:image" content="https://aromaypunto.pe/img/chanchamayo-og.webp">
<link rel="preload" as="image" href="chanchamayo-800.webp"
fetchpriority="high"> <!-- cap26 -->
</head>
<body>
<header><nav aria-label="Principal">...</nav></header> <!-- caps6/25 -->
<main>
<article>
<picture>...webp + jpg fallback, sizes...</picture> <!-- cap20 -->
<h1>Café Chanchamayo</h1>
<p>Notas de cata: <em>chocolate</em>, naranja, panela.</p>
<svg aria-hidden="true">...sprite estrella x4.8...</svg> <!-- cap21 -->
<time datetime="2026-08-23">reseñas de agosto</time> <!-- cap28 -->
<form novalidate> <!-- caps12-18 -->
<label for="correo">Correo para cupones</label>
<input id="correo" name="correo" type="email"
required autocomplete="email"
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$">
<p id="err-correo" aria-live="polite"></p>
<button type="submit" class="btn btn-primary">Suscribirme</button>
</form>
<iframe loading="lazy" title="Mapa tienda Lince"...></iframe> <!-- cap9 -->
</article>
</main>
<footer><address>Av. Arequipa 1234, Lince — Lima</address></footer>
<script type="application/ld+json">{ "@type": "Product", ... }</script> <!-- cap27 -->
<script src="/js/app.js" defer></script> <!-- cap26 -->
</body>
</html>
Lista de verificación final (imprímela)
| Área | Debe cumplirse… | Cap. |
|---|---|---|
| Estructura | landmarks completos, un solo h1, jerarquía sin saltos | 3, 6 |
| Imágenes | formato moderno + fallback, width/height SIEMPRE, alt correcto | 5, 20 |
| Formulario | label-for, validación nativa+JS, errores con aria-live | 12–18, 25 |
| SEO | title único, OG completo, canonical, JSON-LD validado | 23, 24, 27 |
| Rendimiento | hero precargado, resto lazy, defer en scripts, budget ≤500 KB | 26 |
| A11y | prueba de teclado de 60 s sin quedarte trabado | 25 |
Rúbrica de autoevaluación
- Lighthouse ≥ 90 en las cuatro categorías (Performance, Accessibility, Best Practices, SEO).
- Navegable al 100% con teclado; NVDA lee la ficha sin ruidos.
- Rich Results Test acepta tu JSON-LD.
- Sin una sola imagen sin dimensiones (CLS = 0).