Módulo 18: Proyecto final

Objetivos del módulo

  • Planificar un sitio web de varias páginas: requisitos, estructura y wireframe
  • Combinar HTML5 semántico, CSS propio moderno y Bootstrap 5.3.8 en un mismo proyecto
  • Implementar navbar responsive, cards, modal, formulario validado y modo oscuro
  • Auditar accesibilidad y rendimiento con Lighthouse y validar con el W3C
  • Publicar el sitio en Internet con GitHub Pages

1. El proyecto: “Lumen Estudio”

Vas a construir la web de Lumen Estudio, un estudio ficticio de diseño y desarrollo web. Es el tipo de encargo que podrías recibir como primer cliente, y sirve igual como portfolio personal si cambias los textos por los tuyos.

📘 Concepto: Un proyecto real no empieza escribiendo código, sino definiendo qué hay que construir. Igual que un arquitecto dibuja planos antes de poner un ladrillo, tú definirás requisitos, estructura y bocetos.

1.1 Requisitos

Tipo Requisito
Páginas Inicio, Servicios, Portfolio y Contacto
Navegación Navbar común y responsive, con indicador de página actual
Inicio Portada (hero) animada con llamada a la acción, resumen de servicios y proceso de trabajo
Servicios Tarjetas con precios orientativos y preguntas frecuentes (acordeón)
Portfolio Rejilla de proyectos; al pulsar uno se abre un modal con el detalle
Contacto Formulario validado; al enviarlo correctamente se muestra un modal de confirmación
Tema Modo oscuro con conmutador que recuerda la preferencia
Calidad HTML válido, accesible (teclado, contraste, textos alternativos) y Lighthouse ≥ 90
Publicación Disponible en GitHub Pages

1.2 Reparto de responsabilidades

El HTML5 semántico aporta estructura y significado; Bootstrap 5.3.8 (por CDN) pone la rejilla, las utilidades y los componentes; el CSS propio da la identidad visual (variables, hero, Grid, Flexbox, animaciones, ajustes del modo oscuro); y un poco de JavaScript gestiona el tema, la validación y el modal dinámico.


2. Estructura de carpetas

lumen-estudio/
├── index.html
├── servicios.html
├── portfolio.html
├── contacto.html
├── css/
│   └── estilos.css
├── js/
│   ├── tema.js          ← se carga en el <head> (evita el parpadeo)
│   └── main.js          ← lógica de la página
├── img/
│   ├── proyecto-1.webp
│   ├── proyecto-2.webp
│   └── ...
└── favicon.svg

⚠️ Importante: Usa nombres de archivo en minúsculas, sin espacios ni tildes (proyecto-1.webp, no Proyecto 1.WEBP). GitHub Pages distingue mayúsculas de minúsculas: un enlace a Img/Foto.jpg que en tu Windows funciona puede dar error 404 una vez publicado.


3. Wireframe

Antes de maquetar, haz un boceto de baja fidelidad. Así decides la estructura sin distraerte con colores.

flowchart TB
    subgraph Inicio["index.html"]
        direction TB
        N1["Navbar: logo · Inicio · Servicios · Portfolio · Contacto · 🌓"]
        H1["HERO: título grande + subtítulo + 2 botones (animado)"]
        S1["SERVICIOS: 3 cards en fila (1 columna en móvil)"]
        P1["PROCESO: 4 pasos en CSS Grid"]
        F1["Footer: datos · enlaces · redes"]
        N1 --> H1 --> S1 --> P1 --> F1
    end
PORTFOLIO (escritorio)                    CONTACTO (escritorio)
+------------------------------------+    +------------------------------------+
| NAVBAR                             |    | NAVBAR                             |
+------------------------------------+    +------------------------------------+
| Título + breadcrumb                |    | Título                             |
+-----------+-----------+------------+    +----------------------+-------------+
| [img]     | [img]     | [img]      |    | Formulario           | Datos de    |
| Proyecto  | Proyecto  | Proyecto   |    | nombre | email       | contacto    |
| [Ver]     | [Ver]     | [Ver]      |    | asunto               | horario     |
+-----------+-----------+------------+    | mensaje              | email       |
| ...       | ...       | ...        |    | [x] privacidad       |             |
+-----------+-----------+------------+    | [Enviar]             |             |
| FOOTER                             |    +----------------------+-------------+
+------------------------------------+    | FOOTER                             |
                                          +------------------------------------+

💡 Consejo: Dibuja también la versión móvil: en general, las columnas pasan a apilarse una debajo de otra. Diseñar primero el móvil (mobile first) te obliga a priorizar el contenido.


4. Página de inicio: index.html

💻 Ejemplo: Página completa. La cabecera y el pie se repiten idénticos en las demás páginas (cambiando solo el enlace activo).

<!doctype html>
<html lang="es" data-bs-theme="light">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Lumen Estudio · Diseño y desarrollo web</title>
  <meta name="description" content="Estudio de diseño web en Valencia. Creamos webs rápidas, accesibles y responsive para pequeñas empresas.">
  <link rel="icon" href="favicon.svg" type="image/svg+xml">

  <!-- Tema antes de pintar: evita el destello claro en modo oscuro -->
  <script src="js/tema.js"></script>

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css" rel="stylesheet">
  <link href="css/estilos.css" rel="stylesheet">
</head>
<body>
  <a class="visually-hidden-focusable" href="#contenido">Saltar al contenido principal</a>

  <header>
    <nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top" aria-label="Navegación principal">
      <div class="container">
        <a class="navbar-brand fw-bold" href="index.html">
          <i class="bi bi-lightbulb-fill text-warning" aria-hidden="true"></i> Lumen
        </a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#menu"
                aria-controls="menu" aria-expanded="false" aria-label="Mostrar u ocultar el menú">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="menu">
          <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
            <li class="nav-item"><a class="nav-link active" aria-current="page" href="index.html">Inicio</a></li>
            <li class="nav-item"><a class="nav-link" href="servicios.html">Servicios</a></li>
            <li class="nav-item"><a class="nav-link" href="portfolio.html">Portfolio</a></li>
            <li class="nav-item"><a class="nav-link" href="contacto.html">Contacto</a></li>
          </ul>
          <button class="btn btn-outline-secondary ms-lg-3" type="button" id="btnTema" aria-label="Cambiar a modo oscuro">
            <i class="bi bi-moon-stars-fill" aria-hidden="true"></i>
          </button>
        </div>
      </div>
    </nav>
  </header>

  <main id="contenido">
    <!-- HERO -->
    <section class="hero text-center text-lg-start">
      <div class="container">
        <div class="row align-items-center g-5">
          <div class="col-lg-7 aparecer">
            <p class="text-uppercase fw-semibold hero__antetitulo mb-2">Estudio de diseño web</p>
            <h1 class="display-4 fw-bold">Webs que iluminan tu negocio</h1>
            <p class="lead my-4">Diseñamos y desarrollamos sitios rápidos, accesibles y preciosos en cualquier pantalla.</p>
            <div class="d-grid gap-2 d-sm-flex justify-content-sm-center justify-content-lg-start">
              <a href="contacto.html" class="btn btn-primary btn-lg px-4">Pide presupuesto</a>
              <a href="portfolio.html" class="btn btn-outline-light btn-lg px-4">Ver proyectos</a>
            </div>
          </div>
          <div class="col-lg-5 d-none d-lg-block">
            <div class="hero__orbe" aria-hidden="true"></div>
          </div>
        </div>
      </div>
    </section>

    <!-- SERVICIOS -->
    <section class="py-5" aria-labelledby="titulo-servicios">
      <div class="container">
        <h2 id="titulo-servicios" class="text-center mb-5">¿Qué hacemos?</h2>
        <div class="row row-cols-1 row-cols-md-3 g-4">
          <div class="col">
            <article class="card h-100 tarjeta-servicio">
              <div class="card-body">
                <i class="bi bi-palette fs-1 text-primary" aria-hidden="true"></i>
                <h3 class="h5 mt-3">Diseño UI/UX</h3>
                <p class="card-text text-body-secondary">Interfaces claras centradas en tus usuarios.</p>
              </div>
            </article>
          </div>
          <div class="col">
            <article class="card h-100 tarjeta-servicio">
              <div class="card-body">
                <i class="bi bi-code-slash fs-1 text-primary" aria-hidden="true"></i>
                <h3 class="h5 mt-3">Desarrollo web</h3>
                <p class="card-text text-body-secondary">HTML semántico, CSS moderno y código mantenible.</p>
              </div>
            </article>
          </div>
          <div class="col">
            <article class="card h-100 tarjeta-servicio">
              <div class="card-body">
                <i class="bi bi-speedometer2 fs-1 text-primary" aria-hidden="true"></i>
                <h3 class="h5 mt-3">Rendimiento y SEO</h3>
                <p class="card-text text-body-secondary">Webs rápidas que Google y tus clientes adoran.</p>
              </div>
            </article>
          </div>
        </div>
      </div>
    </section>

    <!-- PROCESO (CSS Grid propio) -->
    <section class="py-5 bg-body-tertiary" aria-labelledby="titulo-proceso">
      <div class="container">
        <h2 id="titulo-proceso" class="text-center mb-5">Cómo trabajamos</h2>
        <ol class="proceso">
          <li class="proceso__paso"><span class="proceso__num">1</span><h3 class="h6">Escuchamos</h3><p>Reunión para entender tu proyecto.</p></li>
          <li class="proceso__paso"><span class="proceso__num">2</span><h3 class="h6">Diseñamos</h3><p>Wireframes y prototipo visual.</p></li>
          <li class="proceso__paso"><span class="proceso__num">3</span><h3 class="h6">Desarrollamos</h3><p>Maquetación responsive y pruebas.</p></li>
          <li class="proceso__paso"><span class="proceso__num">4</span><h3 class="h6">Publicamos</h3><p>Lanzamiento y formación.</p></li>
        </ol>
      </div>
    </section>
  </main>

  <footer class="pie py-4 border-top">
    <div class="container pie__contenido">
      <p class="mb-0">© <span id="anio">2026</span> Lumen Estudio · Valencia</p>
      <ul class="list-unstyled d-flex gap-3 mb-0 fs-5">
        <li><a href="https://github.com/" class="link-body-emphasis" aria-label="GitHub"><i class="bi bi-github" aria-hidden="true"></i></a></li>
        <li><a href="https://www.linkedin.com/" class="link-body-emphasis" aria-label="LinkedIn"><i class="bi bi-linkedin" aria-hidden="true"></i></a></li>
      </ul>
    </div>
  </footer>

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
          integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
  <script src="js/main.js"></script>
</body>
</html>

💡 Consejo: En las demás páginas, copia el <head>, el <header> y el <footer>, cambia el <title> y la description, y mueve active y aria-current="page" al enlace correspondiente.


5. Hoja de estilos propia: css/estilos.css

/* =========================================================
   1. Variables: identidad de marca
   ========================================================= */
:root {
  --lumen-acento: #6f42c1;
  --lumen-acento-2: #ff9f1c;
  --lumen-hero-fondo: linear-gradient(135deg, #1e1b4b 0%, #4c1d95 55%, #6f42c1 100%);
  --lumen-radio: 1rem;
  --lumen-sombra: 0 0.75rem 2rem rgb(0 0 0 / 0.08);

  /* Integración con Bootstrap: fuente y color primario */
  --bs-body-font-family: "Outfit", system-ui, sans-serif;
  --bs-primary: var(--lumen-acento);
  --bs-primary-rgb: 111, 66, 193;
  --bs-link-color-rgb: 111, 66, 193;
}

[data-bs-theme="dark"] {
  --lumen-hero-fondo: linear-gradient(135deg, #0b0a1f 0%, #1e1b4b 60%, #3b1f7a 100%);
  --lumen-sombra: 0 0.75rem 2rem rgb(0 0 0 / 0.4);
  --bs-body-bg: #0f0e1a;
  --bs-tertiary-bg: #17152a;
  --bs-link-color-rgb: 177, 151, 252;
}

/* Botón primario con el color de marca (variables de componente) */
.btn-primary {
  --bs-btn-bg: var(--lumen-acento);
  --bs-btn-border-color: var(--lumen-acento);
  --bs-btn-hover-bg: color-mix(in srgb, var(--lumen-acento), black 15%);
  --bs-btn-hover-border-color: color-mix(in srgb, var(--lumen-acento), black 15%);
  --bs-btn-active-bg: color-mix(in srgb, var(--lumen-acento), black 25%);
  --bs-btn-focus-shadow-rgb: 111, 66, 193;
}

h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }

:focus-visible {
  outline: 3px solid var(--lumen-acento-2);
  outline-offset: 3px;
}

/* =========================================================
   2. Hero
   ========================================================= */
.hero {
  background: var(--lumen-hero-fondo);
  color: #fff;
  padding-block: clamp(4rem, 10vw, 8rem);

  .hero__antetitulo { color: var(--lumen-acento-2); letter-spacing: 0.1em; }
}

.hero__orbe {
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 30% 30%, #ffd166, var(--lumen-acento-2) 40%, transparent 70%);
  animation: flotar 6s ease-in-out infinite;
}

/* =========================================================
   3. Tarjetas de servicio
   ========================================================= */
.tarjeta-servicio {
  border: none;
  border-radius: var(--lumen-radio);
  box-shadow: var(--lumen-sombra);
  transition: transform 0.25s ease, box-shadow 0.25s ease;

  &:hover { transform: translateY(-6px); }
}

/* =========================================================
   4. Proceso: CSS Grid propio
   ========================================================= */
.proceso {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
  gap: 1.5rem;
}

.proceso__paso {
  background: var(--bs-body-bg);
  border-radius: var(--lumen-radio);
  padding: 1.5rem;
  box-shadow: var(--lumen-sombra);

  p { margin: 0; color: var(--bs-secondary-color); }
}

.proceso__num {
  display: grid;
  place-items: center;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  margin-block-end: 0.75rem;
  border-radius: 50%;
  background: var(--lumen-acento);
  color: #fff;
  font-weight: 800;
}

/* =========================================================
   5. Portfolio
   ========================================================= */
.proyecto {
  border-radius: var(--lumen-radio);
  overflow: hidden;

  img {
    aspect-ratio: 4 / 3;
    object-fit: cover;
    transition: transform 0.4s ease;
  }

  &:hover img,
  &:focus-within img { transform: scale(1.05); }
}

/* =========================================================
   6. Pie con Flexbox
   ========================================================= */
.pie__contenido {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
}

/* =========================================================
   7. Animaciones (respetando la preferencia de movimiento)
   ========================================================= */
@keyframes aparecer {
  from { opacity: 0; transform: translateY(1.5rem); }
  to   { opacity: 1; transform: none; }
}

@keyframes flotar {
  50% { transform: translateY(-1rem); }
}

.aparecer { animation: aparecer 0.8s ease-out both; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

💡 Consejo: Repasa el fichero buscando lo aprendido en el curso: variables (propias y --bs-*), color-mix(), clamp(), min(), text-wrap, anidamiento, Grid con auto-fit/minmax(), Flexbox, propiedades lógicas y @keyframes con prefers-reduced-motion.


6. JavaScript

6.1 js/tema.js (en el <head>)

// Se ejecuta antes de pintar la página para aplicar el tema guardado
(() => {
  const guardado = localStorage.getItem('tema');
  const prefiereOscuro = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const tema = guardado ?? (prefiereOscuro ? 'dark' : 'light');
  document.documentElement.setAttribute('data-bs-theme', tema);
})();

6.2 js/main.js (al final del <body>)

// ---------- 1. Conmutador de tema ----------
const btnTema = document.getElementById('btnTema');

function pintarBotonTema() {
  const oscuro = document.documentElement.getAttribute('data-bs-theme') === 'dark';
  btnTema.innerHTML = oscuro
    ? '<i class="bi bi-sun-fill" aria-hidden="true"></i>'
    : '<i class="bi bi-moon-stars-fill" aria-hidden="true"></i>';
  btnTema.setAttribute('aria-label', oscuro ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro');
}

btnTema?.addEventListener('click', () => {
  const actual = document.documentElement.getAttribute('data-bs-theme');
  const nuevo = actual === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-bs-theme', nuevo);
  localStorage.setItem('tema', nuevo);
  pintarBotonTema();
});

if (btnTema) pintarBotonTema();

// ---------- 2. Año actual en el pie ----------
const anio = document.getElementById('anio');
if (anio) anio.textContent = new Date().getFullYear();

// ---------- 3. Validación del formulario de contacto ----------
const form = document.getElementById('formContacto');

form?.addEventListener('submit', (evento) => {
  evento.preventDefault();                 // en este proyecto no hay servidor
  form.classList.add('was-validated');

  if (!form.checkValidity()) {
    form.querySelector(':invalid')?.focus(); // llevar el foco al primer error
    return;
  }

  const nombre = form.elements.nombre.value.trim();
  document.getElementById('nombreConfirmacion').textContent = nombre;
  bootstrap.Modal.getOrCreateInstance(document.getElementById('modalGracias')).show();

  form.reset();
  form.classList.remove('was-validated');
});

// ---------- 4. Modal dinámico del portfolio ----------
const modalProyecto = document.getElementById('modalProyecto');

modalProyecto?.addEventListener('show.bs.modal', (evento) => {
  const boton = evento.relatedTarget;      // botón que abrió el modal
  modalProyecto.querySelector('.modal-title').textContent = boton.dataset.titulo;
  modalProyecto.querySelector('.modal-body p').textContent = boton.dataset.descripcion;
  const img = modalProyecto.querySelector('.modal-body img');
  img.src = boton.dataset.imagen;
  img.alt = boton.dataset.alt;
});

📘 Concepto: Un único main.js sirve para todas las páginas gracias al operador ?. (encadenamiento opcional): si en una página no existe el formulario o el modal, el código simplemente no hace nada en lugar de producir un error.


7. Página de portfolio (contenido del <main>)

<main id="contenido" class="py-5">
  <div class="container">
    <nav aria-label="Ruta de navegación">
      <ol class="breadcrumb">
        <li class="breadcrumb-item"><a href="index.html">Inicio</a></li>
        <li class="breadcrumb-item active" aria-current="page">Portfolio</li>
      </ol>
    </nav>
    <h1 class="mb-4">Proyectos recientes</h1>

    <div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-4">
      <div class="col">
        <article class="card h-100 proyecto">
          <img src="img/proyecto-1.webp" class="card-img-top" width="800" height="600" loading="lazy"
               alt="Web de la panadería La Espiga en un portátil">
          <div class="card-body d-flex flex-column">
            <h2 class="h5 card-title">Panadería La Espiga</h2>
            <p class="card-text text-body-secondary">Web corporativa con pedidos por WhatsApp.</p>
            <button type="button" class="btn btn-outline-primary mt-auto"
                    data-bs-toggle="modal" data-bs-target="#modalProyecto"
                    data-titulo="Panadería La Espiga"
                    data-descripcion="Rediseño completo con catálogo de productos y botón de pedido. +40% de pedidos en tres meses."
                    data-imagen="img/proyecto-1.webp"
                    data-alt="Web de la panadería La Espiga en un portátil">
              Ver detalle <span class="visually-hidden">de Panadería La Espiga</span>
            </button>
          </div>
        </article>
      </div>
      <!-- Repite el bloque .col para cada proyecto, cambiando los data-* -->
    </div>
  </div>

  <!-- Modal reutilizable: su contenido lo rellena main.js -->
  <div class="modal fade" id="modalProyecto" tabindex="-1" aria-labelledby="modalProyectoTitulo" aria-hidden="true">
    <div class="modal-dialog modal-lg modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h2 class="modal-title fs-5" id="modalProyectoTitulo">Proyecto</h2>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
        </div>
        <div class="modal-body">
          <img src="" class="img-fluid rounded mb-3" alt="">
          <p class="mb-0"></p>
        </div>
      </div>
    </div>
  </div>
</main>

💡 Consejo: Indica siempre width y height en las imágenes (aunque luego CSS las haga responsive): el navegador reserva el espacio y evitas saltos de contenido (CLS), una métrica que Lighthouse penaliza. loading="lazy" retrasa la carga de las imágenes que no están en pantalla.


8. Página de contacto (contenido del <main>)

<main id="contenido" class="py-5">
  <div class="container">
    <h1 class="mb-4">Contacto</h1>
    <div class="row g-5">
      <div class="col-lg-8">
        <form id="formContacto" class="row g-3" novalidate>
          <div class="col-md-6">
            <label for="nombre" class="form-label">Nombre</label>
            <input type="text" class="form-control" id="nombre" name="nombre" autocomplete="name" required>
            <div class="invalid-feedback">Escribe tu nombre.</div>
          </div>
          <div class="col-md-6">
            <label for="email" class="form-label">Correo electrónico</label>
            <input type="email" class="form-control" id="email" name="email" autocomplete="email" required>
            <div class="invalid-feedback">Introduce un correo válido.</div>
          </div>
          <div class="col-12">
            <label for="asunto" class="form-label">¿Qué necesitas?</label>
            <select class="form-select" id="asunto" name="asunto" required>
              <option value="" selected disabled>Elige una opción</option>
              <option>Web corporativa</option>
              <option>Tienda online</option>
              <option>Rediseño</option>
            </select>
            <div class="invalid-feedback">Selecciona una opción.</div>
          </div>
          <div class="col-12">
            <div class="form-floating">
              <textarea class="form-control" id="mensaje" name="mensaje" placeholder="Mensaje"
                        style="height: 9rem" minlength="20" required></textarea>
              <label for="mensaje">Cuéntanos tu proyecto</label>
              <div class="invalid-feedback">Escribe al menos 20 caracteres.</div>
            </div>
          </div>
          <div class="col-12">
            <div class="form-check">
              <input class="form-check-input" type="checkbox" id="privacidad" required>
              <label class="form-check-label" for="privacidad">He leído y acepto la política de privacidad</label>
              <div class="invalid-feedback">Debes aceptarla para enviar el mensaje.</div>
            </div>
          </div>
          <div class="col-12">
            <button type="submit" class="btn btn-primary btn-lg">
              <i class="bi bi-send me-1" aria-hidden="true"></i> Enviar
            </button>
          </div>
        </form>
      </div>

      <aside class="col-lg-4">
        <address class="p-4 bg-body-tertiary rounded-4 mb-0">
          <h2 class="h5">Lumen Estudio</h2>
          <p><i class="bi bi-geo-alt me-2" aria-hidden="true"></i>Calle de la Luz, 10 · Valencia</p>
          <p><i class="bi bi-envelope me-2" aria-hidden="true"></i><a href="mailto:hola@lumen.example">hola@lumen.example</a></p>
          <p class="mb-0"><i class="bi bi-clock me-2" aria-hidden="true"></i>L-V de 9:00 a 18:00</p>
        </address>
      </aside>
    </div>
  </div>

  <!-- Modal de confirmación -->
  <div class="modal fade" id="modalGracias" tabindex="-1" aria-labelledby="modalGraciasTitulo" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content text-center p-3">
        <div class="modal-body">
          <i class="bi bi-check-circle-fill text-success display-4" aria-hidden="true"></i>
          <h2 class="h4 mt-3" id="modalGraciasTitulo">¡Gracias, <span id="nombreConfirmacion"></span>!</h2>
          <p>Hemos recibido tu mensaje. Te responderemos en menos de 24 horas.</p>
          <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Cerrar</button>
        </div>
      </div>
    </div>
  </div>
</main>

⚠️ Importante: GitHub Pages solo sirve ficheros estáticos: no puede procesar formularios. Para recibir los mensajes de verdad necesitarías un servicio externo de formularios o un backend (por ejemplo, en PHP). En este proyecto simulamos el envío con JavaScript.

La página Servicios sigue el mismo patrón: un <h1>, una fila row-cols-1 row-cols-md-3 de cards con precio (card-header, list-group-flush con lo incluido, card-footer con botón) y un acordeón de preguntas frecuentes como el del módulo 16.


9. Checklist de calidad

9.1 Accesibilidad

✔ Comprobación
☐ lang="es" en <html> y un <title> distinto en cada página
☐ Un solo <h1> por página y jerarquía de títulos sin saltos (h1 → h2 → h3)
☐ Todas las imágenes con alt descriptivo (o alt="" si son decorativas)
☐ Iconos decorativos con aria-hidden="true"; botones de solo icono con aria-label
☐ Se puede usar toda la web solo con el teclado (Tab, Shift+Tab, Enter, Esc) y el foco es visible
☐ Enlace “Saltar al contenido” funcional
☐ Contraste suficiente en ambos modos (claro y oscuro)
☐ Cada campo del formulario tiene su <label> y los errores se explican con texto
☐ Las animaciones se reducen con prefers-reduced-motion

9.2 Rendimiento

✔ Comprobación
☐ Imágenes en formato moderno (WebP/AVIF), con el tamaño adecuado y width/height
☐ loading="lazy" en imágenes fuera de la primera pantalla (nunca en la del hero)
☐ Fuentes con preconnect y display=swap; como máximo 2 familias
☐ Scripts al final del <body> (o con defer)
☐ Sin CSS ni JS que no se use

9.3 Auditoría con Lighthouse

  1. Abre tu página en Chrome o Edge y pulsa F12.
  2. Ve a la pestaña Lighthouse.
  3. Elige modo Navigation, dispositivo Mobile y marca Performance, Accessibility, Best Practices y SEO.
  4. Pulsa Analyze page load y espera el informe.
  5. Revisa cada aviso: Lighthouse explica el problema y enlaza a cómo solucionarlo.

💡 Consejo: Haz la auditoría en una ventana de incógnito (las extensiones del navegador alteran los resultados) y repítela con la web ya publicada en GitHub Pages, que es lo que verán tus usuarios. Objetivo: 90 o más en las cuatro categorías.

9.4 Validación W3C

Validador Dirección Qué comprueba
HTML validator.w3.org Etiquetas mal cerradas, atributos inválidos, id duplicados…
CSS jigsaw.w3.org/css-validator Propiedades y valores incorrectos

Puedes validar por URL (una vez publicada), subiendo el fichero o pegando el código. Corrige todos los errores; los avisos (warnings) revísalos con criterio.

⚠️ Importante: El validador de CSS puede marcar como error alguna característica muy reciente (anidamiento, @layer, color-mix()…) aunque los navegadores la soporten. Si estás seguro de que es CSS válido y actual (compruébalo en MDN), puedes ignorar ese aviso concreto.


10. Publicación en GitHub Pages

📘 Concepto: GitHub Pages es un servicio gratuito de GitHub que publica como sitio web los ficheros de un repositorio. Tu web quedará en https://TU-USUARIO.github.io/NOMBRE-REPOSITORIO/.

10.1 Crear el repositorio

  1. Crea una cuenta en github.com (si no la tienes).
  2. Pulsa New repository (botón + arriba a la derecha).
  3. Nombre: lumen-estudio. Visibilidad: Public. No marques ninguna opción de inicialización.
  4. Pulsa Create repository.

10.2 Subir el proyecto con Git

Abre la terminal de VS Code en la carpeta del proyecto:

# (Si es tu primera vez con Git, configura antes user.name y user.email)
# Convertir la carpeta en repositorio y hacer el primer commit
git init
git add .
git commit -m "Primera versión de la web de Lumen Estudio"

# Conectar con GitHub y subir
git branch -M main
git remote add origin https://github.com/TU-USUARIO/lumen-estudio.git
git push -u origin main

💡 Consejo: Si no quieres usar la terminal todavía, en la página del repositorio vacío tienes el enlace uploading an existing file: arrastra tus ficheros y carpetas y pulsa Commit changes.

10.3 Activar GitHub Pages

  1. En el repositorio, ve a Settings → Pages (menú lateral).
  2. En Build and deployment, Source: Deploy from a branch.
  3. Branch: main y carpeta / (root). Pulsa Save.
  4. Espera uno o dos minutos y recarga: aparecerá “Your site is live at…” con la URL.

10.4 Actualizar la web

Cada cambio que subas (git add ., git commit -m "Describe el cambio" y git push) se publica automáticamente en uno o dos minutos.

Problema tras publicar Causa habitual Solución
Error 404 en la página principal No hay index.html en la raíz El fichero de inicio debe llamarse exactamente index.html
Imágenes o CSS no cargan Rutas absolutas (/css/...) o mayúsculas distintas Usa rutas relativas (css/estilos.css) y respeta mayúsculas
Los cambios no aparecen Caché del navegador o despliegue en curso Espera, revisa la pestaña Actions y recarga con Ctrl + F5
Una carpeta que empieza por _ no se publica GitHub Pages usa Jekyll por defecto Crea un fichero vacío .nojekyll en la raíz

11. Propuestas de ampliación

Ampliación Qué practicarás
Filtro del portfolio por categorías (botones btn-check) JavaScript y atributos data-*
Página de blog con artículos y paginación Más HTML semántico (article, time)
View transitions entre páginas (@view-transition) CSS moderno (módulo 14)
Compilar Bootstrap con Sass solo con los componentes usados Personalización avanzada (módulo 17)
Formulario real con un servicio externo o un backend propio Integración cliente-servidor
Versión en inglés con hreflang y dominio propio en GitHub Pages Internacionalización, SEO y DNS

12. Ejercicios

Ejercicio 1: Planificación y página de inicio

Escribe los requisitos de tu versión del proyecto (tu portfolio o la web de un negocio real o inventado) y dibuja el wireframe de las cuatro páginas en escritorio y móvil. Después crea la estructura de carpetas y la página index.html completa con navbar, hero animado, servicios, proceso y pie. Valida el HTML en el W3C hasta obtener cero errores.

Ejercicio 2: Servicios con precios

Construye servicios.html con tres cards de precios (la central destacada con border-primary y un badge “Más popular”) y un acordeón con al menos cuatro preguntas frecuentes.

Ejercicio 3: Portfolio con modal

Crea portfolio.html con al menos seis proyectos y el modal reutilizable. Comprueba con el teclado que el foco entra en el modal al abrirlo y vuelve al botón al cerrarlo.

Ejercicio 4: Contacto validado

Implementa contacto.html con el formulario validado y el modal de agradecimiento. Añade un campo de teléfono opcional con pattern y un mensaje de error propio.

Ejercicio 5: Auditoría y mejora

Pasa Lighthouse en modo móvil a las cuatro páginas, en modo claro y oscuro. Anota las puntuaciones iniciales, corrige los problemas y vuelve a medir hasta llegar a 90 o más en todas las categorías.

Ejercicio 6: Publicación

Publica el proyecto en GitHub Pages, comparte la URL y añade al repositorio un fichero README.md que explique el proyecto, las tecnologías usadas y el enlace a la web publicada.


Resumen

Fase Herramientas y técnicas
Planificación Requisitos, estructura de carpetas, wireframe (Mermaid / texto)
Estructura HTML5 semántico, skip link, jerarquía de títulos, alt
Maquetación Rejilla y utilidades de Bootstrap + CSS Grid y Flexbox propios
Identidad visual Variables CSS, variables --bs-*, color-mix(), clamp(), anidamiento
Componentes Navbar, cards, breadcrumb, modal, acordeón, formularios de Bootstrap
Interactividad data-bs-theme + localStorage, validación con .was-validated, evento show.bs.modal
Animación @keyframes, transition, prefers-reduced-motion
Calidad Checklist de accesibilidad y rendimiento, Lighthouse, validadores W3C
Publicación Git, GitHub, Settings → Pages → Deploy from a branch

¡Enhorabuena! Has completado el curso. Ahora tienes las bases para maquetar cualquier sitio web moderno, accesible y responsive. El siguiente paso natural es profundizar en JavaScript para dar vida a tus interfaces.