# INFORME — Rendimiento de fermenty.es (fase 0, diagnóstico)

**Fecha:** 8 de agosto de 2026
**Rama:** `rendimiento-080826`
**Briefing:** rendimiento web pública, medición de partida Lighthouse 13.4.1 (home: 61)
**Método:** lectura del código en la rama + verificación de producción con `curl`
(cabeceras, TTFB, HTML servido) + Lighthouse 13.4.1 móvil corrido desde aquí sobre
las siete páginas del §5.1. Ningún cambio de código en esta fase.

---

## 0. Resumen ejecutivo

El diagnóstico del briefing es correcto en lo esencial y se queda corto en un punto
que lo agrava todo: **el servidor habla HTTP/1.1**, sin HTTP/2. La cadena de
`@import` (4 niveles en serie, con dos dominios externos de Google Fonts al final)
sobre HTTP/1.1 y con 11 CSS bloqueantes es la causa dominante del LCP de 9 s. El
backend no tiene culpa: TTFB real de 105–150 ms en todas las rutas, consultas con
eager loading correcto, cero N+1.

De las tres hipótesis: la 1 se **confirma**, la 2 se **confirma con matiz**, la 3 se
**refuta a medias** — la compresión gzip **sí está activa** (el informe de Lighthouse
engañaba); lo que falta es `Cache-Control` y HTTP/2.

La sospecha de que la peor página no era la medida: **acertada**. Lighthouse sobre
las siete páginas sitúa a `/fermentos` empatada con la home en el fondo (61 en mi
pasada), por un problema propio que la home no tiene: sus portadas son
`background-image` inline sobre el cover grande de storage — el LCP tarda 2,4 s
solo en ser *descubierto*. El thumb de 420 px que lo arreglaría se genera en cada
subida desde siempre y ninguna vista pública lo consume.

El plan tiene 6 fases. Las tres primeras (Apache, fuentes autoalojadas, aplanar la
cascada CSS) se llevan por delante ~3 de los 3,9 s de retraso de renderizado y son
comunes a todo el sitio. Las otras tres (LCP/imágenes de la home, storage con WebP
y tarjetas con `<img>` de verdad, remates de layout y versionado) consolidan el
≥90 página a página.

---

## 1. Las tres hipótesis, verificadas

### H1 — «La causa dominante es la cadena de @import, no el peso» → **CONFIRMADA**

El CSS propio de la home pesa 53 KB en disco y **~14 KB comprimido** (gzip real de
producción: `site.css` 26,1 KB → 5,6 KB transferidos). El peso es irrelevante; los
round-trips no:

```
HTML
└─ styles.css              (0,4 KB — solo 4 líneas de @import)   nivel 2
   └─ tokens/fonts.css     (0,7 KB — solo 1 @import más)         nivel 3
      └─ fonts.googleapis.com/css2   (DNS + TLS nuevos)          nivel 4
         └─ 4 × woff2 en fonts.gstatic.com (DNS + TLS nuevos)    nivel 5
```

Cinco niveles en serie, dos handshakes TLS extra a dominios de Google, y todo ello
**en HTTP/1.1** (verificado: `curl -w '%{http_version}'` → `1.1`), donde cada
conexión nueva paga su setup completo y el navegador tiene 6 conexiones por host.
En 4G lento, cada nivel son ~600–750 ms. Ahí están los 3,9 s de retraso de
renderizado del elemento LCP: el hero llega en 260 ms y espera pintado a que
termine la cascada.

Agravante que el informe de Lighthouse no separa: los 11 CSS bloqueantes de la home
son 6 `<link>` del layout/página + 4 `@import` de tokens + el css2 de Google. Y las
4 capturas PNG de la home (161 KB) cargan **todas eager** — cero `loading="lazy"` —
compitiendo por el ancho de banda con el CSS crítico.

### H2 — «Google Fonts cuesta dos saltos y sobra la mitad» → CONFIRMADA, con matiz

Los dos saltos y los dos dominios extra: confirmados (niveles 4-5 de arriba). El
`@import` pide **15 combinaciones** (Comfortaa ×4, Raleway ×7, JetBrains Mono ×4);
el navegador solo baja las que cada página usa (4 woff2, 142 KB, en la home), así
que el matiz es que no se descarga de más — se *declara* de más. El coste real no
son los KB sino los dos handshakes y el nivel extra de cadena. `display=swap` ya
está en la URL, así que las fuentes no bloquean el pintado por sí mismas: bloquea
el **CSS** css2, que es un recurso bloqueante más.

Dos detalles que rematan el caso del autoalojado:
- `tokens/fonts.css` lleva desde junio el comentario «Swap to self-hosted woff2
  before shipping». El barco zarpó sin hacerlo.
- El layout público no tiene **ni un `preconnect`** a fonts.googleapis/gstatic
  (el layout de la PWA sí los lleva — `layouts/mobile.blade.php:21-22`). La web
  pública paga los handshakes al descubrirlos, en el peor momento.

### H3 — «Falta compresión y expiración en Apache» → REFUTADA A MEDIAS

- **Compresión: SÍ está activa.** `curl` con `Accept-Encoding` devuelve
  `Content-Encoding: gzip` en todos los CSS/JS (`site.css`: 26,1 KB → 5.622 bytes).
  Lo que Lighthouse mostraba (6 KB recurso / 5,8 KB transferencia) era el tamaño
  *descomprimido estimado vs transferido*: ficheros pequeños donde gzip casi no se
  nota en su tabla. No hay brotli, solo gzip — mejora menor disponible.
- **Caché: NO existe.** Ningún estático lleva `Cache-Control` ni `Expires` — solo
  `ETag` y `Last-Modified` (caché heurística: el navegador revalida cuando quiere).
  Los 24 recursos con TTL `None` del informe: confirmados. No hay `mod_expires`
  en `public/.htaccess` (solo el boilerplate de Laravel) ni en el vhost.
- **Lo que la hipótesis no veía: HTTP/1.1.** Apache 2.4.58 soporta `mod_http2` y
  PHP va por FPM (compatible con `mpm_event`); activarlo es configuración pura y
  multiplica el valor de todo lo demás.

---

## 2. Lo medido por página

### TTFB real (curl desde red doméstica europea, 3 pasadas, HTML comprimido)

| Ruta | TTFB (3 pasadas) |
|---|---|
| `/` | 123 / 123 / 126 ms |
| `/fermentos` | 125 / 116 / 152 ms |
| `/fermentos/kombucha` | 143 / 397¹ / 125 ms |
| `/blog` | 146 / 116 / 121 ms |
| `/blog/kombucha-calor-verano` | 144 / 132 / 139 ms |
| `/pro` | 109 / 110 / 115 ms |
| `/herramientas/afinador-kombucha` | 112 / 119 / 111 ms |
| `/herramientas` | 110 / 116 / 105 ms |

¹ Un outlier aislado en 24 peticiones; no se repite.

Eso incluye red + TLS: el tiempo de aplicación es una fracción. **El «0 ms» de
Lighthouse era su modelo, pero la conclusión es la misma: el servidor no es el
problema.** No hay `Server-Timing`; no propongo añadirlo de forma permanente porque
no hay nada que cazar (véase §3.3).

### Lighthouse 13.4.1 móvil (corrido el 8-ago desde esta auditoría, 1 pasada/página)

Misma versión que la medición de partida; red distinta (los absolutos no son
comparables 1:1 con el 61 del briefing — las *diferencias entre páginas* sí).

| Página | Rendimiento | FCP | LCP | TBT | CLS |
|---|---|---|---|---|---|
| `/` | 68 | 4,0 s | 6,2 s | 0 ms | 0 |
| `/fermentos` | **61** | **6,0 s** | 6,2 s | 0 ms | 0 |
| `/fermentos/kombucha` | 80 | 3,8 s | 3,8 s | 0 ms | 0 |
| `/blog` | 70 | 3,8 s | 5,8 s | 0 ms | 0 |
| `/blog/kombucha-calor-verano` | 73 | 3,9 s | 4,8 s | 0 ms | 0 |
| `/pro` | 80 | 3,8 s | 3,8 s | 0 ms | 0 |
| `/herramientas/afinador-kombucha` | 80 | 3,8 s | 3,8 s | 0 ms | 0 |

El desglose del LCP de `/fermentos` cuenta una historia distinta a la de la home
(TTFB 117 ms · **retraso de descubrimiento del recurso 2.391 ms** · carga 358 ms ·
render 1.139 ms): su LCP no es un `<img>`, es `div.fcard__cover` con la portada
como **`background-image` inline** — el preload scanner no la ve, no admite
`fetchpriority`, y solo se pide cuando el CSS ya ha pintado la caja. Y la URL es el
**cover grande de storage** (hasta 2048 px) para un hueco de 378×150. Ese patrón
sale de `components/tarjeta-fermento.blade.php` y se repite en las cards del blog
(`blog/index`, destacado incluido), los relacionados de la ficha y las variaciones.

### Perfil de recursos bloqueantes por página (del HTML servido en producción)

Base común a TODAS las páginas: `styles.css` (+4 `@import` + css2 de Google) +
`site.css` + `components.css` = **8 recursos bloqueantes de serie**. Encima:

| Página | CSS de página extra | Bloqueantes total | Carga singular |
|---|---|---|---|
| Home | home + fermentos + pro | **11** | Turnstile (async), 4 PNG eager (161 KB), 2 JSON-LD |
| /fermentos | fermentos | 9 | portadas como `background-image` inline (LCP invisible al scanner) |
| Ficha kombucha | fermentos | 9 | cover JPEG 83 KB **sin width/height** |
| /blog | blog | 9 | cards y destacado con covers como `background-image` |
| Entrada blog | blog | 9 | cover JPEG **124 KB** sin width/height (aspect-ratio en CSS sí) |
| /pro | pro | 9 | — |
| Afinador kombucha | fermentos + afinador | 10 | afinador.js 11,7 KB `defer` (no bloquea) |
| /herramientas | tools | 9 | — |

**¿La página peor? `/fermentos`, empatada con la home — y no era la que medía el
briefing.** La sospecha era buena: la peor no era la medida. Todas comparten la
cadena crítica (por eso ficha, pro y afinador clavan un 80 idéntico: son la página
«solo cadena»), pero `/fermentos` y `/blog` añaden el patrón background-image que
retrasa el descubrimiento de su LCP 2,4 s, y la home añade payload (Turnstile +
161 KB de PNG eager + 3 CSS de página). La sospecha sobre el afinador, en cambio,
se desmiente: su interactividad va en un JS `defer` versionado fuera de la ruta
crítica — su perfil de carga es el de una ficha. Arreglada la cadena común suben
todas; `/fermentos` y `/blog` necesitan además el arreglo de tarjetas (fase 5).

---

## 3. Las siete preguntas del §5

### 3.1 El resto del sitio
Contestado en §2: perfil por página y Lighthouse de las siete. La conclusión
operativa: **el grueso del problema es común** (cadena de CSS + fuentes +
HTTP/1.1 + sin caché — las páginas «solo cadena» clavan un 80 idéntico) y se
arregla una vez para todo el sitio. Lo específico por página son dos cosas:
la home con su payload (Turnstile + PNG eager) y las **tarjetas con
`background-image`** que hunden `/fermentos` y `/blog` (fases 4 y 5).

### 3.2 Bootstrap
**No se carga en la web pública. Cero.** El layout público (`layouts/web.blade.php`)
solo enlaza `/landing/*`; Bootstrap se compila únicamente en el bundle del ERP
(`resources/scss/app.scss` → `public/build/assets/app-*.css`) y ninguna vista
pública lo referencia. El porcentaje de uso en la pública es exactamente 0 % porque
no viaja ni un byte. (La landing CSS es artesanal: tokens + site + components.)

### 3.3 TTFB real y N+1
Tabla en §2: 105–150 ms constantes. En código:
- `FermentosController::index/show` — eager loading correcto (`with('guides')`,
  `load(['category','steps.actions'])`); relacionados y variaciones son 2 consultas
  acotadas. Sin N+1.
- `BlogController::index/show` — `with('category'|'author'|'tags')`, comentarios con
  `with('replies')`. Sin N+1. `recordView()` hace 1 INSERT por visita con dedupe
  por índice único — coste despreciable.
- No hay caché de página en ninguna capa, y **no hace falta** para estos números.

### 3.4 Pipeline de imágenes
Sí hay procesamiento al subir, y es mejor de lo que sugieren los nombres:
`ImageStore::storeResized()` (app/Support/ImageStore.php) redimensiona a un máximo
de **2048 px**, calidad 85, y genera un thumb de 420 px. Los nombres
(`kombucha-jar-limon-1781624856.jpg`) son slug saneado + timestamp — de ImageStore,
no del original tal cual. Los problemas reales:
1. **Conserva el formato de entrada** — jamás emite WebP.
2. **2048 px es el doble de lo que ninguna vista pública muestra** (covers a ≤960 px).
3. El thumb de 420 px existe pero **ninguna vista pública lo consume**: la ficha
   sirve el cover completo y — peor — las tarjetas (`tarjeta-fermento`, cards del
   blog) sirven ese mismo cover grande como `background-image` para huecos de
   ~380×150. El modelo ya expone `thumbnail_url` con fallback; nadie lo llama.
4. Las vistas pintan `<img>` sin `width/height` (ficha y blog; el blog salva el CLS
   por `aspect-ratio` en CSS, la ficha no), y las tarjetas ni siquiera son `<img>`
   — el `background-image` es indescubrible para el preload scanner y ancla el LCP
   de `/fermentos` y `/blog` (véase §2).

Propuesta sistemática (fase 5): ImageStore emite `.webp` junto al original (cover y
thumb), tope 1600 px, comando artisan de backfill para lo ya subido, y `<picture>`
+ dimensiones en las 3-4 vistas que pintan covers. Nada de conversiones a mano.

### 3.5 ¿Vite para public/landing?
**No, y el propio repo lo argumenta.** `deploy.sh` no tiene paso de node: producción
es `git reset` + composer, y `public/build/` viaja **commiteado** (compilado en
local). Meter la landing en Vite significaría: (a) depender de un build que hoy está
roto (vite 8 vs laravel-vite-plugin 3 / sass — la compilación del ERP ya se hace
con un workaround), (b) añadir manifest/hashing para unos ficheros planos que no
necesitan transformación alguna, y (c) no ganar nada que un `?v=filemtime`
server-side no dé gratis (el patrón ya existe en `components.css` y en los assets
del afinador — solo está aplicado a medias). La recomendación es lo contrario:
**quedarse fuera de Vite y sistematizar lo que ya hay** — un helper `asset_v()`
para el versionado (fase 6) y un concatenado trivial para aplanar la cascada
(fase 3), commiteado como ya se commitea `public/build`. Si algún día la landing
crece a componentes JS con imports, se revisita; hoy sería coste sin retorno.
El despliegue no cambia en nada: mismos ficheros en git, mismo `deploy.sh`, mismo
reciclado de PHP.

**⚠ La trampa que deja quedarse fuera de Vite** (elevada aquí desde la lista de
deuda a petición del propietario, 8-ago): `npm run build` está roto (vite 8 vs
laravel-vite-plugin 3 / sass) **y `public/build/` viaja commiteado**. Quien ejecute
el build en buena fe — porque clona el repo y hace lo obvio — regenerará
`public/build/` con un toolchain a medio casar y, si commitea, **pisa los assets
del ERP en producción en el siguiente deploy**. Hasta que el build tenga dueño y
arreglo, la regla es: *no se ejecuta `npm run build`; `public/build/` se toca solo
con el workaround documentado* (compilar SCSS con node-sass a mano — memoria del
2026-06). Esta regla debería vivir también en el README raíz o en CLAUDE.md del
repo, no solo aquí.

### 3.6 site.js
7,4 KB, síncrono al final del `<body>`. Hace: cookie de zona horaria, clase de
dispositivo (`data-device` + cookie), sombra del nav al hacer scroll, hamburguesa,
back-to-top, scroll a la confirmación de suscripción, reveals con
IntersectionObserver, y el bloque de «superficie» que reescribe los enlaces a la
app según el ancho real de ventana. Los reflows forzados: lecturas de
`window.innerWidth` en tiempo de parseo (`site.js:23`, el de 75 ms) y de
`window.scrollY` en los handlers iniciales (`:35`). Ninguno es evitable con CSS —
escriben cookies y reescriben hrefs, cosas de JS — pero los 75 ms se van solos al
pasar el script a `defer` (fase 6): con el layout ya estable, leer `innerWidth` no
fuerza nada. Es cosmético: TBT ya es 0. Lo único sustancial que sobra ahí es el
**stub muerto** de `form[data-subscribe]` (herencia de la landing estática;
ninguna vista lo usa ya — los formularios reales postean a rutas de verdad).

### 3.7 Deuda encontrada por el camino
Lista en §6. No se toca nada de eso en esta rama salvo lo que las fases ya cubren.

---

## 4. Plan por fases (impacto/coste, cada una con su commit)

> Regla de oro heredada del briefing: nada de JS en la ruta crítica, nada de
> imágenes sin dimensiones. Las fases 1-2 no tocan ni un selector; la 3 es la única
> que toca el layout y por eso va con la verificación visual más gorda.

### Fase 1 — Apache + versionado universal · impacto ALTO, riesgo NULO (caché) / BAJO (HTTP/2)

> Reordenada el 8-ago a petición del propietario: el versionado `?v=` sube aquí
> desde la fase 6. Poner `Cache-Control` largo sobre assets sin busting y luego
> modificarlos en F2-F4 habría clavado CSS viejo en los navegadores durante todo
> el TTL, sin CDN con el que purgar.

- **Helper `asset_v('landing/...')`** (asset + `?v=filemtime`, cacheado por
  request) aplicado a **todos** los CSS/JS de la web pública — layout y `@push`
  de cada página. Sustituye a los tres `filemtime()` manuales que ya existían.
- Bloque `mod_expires`/`mod_headers` en `public/.htaccess` (viaja en git, el
  deploy no cambia): imágenes y fuentes 30 d (los nombres de storage llevan
  timestamp — de facto inmutables); **CSS/JS 1 hora, no más**: los `@import` de
  `styles.css` son ficheros estáticos que no pueden llevar `?v=`, así que el
  busting NO es universal hasta que la fase 3 mate el barril. La fase 3 los sube
  a 1 año + `immutable`. HTML sin caché (ya lo pone Laravel).
- En el vhost (una vez, a mano, comandos en el Anexo A): **verificar el MPM
  activo primero** (`apache2ctl -M`) — `mod_http2` exige event o worker; con
  PHP-FPM debería ser event, pero se confirma antes de tocar y se deja anotado
  el rollback (`a2dismod http2` + reload). *Riesgo nulo es la caché; HTTP/2 es
  riesgo bajo, que no es lo mismo.*
- Qué da y qué no da HTTP/2, para leer bien la re-medición: arregla la
  **concurrencia** (los 11 CSS bajan en paralelo por una conexión), **no la
  serialización** — los cinco niveles de `@import` siguen siendo cinco viajes,
  con multiplexado o sin él. Si tras F1 el LCP mejora menos de lo esperado, no
  es que HTTP/2 haya fallado: es que F3 sigue pendiente. La proyección del plan
  no se apoya en F1 para lo que solo puede dar F3.
- `mod_brotli` si está disponible en el paquete de Ubuntu; si no, gzip se queda.
- **Verifica:** `curl -sI` (cache-control, content-encoding), `curl -w
  '%{http_version}'` → `2`, y Lighthouse de **`/fermentos`** — la métrica de
  seguimiento de esta rama es la peor página, no la home.

### Fase 1 bis — El thumb que ya existía · impacto ALTO en /fermentos, coste UNA LÍNEA

> Adelantada desde la fase 5 a petición del propietario: la página que incumple
> el criterio de aceptación no espera a la quinta fase cuando el arreglo lleva
> meses en el disco.

- `components/tarjeta-fermento.blade.php`: el `background-image` pasa de
  `cover_image_url` a **`thumbnail_url`** (420 px, generado en cada subida,
  con fallback al cover si no hay thumb). Sin pipeline, sin backfill, sin tocar
  el markup: solo cambia la URL. El recurso LCP de `/fermentos` pasa de ~83 KB
  (cover 2048 px) a la escala del hueco real (378×150).
- Las cards del blog NO entran aquí: `BlogPost` no tiene accessor de thumb —
  eso es pipeline de verdad y se queda en la fase 5.
- El refactor a `<img>` real (descubribilidad, `fetchpriority`) también queda
  en la fase 5: esta fase es solo la línea.
- **Verifica:** Lighthouse `/fermentos` antes/después; las portadas se ven
  idénticas a tamaño de tarjeta (420 px para un hueco de ~380 px).

### Fase 2 — Fuentes autoalojadas · impacto ALTO, riesgo BAJO
- Auditar pesos realmente usados en la landing CSS y bajar **solo esos** woff2
  (subset latin) a `public/landing/fonts/` — nada de autoalojar las 15 variantes
  declaradas por inercia. Mirar primero si Comfortaa/Raleway/JetBrains Mono
  tienen versión variable: una vf por familia puede salir más barata que 3-4
  estáticas.
- `tokens/fonts.css` pasa de `@import` de Google a `@font-face` locales con
  `font-display: swap`.
- `<link rel="preload" as="font">` **solo de la cara del pliegue superior** (una,
  dos como máximo): precargar cuatro fuentes compite con el LCP por el ancho de
  banda, que es exactamente lo que estamos quitando.
- Desaparecen fonts.googleapis y fonts.gstatic de la web pública: −2 dominios,
  −2 niveles de cadena. (En `+++/CI/Fonts` hay Comfortaa pero no Raleway ni
  JetBrains Mono, y sin subset — se bajan los woff2 correctos en la fase.)
- **Verifica:** tipografías idénticas en las 7 páginas, red sin peticiones a Google.

### Fase 3 — Aplanar la cascada CSS · impacto ALTO, riesgo MEDIO (toca layout)
- Nuevo artefacto `public/landing/base.css` = tokens (fonts + colors + typography +
  spacing) + `site.css` + `components.css` concatenados en su orden actual.
  Generado por un script de concatenación trivial (`npm run landing:css` o comando
  artisan — sin Vite, sin transformación), corrido en local y commiteado: el mismo
  patrón que `public/build/`. Los ficheros actuales siguen siendo la fuente.
- `layouts/web.blade.php`: un solo `<link>` a `base.css?v=` + el CSS de página del
  `@push`. El barril `styles.css` deja de enlazarse (se queda en el repo como
  fuente del concatenado).
- La home deja de cargar `fermentos.css` y `pro.css`: se auditan con coverage los
  selectores que realmente usa (las cards de fermentos, la tabla de precios) y se
  mueven a `home.css`. Plan B si sale enrevesado: mantener los 2 links — con la
  cascada plana y HTTP/2 son 2 peticiones paralelas baratas, no 2 niveles.
- Resultado: de 11 recursos bloqueantes a **2-3, todos del mismo dominio, sin
  niveles**.
- Al morir el barril muere el último asset sin busting: **aquí los CSS/JS suben
  de 1 hora a 1 año + `immutable`** en Apache (cierre del TTL corto de F1).
- **Verifica:** comparación visual antes/después de las 7 páginas, móvil y
  escritorio (criterio §7 del briefing). Re-medición Lighthouse aquí: el grueso
  del objetivo debe estar ya cumplido.

### Fase 4 — LCP e imágenes de la home · impacto MEDIO, riesgo BAJO
- `fetchpriority="high"` en el hero (`app-lote.png`).
- `loading="lazy" decoding="async"` en las 3 capturas bajo el pliegue.
- Capturas a WebP con `srcset` 1x/2x (hoy: PNG servido a 388×540 para 282×392 CSS;
  WebP ≈ −60 %). Los PNG se quedan de fallback en `<picture>` o se retiran si el
  soporte WebP se da por universal (2026: lo es).
- Turnstile: cargar `api.js` solo cuando el formulario de la lista asome
  (IntersectionObserver — el patrón reveal ya existe en site.js). Hoy entra en
  cada visita a la home para un form que está al fondo de la página.
  **Con red de seguridad y derecho a no hacerse**: si el widget no ha resuelto
  cuando la persona envía, el formulario falla — así que el envío debe esperar
  al token o la carga dispararse con margen (al asomar la sección anterior). Ya
  es `async` y solo-home: la ganancia es pequeña; si complica, se queda como
  está (decisión del propietario, 8-ago).
- **Verifica:** Lighthouse home — LCP < 2,5 s y TBT sigue en 0.

### Fase 5 — Storage: WebP y el fin del background-image · impacto ALTO en /fermentos y /blog, riesgo BAJO
- **Tarjetas a `<img>` de verdad**: `tarjeta-fermento` y las cards del blog pasan
  de `div` con `background-image` inline a `<img>` con `object-fit: cover`,
  dimensiones y `loading="lazy"` en las que van bajo el pliegue — y consumiendo
  **`thumbnail_url`** (420 px, existe desde siempre) en vez del cover completo.
  Esto solo es lo que saca a `/fermentos` de su 61: el LCP se vuelve descubrible
  en el HTML y pasa de ~83 KB a ~15-20 KB. El CLS se blinda con las dimensiones
  (hoy lo salva la caja del div; el cambio no puede regresarlo).
- `ImageStore::storeResized()`: emite `.webp` junto al original (cover + thumb),
  tope 2048 → 1600 px.
- Comando artisan de backfill (`imagenes:webp`) para `ferment-types/`,
  `blog/covers/` y avatares ya subidos.
- Covers grandes (ficha, blog show): `<picture>` con source WebP + dimensiones
  (`width/height` o `aspect-ratio`) — la ficha hoy no reserva espacio y es un CLS
  latente.
- **Verifica:** Lighthouse en `/fermentos`, `/blog`, ficha y entrada; CLS sigue
  en 0 en las cuatro.

### Fase 6 — defer y el hueco de analítica · impacto BAJO, riesgo BAJO
*(El versionado que vivía aquí subió a la fase 1.)*
- `defer` en site.js (mantiene el orden con los page-JS que ya van `defer`) y
  fuera el stub muerto de `data-subscribe`.
- `@stack('analytics')` al final del `<head>` con comentario de contrato: *solo
  scripts `async`/`defer`, jamás CSS o JS síncrono* — el sitio obvio que pedía el
  briefing, ya inmune a la ruta crítica.
- **Verifica:** `curl -sI` de un CSS, un JS y un woff2 con los criterios §7;
  Lighthouse final de las 7 páginas.

### Proyección
El reparto honesto de méritos: **la serialización (los cinco niveles) solo la
matan F2 y F3** — F1 da caché de repetición, concurrencia h2 y el suelo para
poder tocar los assets sin clavar versiones viejas; F1 bis rescata el LCP de
`/fermentos`. Esperar de F1+F1 bis una mejora moderada en primera visita (y
grande en `/fermentos`); el salto del retraso de renderizado de ~3,9 s a <1 s
llega con F2-F3 (un CSS bloqueante local + fuentes en swap). Tras F4, el hero
de la home llega con prioridad alta y sin competir con 161 KB de PNG.
Estimación para la home en las condiciones de partida: **FCP ~1,2-1,6 s ·
LCP ~1,8-2,4 s · TBT 0 · CLS 0**. La métrica de seguimiento de la rama es
**`/fermentos`** (la peor página, criterio «ninguna <85»), no la home. Se
valida re-midiendo tras F1, F3 y F6.

---

## 5. Restricciones respetadas
- Solo Apache: todo el plan de caché es `.htaccess` + vhost; sin CDN ni Varnish.
- `deploy.sh` intacto: los artefactos (base.css, woff2, webp de landing) van en
  git como ya va `public/build/`; el backfill de storage es un artisan puntual.
- El reciclado de PHP no interactúa con nada de esto (no se tocan vistas
  compiladas fuera del flujo normal).
- TBT/CLS: no se añade ni un byte de JS síncrono; se añaden dimensiones donde
  faltaban. Turnstile pasa de async-siempre a async-cuando-se-ve.
- Accesibilidad/SEO: no se toca copy, ni estructura de encabezados, ni hreflang.

---

## 6. Deuda encontrada (se anota, NO se toca en esta rama)

1. **`public/landing/README.md` describe otro producto**: el bundle estático con
   `index.html`/`premium.html` y toggle de idioma en cliente. Nada de eso existe ya.
2. **Stub muerto en site.js**: `form[data-subscribe]` — ninguna vista lo usa (la
   fase 6 lo retira de paso; lo listo porque es síntoma de la herencia estática).
3. **`resources/scss/erp/_landing.scss`** compilado dentro del `app.css` del ERP:
   estilos de la landing vieja (`.landing-hero`) que la web Blade no usa. Peso
   muerto en el bundle del ERP.
4. **`public/landing/img/app-completar.png`** (48 KB): sin una sola referencia.
5. **Google Fonts por red también en la PWA** (`layouts/mobile.blade.php`) **y en el
   `app.css` del ERP**: mismo arreglo de autoalojado aplicable fuera de esta rama.
6. **Dos fuentes de verdad de tokens de diseño**: `public/landing/tokens/*.css` y
   `resources/scss/erp/_tokens.scss`. Divergirán si nadie las casa.
7. **`npm run build` roto** (vite 8 vs laravel-vite-plugin 3 / sass): el ERP se
   compila con workaround y se commitea. Conocido desde junio; sigue sin dueño.
8. **Versionado `?v=` a tres velocidades**: components.css y los assets del
   afinador lo llevan, el resto no (la fase 6 lo unifica — listado aquí porque la
   inconsistencia es la deuda, no el helper).
9. **El patrón `background-image` inline con `cover_image_url` está también en el
   panel y el ERP** (`dashboard`, `batches/create`, `recetas/*`, admin del blog):
   fuera del alcance de esta rama, pero cuando la fase 5 cree el hábito de
   `thumbnail_url` + `<img>`, esas vistas son las siguientes candidatas.

---

## 7. Registro de aprobación

**Aprobado el 8-ago-2026 por el propietario, con tres cambios** (ya integrados
en el plan de arriba):

1. El versionado `?v=` sube de F6 a F1 — `Cache-Control` largo sin busting
   universal habría clavado assets viejos entre fases, sin CDN con el que purgar.
   Mientras los `@import` de tokens sigan vivos (hasta F3), CSS/JS van con TTL
   de 1 hora.
2. El cambio de `cover_image_url` → `thumbnail_url` en `tarjeta-fermento` se
   adelanta como **fase 1 bis** (una línea); el resto del trabajo de imágenes
   sigue en F5.
3. HTTP/2 se ejecuta con verificación previa del MPM y rollback anotado —
   riesgo bajo, no nulo — y la proyección no le atribuye lo que solo da F3
   (arregla concurrencia, no serialización).

Menores: F2 solo con las variantes usadas (mirar variable fonts), preload de
1-2 caras máximo; el Turnstile perezoso de F4 es opcional y con red de
seguridad. La trampa del build de Vite queda escrita en el §3.5.

Métrica de seguimiento al cierre de cada fase: **Lighthouse de `/fermentos`**.

Datos del servidor confirmados por el propietario (8-ago): `mod_expires` ya
está habilitado en el Apache de producción — el bloque del `.htaccess` surte
efecto sin tocar módulos; la verificación del Anexo A queda como salvaguarda.

---

## Cierre de ejecución (8-ago-2026, mismo día)

Las seis fases más la 1 bis se ejecutaron, desplegaron y midieron el mismo día.
Barrido final (Lighthouse 13.4.1 móvil, misma máquina y red que el barrido de
fase 0 — las *diferencias* son comparables):

| Página | Fase 0 | Final | FCP | LCP | TBT | CLS |
|---|---|---|---|---|---|---|
| Home | 68 | **96** | 1,8 s | 2,5 s | 0 | 0,004 |
| `/fermentos` | 61 | **96** | 1,4 s | 2,7 s | 0 | 0 |
| Ficha kombucha | 80 | **97** | 1,4 s | 2,4 s | 0 | 0 |
| `/blog` | 70 | **91** | 1,4 s | 3,4 s | 0 | 0 |
| Entrada blog | 73 | **95** | 1,4 s | 2,9 s | 0 | 0 |
| `/pro` | 80 | **95** | 1,4 s | 2,3 s | 0 | 0 |
| Afinador | 80 | **93**¹ | 1,8 s | 2,3 s | 0 | 0,130¹ |

¹ El CLS del afinador lo causó un descubrimiento de la propia mejora: el lockup
SVG del nav/footer iba sin dimensiones y, con la web ya rápida, llegaba DESPUÉS
del primer pintado — antes llegaba a tiempo porque todo era lento. Hotfix
`c2e5aa8` (width/height explícitos); re-medir tras su deploy.

**Criterios del §7 del briefing:** todas las páginas ≥85 ✓ (peor: /blog 91) ·
home ≥90 ✓ · TBT 0 mantenido ✓ · CLS ~0 mantenido ✓ (con el hotfix del lockup) ·
`curl -sI` devuelve `content-encoding: gzip` y `cache-control` coherente
(1 año immutable con `?v=`, 30 d imágenes) ✓ · deploy de una pasada intacto ✓ ·
regresión visual: capturas antes/después idénticas en home, ficha, /fermentos y
/blog ✓. La suite además quedó en verde total (285 tests) — los fallos
«preexistentes» eran el manifiesto de paquetes local sin regenerar.

**Queda pendiente UNA cosa del plan: HTTP/2** (Anexo A) — producción sigue en
HTTP/1.1. Es config del vhost, cinco minutos, y es lo que puede bajar el LCP de
2,5-2,7 al objetivo estricto de <2,5 en todas.

Nota operativa nueva: tras cada subida de imagen el `.webp` sale solo
(ImageStore); `imagenes:webp` solo hace falta si se importa a mano al disco.
Si se toca cualquier fuente del CSS común: `npm run landing:css` y commit.

---

## Anexo A — F1 en el servidor (una vez, a mano)

```bash
# 0) Estado actual — confirmar antes de tocar nada
apache2ctl -M | grep -Ei 'mpm|http2|expires|headers|deflate|brotli'
#    Se espera: mpm_event (PHP va por FPM). Si saliera mpm_prefork, PARAR:
#    mod_http2 no funciona con prefork y el cambio de MPM es otra conversación.
#    mod_expires: confirmado habilitado (propietario, 8-ago).

# 1) HTTP/2 (solo si el MPM es event o worker)
sudo a2enmod http2
#    En el vhost TLS de fermenty.es (p. ej. /etc/apache2/sites-available/*-le-ssl.conf
#    o el que corresponda), dentro del <VirtualHost *:443>:
#      Protocols h2 http/1.1
sudo apache2ctl configtest && sudo systemctl reload apache2

# 2) Verificar
curl -s -o /dev/null -w '%{http_version}\n' https://fermenty.es/   # → 2

# 3) Rollback si algo se tuerce
#    quitar la línea Protocols del vhost
sudo a2dismod http2 && sudo systemctl reload apache2

# 4) Brotli (opcional, si el paquete está a mano)
sudo apt install brotli && sudo a2enmod brotli && sudo systemctl reload apache2
#    Sin AddOutputFilterByType extra: con el default de Ubuntu ya negocia br
#    para text/*; verificar con curl -H 'Accept-Encoding: br' -sI <css>.
```

El bloque de `mod_expires`/`mod_headers` NO va aquí: viaja en git dentro de
`public/.htaccess` (con guardas `<IfModule>`) y llega solo con el deploy.
