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.

32 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil HTML Living Standard · WHATWG
32
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
0
Requisitos previos
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.

1 · Qué es HTML5 y cómo piensa el navegador

Básico ~10 min

Antes 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.

Idea clave del manual: escribe HTML pensando en el SIGNIFICADO (semántica), no en el aspecto visual. El aspecto se lo delegamos a CSS; el significado es lo que le sirve a Google, a un lector de pantalla y a tu yo futuro.

Las tres capas de una página web

CapaTecnologíaResponsabilidad
Estructura + significadoHTMLqué ES cada cosa
PresentaciónCSScómo SE VE
ComportamientoJavaScriptqué 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 min

El 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

PiezaPara 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.
viewportHace 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.
Orden sagrado en el head: charset antes que title, viewport antes que cualquier estilo. Es la única «ceremonia» obligatoria de HTML.

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.
Regla práctica para todo el manual: CSS siempre en el head; JS con 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>&copy; 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.css entra por <link> en el head: los estilos están listos ANTES del primer render.
  • app.js se declara ARRIBA pero ejecuta ABAJO: gracias a defer, 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>&copy; 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

  1. Clic derecho → Ver código fuente: muestra TU archivo, intacto.
  2. 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 min

El 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>
EtiquetaSemánticaÚsalo para…
<strong>importancia fuerteavisos, advertencias
<em>énfasis de vozcambiar el sentido de la frase
<b>solo visualpalabras clave, nombres de UI
<i>solo visualtérminos técnicos, idiomas extranjeros
<small>letra pequeña legaldisclaimers, letra chica
<mark>resaltado contextualcoincidencias de búsqueda
<abbr title="">abreviaturaIGV, SUNAT con tooltip explicativo
<time datetime="2026-08-23">fecha legible por máquinasfechas y horas en contenido

Entidades: los caracteres especiales

<p>Panadería &amp; Cafetería &lt;El Trigal&gt; — 5&nbsp;kg</p>
Escribe…Se ve…Cuándo
&lt; / &gt;< / >mostrar etiquetas como texto
&amp;&ampersand literal
&quot;"comillas dentro de atributos
&nbsp;espacio duroS/ 150.00 sin salto de línea
&copy;©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 -->
Anti-patrón: apilar <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 min

La 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.
  • download fuerza descarga aunque sea un PDF visible en navegador.

Texto del enlace: la regla del contexto fuera de línea

MALBIENPor qué
clic aquíVer precios de envíosLos lectores de pantalla listan enlaces SIN contexto
más infoRequisitos del RUC nuevo«Más» ¿de qué? Google también lee ese texto
URL cruda largaNormativa SUNAT 2026Inpronunciable 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.
Puente al curso PHP: los href con query string (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 min

El 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">
AtributoPara quéSi falta…
srcruta de la imagennada se ve (obvio)
alttexto alternativo OBLIGATORIOlector de pantalla anuncia la ruta cruda; si la imagen falla, caja vacía
width + heightproporciones reservadas ANTES de cargarsaltos 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é

FormatoFuerzaÚsalo para…
AVIFmáxima compresión modernafotos cuando puedas servirlo (con fallback)
WebP30% menor que JPEG, universal hoydefault de fotos e ilustraciones
JPEG/PNGcompatibilidad totalfallbacks y casos muy específicos (PNG: transparencia dura)
SVGvector, escala infinitologos, 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.
  • figcaption queda asociado semánticamente a su imagen aunque los separes con CSS.
  • Adelanto: imágenes responsivas completas (srcset/picture) en cap. 20.
Puente al curso JS: el CLS que evitas con width/height es una de las tres Core Web Vitals del cap. 39 de js_01. HTML bien escrito ya suma puntos ahí.

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 min

Las 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

EtiquetaPrueba 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 con aria-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

ConsumidorQué aprovecha
Googlemain vs aside: qué es contenido central; nav para sitelinks
Lectores de pantallalandmarks = navegación rápida por secciones (tecla D en NVDA)
Modo lector (Safari/Firefox)article + jerarquía limpia = lectura sin ruido
Tu equipoclass="div2-copia" no necesita documentación si el tag ya explica
Puente al curso JS: la SPA del cap. 41 de js_01 inyecta vistas en <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 min

Tres 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>
TipoSignificadoEjemplos de uso real
<ul>conjunto sin ordenmenús, características, tags
<ol>secuencia con pasosrecetas, rankings, instrucciones
<dl>pares término-definiciónglosarios, 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.

Anti-patrón: usar listas solo porque «quedan con vinetas» y luego borrarlas con CSS. Si tu contenido no es una enumeración, usa párrafos — el marcado comunica intención, no apariencia.

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 min

Para 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ónHerramienta correcta
«Maquetar» el formulario o la páginaCSS Grid/Flexbox (era pre-historia)
Lista de tarjetas de productosul + CSS grid
Datos que en móvil no caben y «se arreglan» con scroll horizontal eternoreplantear 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>
Puente al curso PHP: cuando tu API REST devuelva listados, esta estructura (thead + tbody + th scope) es la que renderizarás con fetch. Semántica ahora = menos trabajo después.

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 min

Pá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

AtributoPor qué
titleel 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
referrerpolicycontrola cuánto de tu URL recibe el tercero
sandboxjaula 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

ServicioPatrón seguro
YouTubeusa el código «Insertar» oficial (ya trae lazy si activas la casilla); considera la fachada de miniatura + clic (carga real solo al pedirlo)
Google MapsCompartir → Insertar un mapa; añade loading="lazy" y title descriptivo
Widgets de pago/redesrevisa 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.

Puente a seguridad: el cap. 35 de js_01 trató postMessage entre ventanas: los iframes SON ese canal. Aquí viste cómo limitarlos desde HTML; allí, cómo comunicarse con ellos verificando origen.

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 min

Acordeones, 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 en returnValue — 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>
NecesidadNativoJS requerido
Acordeón / FAQdetails+summaryninguno
Modal con confirmacióndialog2 líneas (open/close)
Menú contextual / tooltip ricopopoverninguno
Zona de búsquedasearchninguno

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 min

Atributos que CUALQUIER elemento acepta — y el puente oficial entre tu markup y JavaScript.

El kit global

AtributoFunción realOjo con…
ididentidad ÚNICA: anclas, label-for, getElementByIdduplicados = bugs silenciosos; no uses id para estilar (eso es class)
classpertenencia a grupos: estilos y selección múltiplenombres por ROL (.btn-danger), no por aspecto (.rojo)
hiddenocultar semánticamente (no se renderiza)si tu CSS display:block le «gana», rompes su contrato — evita sobreescribirlo
titletooltip nativo al hoversolo info complementaria; NO accesible por teclado/táctil
langidioma de ese fragmentocitas en otro idioma: <q lang="en">less is more</q>
dir="rtl"dirección de escrituracontenidos árabe/hebreo embebidos
contenteditableeditable en vivoeditores rápidos, demos; guarda validando siempre
tabindex="0"mete un no-interactivo al orden de tabulacióncon 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-productodataset.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.

Puente al curso JS: dataset ya apareció en el cap. 32 de js_01 (selección y mutación del DOM). Aquí viste la cara HTML del mismo contrato: quien escribe el markup decide qué datos viajan pegados al elemento.

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 min

El 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

GETPOST
Datos viajan en…URL (?q=cafe&pag=2)cuerpo HTTP (invisible en URL)
PHP lo lee en…$_GET$_POST
Se puede marcar/recargarsí (búsqueda, filtros)recargar re-envía (pedidos, pagos)
VolumenURLs limitadas ~2 KBmegas (y archivos: solo POST)
Usa para…CONSULTAR/buscar/filtrarCAMBIAR 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 -->
Bug clásico: un botón auxiliar sin type="button" dentro del form envía TODO por accidente. Decláralo siempre.

El flujo completo visto desde arriba

  1. Usuario llena y pulsa submit → navegador arma pares nombre=valor.
  2. Los codifica (urlencoded o multipart — cap. 16 para archivos).
  3. PHP recibe en $_GET/$_POST → valida DE NUEVO → responde.
  4. 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 min

Siete 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

TipoEspecialidadValidación gratis
texttexto libre generalninguna
emailcorreos (uno o varios con multiple)formato básico de correo
urldirecciones webdebe tener esquema (https://…)
teltélefonos (sin formato fijo internacional)ninguna — combínala con pattern (cap. 15)
searchbúsquedas: borra con una X, Enter disparaninguna
passwordoculta caracteres (NO cifra: eso hace HTTPS)ninguna
hiddenvalor 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>
PropiedadHace…Nota fina
valuevalor inicial del controlen PHP: repoblar tras error de validación
placeholderejemplo/hint mientras está vacíoNUNCA sustituye al label (desaparece al escribir)
minlength / maxlengthrango de caracteresmaxlength corta; minlength VALIDA (con required)
spellcheckcorrector nativo on/offoff para correos, códigos, nombres propios raros
autocompleteautollenado semánticotokens estándar: name, email, tel, postal-code…
inputmodeteclado móvil preferidonumeric, decimal, tel, email, url, search
readonlysolo lectura PERO viaja al servidordatos calculados que deben llegar a PHP
disabledapagado total: NO viaja al servidorideal para «lo desactivo hasta que…»
readonly vs disabled — la trampa de examen: ambos impiden editar, pero disabled saca el campo del envío. Un total calculado marcado disabled llegará como NULL a tu PHP. Si el dato debe viajar: readonly.

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 min

Controles 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/step validan 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">
EstadoLo que llega a PHP
marcado, value="si"$_POST["promo"] === "si"
desmarcadoNO 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 checked para proponer default.
  • fieldset+legend nombra 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; multiple permite varias opciones → nómbralo categorias[] 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 min

El 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 title como 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:

  1. Sin barras: escribes [0-9]{8}, no /[0-9]{8}/.
  2. 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

PiezaSignifica…
[0-9] o \dun 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»)
\sespacio, 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">
Tres errores típicos: poner ^/$ 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 min

El ú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>

Los tres atributos que moldean el control

AtributoFunciónEjemplo
acceptfiltra lo que el diálogo muestra (SUGERENCIA, no seguridad).pdf,.docx / image/png,image/jpeg / image/*
multiplepermite varios archivos a la veznómbralo array: name="fotos[]"
captureen móvil abre cámara directacapture="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:

ClaveContenidoUso típico
namenombre ORIGINAL en la máquina del usuariosolo informativo — NUNCA confíes ni reuses tal cual
typeMIME DECLARADO por el navegadorse puede fingir: valida contenido real (cap. 17)
sizebytes recibidostu segunda línea contra archivos monstruo
tmp_nameruta temporal donde PHP ya guardó el archivomove_uploaded_file() lo lleva a su destino final
errorcó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 min

Drag & 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

Directiva php.iniControla…Regla práctica
upload_max_filesizepeso máximo POR archivotu tope real por archivo
post_max_sizeTODO el POST (archivos + campos)≥ suma esperada; si te pasas, $_POST y $_Files llegan VACÍOS
max_file_uploadsarchivos simultáneos por requestdefault 20; cuenta los name[]
memory_limitmemoria del scriptsolo 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

  1. HTML (UX): accept correcto, hint de tamaño en el label, validación JS amable antes de gastar la subida del usuario.
  2. Cliente JS (cortesía): f.size y f.type filtrados en change/drop. Nunca como ÚNICO control.
  3. 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 min

La 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

  1. 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.
  2. Errores junto al campo, en texto (no solo color), con el foco movido al primero.
  3. Grupos nombrados: fieldset+legend para radios/checkboxes relacionados; los lectores anuncian la legend con cada opción.
  4. autocomplete correcto: autollenado fiable = formularios más cortos.
  5. 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-describedby enlaza 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ácticaImpacto real
Una columna, campos visibles sin scroll rarocompletado más rápido en móvil
required mínimo indispensablecada 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 min

La 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

AtributoFunciónCuándo
controlsmuestra la barra nativa (play, volumen, pantalla completa)SIEMPRE en video visible — un video sin controles es una trampa
posterimagen de portada ANTES de cargarsiempre: evita primer frame negro
preload"none" | "metadata" | "auto""metadata" es el default sensato; "auto" solo si SABES que lo verán
muted + autoplayarranque automático PERMITIDO solo silenciadovideos de fondo decorativos (y con loop)
loop / playsinlinerepetir / no abrir fullscreen en iOSfondos animados: muted+loop+playsinline juntos
Política de autoplay: los navegadores BLOQUEAN el sonido sin gesto del usuario. autoplay solo funciona acompañado de muted (o tras interacción). Diseña pensando en eso, no contra ello.

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> propioYouTube/Vimeo embed
Control total (sin logos ni anuncios)no
Costo de ancho de bandatuyo (¡los videos pesan!)del servicio
Peso en tu páginalo que cargues (lazy/poster)~1–2 MB de su JS (usa fachada, cap. 9)
Descubrimientotu SEOmotor 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 min

La 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">
AtributoQué dice…
srcset«tengo estas variantes y sus anchos reales EN PIXELES»
sizes«esta imagen ocupará ~X del viewport según el breakpoint»
srcfallback 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>
NecesidadHerramienta
Misma imagen, pesos distintossrcset/sizes solos
Formatos nuevos con fallback (AVIF/WebP)picture + source type
Encuadre/recorte distinto en móvilpicture + source media

Las dos leyes del peso

  1. Lazy para todo lo que esté bajo el pliegue: loading="lazy" (visto en cap. 5) combinado con srcset funciona igual.
  2. 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">
Flujo de trabajo honesto: genera las variantes con una sola orden (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 min

Vectores dentro del HTML: nitidez infinita, peso mínimo y colores que obedecen a tu CSS.

Tres formas de usar un SVG

FormaVentajaLimitación
<img src="icono.svg">cacheable como imagenCSS/JS externos NO lo alcanzan (no cambia color)
inline en el HTMLel CSS de la página lo pinta; animableno se cachea aparte; ensucia el markup si abusas
sprite + <use>un solo archivo con todos los iconosrequiere 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).
  • viewBox define 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, usa role="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

NecesidadGanadorPor qué
Iconos UI que cambian de colorSVG inline/spritecurrentColor + cero peticiones
Ilustración compleja estáticaimg svgcacheable, markup limpio
Fotos / imágenes ricasWebP/AVIF (cap. 20)SVG NO sirve para fotos: explota en peso
Miles de iconos con ligadurasfuentes de iconosvá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>
Seguridad: SVG es XML con JS posible dentro. Si permites uploads de .svg a tu sitio (cap. 16–17), sanitiza o sírvelo desde otro origen — un SVG malicioso puede ejecutar script al abrirse.

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 min

Un 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?

CasoElegirRazón
Gráfico estático/simpleSVG o CSSaccesible, escalable, inspeccionable
Dashboards con miles de puntoscanvaspíxeles baratos: DOM moriría con 10k nodos
Juegos 2D / efectoscanvasredibujo completo a 60fps sin costo DOM
Video realvideocodec nativo, controles, subtítulos
Gráficos de negocio seriosbiblioteca sobre canvas/SVGChart.js/D3 resuelven ejes, tooltips, leyendas
Puente al curso JS: el bucle rAF, el event loop y el profiling del cap. 39 de js_01 son EXACTAMENTE las herramientas para diagnosticar un canvas lento (frames caídos se ven rojos en Performance panel). Canvas amplifica tu JS, no lo reemplaza.

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 min

Lo 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 &amp; 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 &amp; 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

MetaControla…
og:title / og:descriptiontítulo y texto de la tarjeta en WhatsApp/Facebook/Slack
og:imagela miniatura — ABSOLUTA (https://…), ideal 1200×630
og:url + canonicalla 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 min

Todo 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

MALBIENPor qué
/prod.php?id=17&ref=x9/cafe/chanchamayolegible por humanos Y crawlers; palabras clave en la URL
/CAFE_Chanchamayo_FINAL2.html/cafe/chanchamayominúsculas, guiones, sin versiones eternas
Cambiar URL cada rediseñoURLs eternas + redirect 301 cuando toquecada 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

VitalQué mideTu palanca HTML
LCPcuánto tarda el contenido principalhero con fetchpriority high (cap. 20); preload (cap. 26)
CLSsaltos de layoutwidth/height SIEMPRE (cap. 5)
INPrespuesta a interacciónmenos JS bloqueante (defer, cap. 26); dialog/popover nativos (cap. 10)
Puente PHP: URLs amigables tipo /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 min

El 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

Primera regla de ARIA: no uses ARIA si existe un elemento HTML que lo haga solo. Un <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)

  1. Desconecta el mouse: ¿puedes operar TODO con Tab/Enter/Espacio?
  2. Activa NVDA (gratis) 30 segundos: ¿los landmarks y labels tienen sentido?
  3. 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 min

Antes 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>
AtributoDescargaEjecutaÚsalo para…
(nada)bloquea el parseoinmediato al llegarnada, en 2026
asyncparalelaen cuanto llegue (orden NO garantizado)scripts independientes: analítica, widgets
deferparalelatras parsear DOM, en ordenTU 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étricaObjetivo móvil 4G
Peso total primera carga≤ 500 KB
LCP≤ 2.5 s
Fuentes custom≤ 2 familias, woff2 subset
Scripts bloqueantes0

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 min

Hablarle 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

@typeActiva…
Product + Offerprecio/stock en resultados (rich results)
AggregateRatingestrellas de valoración — SOLO con reviews reales en la página
BreadcrumbListmigas de pan visibles bajo el título del resultado
LocalBusinessficha de tienda física: horario, dirección, teléfono
FAQPagepreguntas 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 min

Preparar 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">
PiezaFunción
hreflang«esta página existe TAMBIÉN en estos idiomas» — Google muestra la correcta al usuario correcto
x-defaultla 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 &amp; 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 min

El 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

PiezaQué resuelve
<template>markup inerte que clonas con JS: se parsea pero NO se renderiza ni ejecuta hasta usarlo
Custom Elementsregistrar tu etiqueta con comportamiento propio (customElements.define)
Shadow DOMDOM 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-precio es OBLIGATORIO: las etiquetas custom deben llevar «-» para no chocar jamás con futuras etiquetas nativas.

¿Cuándo SÍ y cuándo NO?

EscenarioElegir
Widget reutilizable en sitios con stacks distintos (web corporativa, design systems)Web Component: cero dependencias, corre en todas partes
App completa con estado, rutas, datosframework (Vue/React): te dan reactividad y ecosistema
Botón o tarjeta simple de una páginaHTML+CSS normal: un componente es over-engineering
Puente: la SPA de js_01 cap. 41 renderiza «componentes» con funciones y strings; los Web Components son la versión ESTANDARIZADA de esa idea. Cuando llegues a Vue/React reconocerás props, slots y ciclo de vida — ya los viste aquí.

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 min

Que 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_color pinta 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)

RequisitoDónde
manifest con name, icons 192+512, start_url, displaycap. actual
service worker registrado con fetch handlercap. actual
Servido por HTTPSrequisito 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()
);

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 min

El navegador trae superpoderes de fábrica: ubicación, portapapeles, cámara y más — todos piden permiso primero.

Mapa rápido del arsenal

APIPara quéPermiso
navigator.clipboardcopiar/pegar programáticosolo para leer
IntersectionObserver«¿este elemento ya es visible?» sin scroll listenersno
localStorage / sessionStoragepersistencia clave-valor localno
geolocationubicación del usuarioSÍ, explícito
getUserMediacámara/micrófonoSÍ, explícito
Notificationnotificaciones del sistemaSÍ, 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
}

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 min

Una 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 &amp; 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)

ÁreaDebe cumplirse…Cap.
Estructuralandmarks completos, un solo h1, jerarquía sin saltos3, 6
Imágenesformato moderno + fallback, width/height SIEMPRE, alt correcto5, 20
Formulariolabel-for, validación nativa+JS, errores con aria-live12–18, 25
SEOtitle único, OG completo, canonical, JSON-LD validado23, 24, 27
Rendimientohero precargado, resto lazy, defer en scripts, budget ≤500 KB26
A11yprueba de teclado de 60 s sin quedarte trabado25

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).
Cierre: dominas el lenguaje sobre el que TODO lo demás se construye: tu PHP genera este markup, tu JS lo anima, los frameworks lo abstraen. Siguiente parada natural: Vue (cap. 42 js_01) o el manual de HTML avanzado/CSS cuando lo publiques. El navegador ya es tuyo.