Files
AgendaPro/responsive-audit.mjs
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

655 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// responsive-audit.mjs — auditoría multi-dispositivo (iPhone 16 Pro Max / iPhone 12 / iPad).
//
// Qué mide y por qué:
//
// 1. ZOOM-AL-ENFOCAR (iOS). Mobile Safari hace auto-zoom cuando enfocas un campo
// con font-size < 16px, y NO revierte al desenfocar: la página queda escalada y
// corrida. Ese comportamiento es exclusivo de Mobile Safari y no se reproduce en
// un navegador de escritorio, así que no lo "actuamos": medimos su predictor
// exacto y determinista — el font-size computado de cada campo de formulario.
// < 16px en un viewport táctil = zoom garantizado en el dispositivo real.
//
// 2. OVERFLOW HORIZONTAL. scrollWidth > clientWidth en la raíz: la causa de que la
// página "aparezca desplazada".
//
// 3. ALTURA DE VIEWPORT. 100vh en iOS no descuenta la barra de URL dinámica; el shell
// queda más alto que el área visible. Comparamos la altura del shell contra la
// altura del viewport.
//
// 4. SAFE AREAS. Con viewport-fit=cover el contenido se mete bajo la Dynamic Island y
// el home indicator. Playwright no puede inyectar insets reales, así que se verifica
// de forma estática que el CSS servido declare env(safe-area-inset-*) en los
// elementos de chrome (header/sidebar). Se reporta como check de código, no de píxel.
//
// 5. CALENDARIO. Overflow propio, altura fija vs proporcional al viewport, y que la
// vista elegida corresponda al tamaño de pantalla.
//
// Uso: npm run audit:responsive (requiere `npm run dev` corriendo)
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.RESPONSIVE_BASE_URL || "http://localhost:5173";
const OUT = process.env.RESPONSIVE_OUT || "screenshots/responsive";
const SHOOT = process.env.RESPONSIVE_SCREENSHOTS !== "0";
// CSS px reales de cada dispositivo objetivo.
const DEVICES = [
{ name: "iphone-12", label: "iPhone 12", width: 390, height: 844, dpr: 3, touch: true, tier: "phone" },
{ name: "iphone-16-pro-max", label: "iPhone 16 Pro Max", width: 440, height: 956, dpr: 3, touch: true, tier: "phone" },
{ name: "iphone-16-pro-max-land", label: "iPhone 16 Pro Max (landscape)", width: 956, height: 440, dpr: 3, touch: true, tier: "phone-land" },
{ name: "ipad-portrait", label: "iPad 10.9\" (portrait)", width: 820, height: 1180, dpr: 2, touch: true, tier: "tablet" },
{ name: "ipad-landscape", label: "iPad 10.9\" (landscape)", width: 1180, height: 820, dpr: 2, touch: true, tier: "tablet-land" },
{ name: "ipad-mini", label: "iPad mini (portrait)", width: 744, height: 1133, dpr: 2, touch: true, tier: "tablet" },
// `tier` describe qué vista de calendario corresponde; `sidebar` marca si hay
// barra lateral fija (Tailwind `lg:` = 1024px). A 1024 se cumplen las dos cosas:
// tier tablet (3 días) Y barra lateral, que es lo buscado en iPad Pro portrait.
{ name: "ipad-pro-portrait", label: "iPad Pro 12.9\" (portrait)", width: 1024, height: 1366, dpr: 2, touch: true, tier: "tablet", sidebar: true },
{ name: "ipad-pro-landscape", label: "iPad Pro 12.9\" (landscape)", width: 1366, height: 1024, dpr: 2, touch: true, tier: "desktop", sidebar: true },
{ name: "desktop", label: "Desktop", width: 1440, height: 900, dpr: 1, touch: false, tier: "desktop", sidebar: true },
];
const PAGES = [
// La landing es pública y no lleva sesión; se audita como cualquier otra página.
// `/` dejó de servir el login, que ahora vive en `/login`.
{ name: "landing", path: "/", role: null },
{ name: "login", path: "/login", role: null },
{ name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" },
{ name: "dashboard", path: "/dashboard", role: "owner" },
{ name: "clients", path: "/clients", role: "owner" },
{ name: "settings", path: "/settings", role: "owner" },
{ name: "employees", path: "/employees", role: "owner" },
{ name: "emp-calendar", path: "/calendar", role: "employee", waitFor: ".fc" },
{ name: "booking", path: "/b/negocio", role: null },
];
const MIN_TOUCH_FONT_PX = 16; // umbral de Mobile Safari para NO hacer auto-zoom
const MIN_TAP_TARGET_PX = 40; // guía de Apple: 44pt; 40 tolera padding/borde
const MIN_GUTTER_PX = 12; // margen lateral mínimo del texto en las páginas públicas
const findings = [];
function fail(device, page, kind, detail) {
findings.push({ device, page, kind, detail });
}
/** Campos de formulario cuyo font-size dispararía el auto-zoom de iOS. */
async function measureFormFields(page) {
return page.evaluate((min) => {
const sel = 'input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="hidden"]),select,textarea';
const bad = [];
let total = 0;
for (const el of document.querySelectorAll(sel)) {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") continue;
total++;
const fs = parseFloat(cs.fontSize);
if (fs < min - 0.01) {
bad.push({
tag: el.tagName.toLowerCase(),
type: el.getAttribute("type") || "",
cls: (el.className || "").toString().slice(0, 70),
fontSize: Math.round(fs * 100) / 100,
});
}
}
return { total, bad };
}, MIN_TOUCH_FONT_PX);
}
async function measureOverflow(page) {
return page.evaluate(() => {
const de = document.documentElement;
const docWidth = de.clientWidth;
const scrollWidth = Math.max(de.scrollWidth, document.body.scrollWidth);
const offenders = [];
for (const el of document.querySelectorAll("*")) {
const r = el.getBoundingClientRect();
if (r.width < 40 || r.height < 8) continue;
if (r.right > docWidth + 2) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" && r.width <= docWidth + 2) continue;
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || "").toString().slice(0, 70),
right: Math.round(r.right),
width: Math.round(r.width),
});
}
}
return { docWidth, scrollWidth, overflow: scrollWidth - docWidth, offenders: offenders.slice(0, 6) };
});
}
/** El shell no debe ser más alto que el viewport (síntoma de 100vh en iOS). */
async function measureShellHeight(page) {
return page.evaluate(() => {
const vh = window.innerHeight;
// El defecto que se busca es un shell de ALTURA FIJA más alto que el área
// visible: el clásico 100vh que en iOS no descuenta la barra de URL. Ese shell
// se marca con [data-app-shell] (AppShell y AdminShell).
//
// Una página que scrollea a propósito —la landing, el login, el booking
// público— es legítimamente más alta que el viewport, así que comparar su alto
// contra vh no mide un defecto: mide que existe scroll. Antes se colaba por el
// fallback a body.firstElementChild, que en esas páginas apunta a #root.
const fixed = document.querySelector("[data-app-shell]");
const shell = fixed || document.body.firstElementChild;
if (!shell) return null;
const h = Math.round(shell.getBoundingClientRect().height);
return { viewportHeight: vh, shellHeight: h, delta: h - vh, hasFixedShell: !!fixed };
});
}
/**
* Canaleta lateral del texto en las páginas públicas.
*
* Existe por un fallo concreto: `.safe-x` está fuera de `@layer` en index.css, así
* que gana a las utilidades `px-*` de Tailwind y deja el padding lateral en 0 cuando
* el inset del sistema es 0 — o sea, en todo lo que no sea un iPhone en landscape. El
* texto acaba pegado al borde y ni el check de overflow ni el de zoom lo detectan,
* porque no hay desborde: hay cero margen.
*/
async function measureGutter(page) {
return page.evaluate(() => {
const vw = document.documentElement.clientWidth;
let min = Infinity;
let culpable = null;
for (const el of document.querySelectorAll("h1,h2,h3,p,label,button,a,li")) {
// Solo nodos con texto propio y visibles: los contenedores a sangre (fondos,
// lavados de color) sí pueden y deben tocar el borde.
const propio = [...el.childNodes].some(
(n) => n.nodeType === 3 && n.textContent.trim().length > 1
);
if (!propio) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) < 0.05) continue;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue;
const holgura = Math.min(r.left, vw - r.right);
if (holgura < min) {
min = holgura;
culpable = `${el.tagName.toLowerCase()} "${(el.textContent || "").trim().slice(0, 30)}"`;
}
}
return { min: Number.isFinite(min) ? Math.round(min) : null, culpable };
});
}
async function measureCalendar(page) {
return page.evaluate(() => {
const fc = document.querySelector(".fc");
if (!fc) return null;
const r = fc.getBoundingClientRect();
const scroller = fc.querySelector(".fc-scroller-harness") || fc;
// La clase de vista real es del tipo `fc-timeGridDay-view`; hay que excluir
// `fc-media-screen` y compañía, que también viven en el nodo raíz.
let viewName = "";
for (const el of fc.querySelectorAll("*")) {
const m = /\bfc-([A-Za-z]+)-view\b/.exec(el.className || "");
if (m) { viewName = m[1]; break; }
}
// ¿el calendario desborda su tarjeta contenedora?
const card = fc.closest(".card");
const cardR = card ? card.getBoundingClientRect() : null;
return {
width: Math.round(r.width),
height: Math.round(r.height),
viewportHeight: window.innerHeight,
heightRatio: Math.round((r.height / window.innerHeight) * 100) / 100,
view: viewName,
innerScrollWidth: scroller.scrollWidth,
innerClientWidth: scroller.clientWidth,
cardOverflowX: cardR ? Math.round(r.width - cardR.width) : 0,
};
});
}
/**
* El menú lateral debe ser una única columna vertical. Se comprueba de verdad y
* no por inspección de clases: una regla CSS que cambie el `display` de los
* enlaces a inline-flex los reparte en dos por fila, que es exactamente el fallo
* que se vio en iPad Pro.
*/
async function measureSidebarNav(page) {
return page.evaluate(() => {
// La barra visible en lg+ (la del panel móvil está fuera de flujo).
const aside = [...document.querySelectorAll("aside")].find((a) => {
const r = a.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(a).position !== "absolute";
});
if (!aside) return null;
const nav = aside.querySelector("nav");
if (!nav) return null;
const links = [...nav.querySelectorAll("a,button")].filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
if (links.length < 2) return null;
const rects = links.map((el) => el.getBoundingClientRect());
// Dos entradas comparten fila si sus rangos verticales se solapan.
let sameRowPairs = 0;
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (vOverlap > 4) sameRowPairs++;
}
}
const lefts = new Set(rects.map((r) => Math.round(r.left)));
return {
count: links.length,
sameRowPairs,
distinctLefts: lefts.size,
asideWidth: Math.round(aside.getBoundingClientRect().width),
displays: [...new Set(links.map((el) => getComputedStyle(el).display))],
};
});
}
/**
* Los botones de solo icono se agrandan a 40px en táctil; el icono debe quedar
* centrado en la caja nueva y no pegado a un borde.
*/
async function measureIconCentering(page) {
return page.evaluate(() => {
const bad = [];
for (const btn of document.querySelectorAll("button")) {
if (btn.querySelectorAll(":scope > *").length !== 1) continue;
const svg = btn.querySelector(":scope > svg");
if (!svg) continue;
// Sin texto visible: en un botón con etiqueta ("<Plus/> Nueva cita") el icono
// va a la izquierda por diseño. Contar hijos elemento no basta para
// distinguirlos, porque el label es un nodo de texto y no cuenta.
if ((btn.textContent || "").trim().length > 0) continue;
const br = btn.getBoundingClientRect();
const sr = svg.getBoundingClientRect();
if (br.width === 0 || sr.width === 0) continue;
const dx = sr.left + sr.width / 2 - (br.left + br.width / 2);
const dy = sr.top + sr.height / 2 - (br.top + br.height / 2);
if (Math.abs(dx) > 2.5 || Math.abs(dy) > 2.5) {
bad.push({
label: (btn.getAttribute("aria-label") || btn.getAttribute("title") || "?").slice(0, 24),
dx: Math.round(dx),
dy: Math.round(dy),
});
}
}
return bad.slice(0, 6);
});
}
/** Objetivos táctiles demasiado pequeños en pantallas touch. */
async function measureTapTargets(page) {
return page.evaluate((min) => {
const small = [];
for (const el of document.querySelectorAll('button,a[href],[role="button"],select')) {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || cs.pointerEvents === "none") continue;
const r = el.getBoundingClientRect();
// Descarta lo oculto visualmente (sr-only mide 1px y no se toca).
if (r.width < 8 || r.height < 8) continue;
if (r.height < min) {
small.push({
tag: el.tagName.toLowerCase(),
text: (el.textContent || "").trim().slice(0, 28),
cls: (el.className || "").toString().slice(0, 50),
h: Math.round(r.height),
});
}
}
return small.slice(0, 8);
}, MIN_TAP_TARGET_PX);
}
async function login(browser, role) {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const email =
role === "admin" ? "[email protected]"
: role === "owner" ? "[email protected]"
: "[email protected]";
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "demo1234");
await page.click('button[type="submit"]');
await page.waitForURL(/\/(dashboard|calendar|admin)/, { timeout: 15000 });
await page.waitForTimeout(1500);
const state = await context.storageState();
await context.close();
return state;
}
/** Check estático: el CSS servido debe declarar safe-area en el chrome de la app. */
async function auditSafeAreaCss() {
const css = fs.readFileSync("src/index.css", "utf8");
const shells = ["src/components/AppShell.tsx", "src/components/AdminShell.tsx"];
const out = { insetsInCss: /env\(\s*safe-area-inset-/.test(css), shellUsage: {} };
for (const f of shells) {
const src = fs.readFileSync(f, "utf8");
out.shellUsage[path.basename(f)] = {
top: /\bsafe-top\b/.test(src),
bottom: /\bsafe-area-bottom\b/.test(src),
// La altura dinámica llega vía la clase .h-screen-safe, que resuelve
// 100dvh con fallback a 100vh en index.css.
dvh: /\bh-screen-safe\b/.test(src) && /100dvh/.test(css),
noRawScreen: !/\bh-screen\b(?!-safe)/.test(src),
};
}
const html = fs.readFileSync("index.html", "utf8");
const vp = html.match(/<meta\s+name="viewport"\s+content="([^"]+)"/i);
out.viewport = vp ? vp[1] : null;
out.viewportFitCover = !!vp && /viewport-fit\s*=\s*cover/.test(vp[1]);
// La landing y el login scrollean a propósito, así que el check dinámico de
// altura de shell no aplica ahí (ver measureShellHeight). El defecto real —usar
// `100vh` o `h-screen` crudos en vez de las utilidades `dvh`— se caza en
// estático sobre las fuentes: es determinista y no necesita navegador.
const publicFiles = [
"src/pages/LandingPage.tsx",
"src/pages/LoginPage.tsx",
...fs
.readdirSync("src/components/landing")
.filter((n) => n.endsWith(".tsx"))
.map((n) => `src/components/landing/${n}`),
];
out.rawViewportUnits = [];
for (const f of publicFiles) {
const src = fs.readFileSync(f, "utf8");
// `h-screen`/`min-h-screen` sin el sufijo `-safe`, o un `100vh` literal.
if (/\b(?:min-|max-)?h-screen\b(?!-safe)/.test(src) || /(^|[^d])100vh/.test(src)) {
out.rawViewportUnits.push(path.basename(f));
}
}
return out;
}
async function pickBrowser() {
const pw = await import("playwright");
for (const name of ["webkit", "chromium"]) {
try {
const b = await pw[name].launch();
return { browser: b, engine: name };
} catch {
/* try next */
}
}
throw new Error("No se pudo lanzar ningún navegador de Playwright.");
}
async function run() {
if (SHOOT) fs.mkdirSync(OUT, { recursive: true });
const { browser, engine } = await pickBrowser();
console.log(`Motor: ${engine}${engine === "chromium" ? " (webkit no instalado; layout válido, sin gestos de Safari)" : " (Safari real)"}`);
console.log(`Base: ${BASE}\n`);
const staticAudit = await auditSafeAreaCss();
for (const f of staticAudit.rawViewportUnits) {
fail("estático", "landing", "raw-viewport-unit", `${f} usa h-screen/100vh crudo (usa las utilidades -safe con dvh)`);
}
const states = {
owner: await login(browser, "owner"),
employee: await login(browser, "employee"),
};
const rows = [];
for (const dev of DEVICES) {
for (const p of PAGES) {
const tag = `${dev.name}/${p.name}`;
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: dev.touch,
isMobile: engine === "chromium" ? dev.touch : undefined,
storageState: p.role ? states[p.role] : undefined,
});
const page = await context.newPage();
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text().slice(0, 160)));
page.on("pageerror", (e) => errors.push("PAGEERROR: " + e.message.slice(0, 160)));
try {
await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 25000 });
if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 20000 });
await page.waitForTimeout(1200);
const fields = await measureFormFields(page);
const overflow = await measureOverflow(page);
const shell = await measureShellHeight(page);
const cal = await measureCalendar(page);
const taps = dev.touch ? await measureTapTargets(page) : [];
// Solo en las páginas públicas de marketing: el panel usa contenedores a
// sangre a propósito y ahí la medida daría falsos positivos.
const gutter =
p.name === "landing" || p.name === "login" ? await measureGutter(page) : null;
if (gutter && gutter.min !== null && gutter.min < MIN_GUTTER_PX) {
fail(dev.name, p.name, "gutter", `texto a ${gutter.min}px del borde (${gutter.culpable})`);
}
const nav = p.role ? await measureSidebarNav(page) : null;
const icons = dev.touch ? await measureIconCentering(page) : [];
if (icons.length) {
fail(dev.name, p.name, "icon-center", `${icons.length} iconos descentrados (ej. "${icons[0].label}" dx=${icons[0].dx} dy=${icons[0].dy})`);
}
if (nav && nav.sameRowPairs > 0) {
fail(dev.name, p.name, "nav-columns", `${nav.sameRowPairs} pares de entradas comparten fila (menú en ${nav.distinctLefts} columnas, display=${nav.displays.join("/")})`);
}
if (dev.touch && fields.bad.length) {
fail(dev.name, p.name, "ios-zoom", `${fields.bad.length}/${fields.total} campos < ${MIN_TOUCH_FONT_PX}px (min ${Math.min(...fields.bad.map((b) => b.fontSize))}px)`);
}
if (overflow.overflow > 2) {
const first = overflow.offenders[0];
fail(dev.name, p.name, "overflow-x", `+${overflow.overflow}px; primer culpable: ${first ? `${first.tag}.${first.cls.split(" ")[0]}` : "?"}`);
}
if (shell && shell.hasFixedShell && shell.delta > 2) {
fail(dev.name, p.name, "shell-height", `shell ${shell.shellHeight}px > viewport ${shell.viewportHeight}px (+${shell.delta})`);
}
if (cal && cal.innerScrollWidth - cal.innerClientWidth > 2) {
fail(dev.name, p.name, "calendar-scroll-x", `scroll interno +${cal.innerScrollWidth - cal.innerClientWidth}px`);
}
if (cal && dev.tier !== "desktop" && cal.heightRatio > 1.15) {
fail(dev.name, p.name, "calendar-height", `alto ${cal.height}px = ${cal.heightRatio}× viewport`);
}
// El otro extremo: una grilla aplastada es tan inservible como una que desborda.
if (cal && cal.height < 320) {
fail(dev.name, p.name, "calendar-too-short", `alto ${cal.height}px (< 320px mínimo usable)`);
}
// La vista por defecto debe corresponder al tamaño de pantalla.
if (cal && p.name === "calendar") {
// En landscape un teléfono tiene ancho de tablet (956px en el 16 Pro Max),
// así que la vista Semana sí corresponde: son 7 columnas de ~120px, no las
// columnas aplastadas que se querían evitar en portrait.
// El tier se decide por ancho, que es lo que condiciona el layout: un
// teléfono en landscape (956px) recibe la misma vista que una tablet.
const expected =
dev.tier === "phone" ? ["timeGridDay", "listWeek"]
: dev.tier === "tablet" || dev.tier === "phone-land"
? ["timeGridThreeDay", "timeGridWeek", "timeGridDay", "listWeek"]
: ["timeGridWeek", "timeGridDay", "listWeek", "dayGridMonth"];
if (cal.view && !expected.includes(cal.view)) {
fail(dev.name, p.name, "calendar-view", `vista "${cal.view}" inesperada para ${dev.tier}`);
}
}
if (taps.length) {
fail(dev.name, p.name, "tap-target", `${taps.length} objetivos < ${MIN_TAP_TARGET_PX}px alto (ej. "${taps[0].text || taps[0].tag}" ${taps[0].h}px)`);
}
if (errors.length) fail(dev.name, p.name, "console", errors[0]);
rows.push({ tag, device: dev.label, page: p.name, fields, overflow, shell, cal, nav, taps: taps.length, errors });
const flags = [
dev.touch && fields.bad.length ? `zoom:${fields.bad.length}` : "",
overflow.overflow > 2 ? `overflow:+${overflow.overflow}` : "",
shell && shell.hasFixedShell && shell.delta > 2 ? `alto:+${shell.delta}` : "",
cal ? `cal:${cal.view}@${cal.width}x${cal.height}` : "",
].filter(Boolean).join(" ");
console.log(`${flags.includes("zoom") || flags.includes("overflow") || flags.includes("alto") ? "✗" : "✓"} ${tag.padEnd(38)} ${flags}`);
if (SHOOT) await page.screenshot({ path: `${OUT}/${p.name}--${dev.name}.png` });
} catch (e) {
fail(dev.name, p.name, "load", e.message.split("\n")[0]);
console.log(`✗ ${tag.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 70)}`);
} finally {
await context.close();
}
}
}
// ---- Modal de cita ----
// Es el formulario con más campos (donde el auto-zoom de iOS pegaría más
// fuerte) y un bottom-sheet que debe caber en el área visible sin quedar bajo
// el home indicator. No se cubre en el barrido de páginas porque hay que
// abrirlo interactuando.
for (const dev of DEVICES.filter((d) => d.touch && d.tier !== "phone-land")) {
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: true,
isMobile: engine === "chromium" ? true : undefined,
storageState: states.owner,
});
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(1200);
await page.getByRole("button", { name: /Nueva cita/ }).first().click();
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
await page.waitForTimeout(1000);
const fields = await measureFormFields(page);
const overflow = await measureOverflow(page);
const sheet = await page.evaluate(() => {
const d = document.querySelector('[role="dialog"]');
const r = d.getBoundingClientRect();
return { h: Math.round(r.height), bottom: Math.round(r.bottom), vh: window.innerHeight };
});
if (fields.bad.length) fail(dev.name, "modal", "ios-zoom", `${fields.bad.length} campos < ${MIN_TOUCH_FONT_PX}px`);
if (overflow.overflow > 2) fail(dev.name, "modal", "overflow-x", `+${overflow.overflow}px`);
if (sheet.h > sheet.vh || sheet.bottom > sheet.vh + 1) {
fail(dev.name, "modal", "modal-fit", `sheet ${sheet.h}px / viewport ${sheet.vh}px (bottom ${sheet.bottom})`);
}
console.log(`${fields.bad.length || overflow.overflow > 2 || sheet.h > sheet.vh ? "✗" : "✓"} ${`${dev.name}/modal`.padEnd(38)} campos=${fields.total} sheet=${sheet.h}/${sheet.vh}`);
if (SHOOT) await page.screenshot({ path: `${OUT}/modal--${dev.name}.png` });
} catch (e) {
fail(dev.name, "modal", "load", e.message.split("\n")[0]);
console.log(`✗ ${`${dev.name}/modal`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`);
} finally {
await context.close();
}
}
// ---- Barra lateral colapsable (lg+) ----
// Se comprueba el comportamiento completo: que colapse, que el contenido gane
// el ancho liberado, que el menú siga en una columna en modo mini, que las
// entradas conserven etiqueta accesible al perder el texto, y que la
// preferencia sobreviva a una recarga.
for (const dev of DEVICES.filter((d) => d.sidebar)) {
const context = await browser.newContext({
viewport: { width: dev.width, height: dev.height },
deviceScaleFactor: dev.dpr,
hasTouch: dev.touch,
storageState: states.owner,
});
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(1000);
const metrics = () =>
page.evaluate(() => {
const aside = [...document.querySelectorAll("aside")].find(
(a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute"
);
const main = document.querySelector("main");
return {
aside: aside ? Math.round(aside.getBoundingClientRect().width) : 0,
main: main ? Math.round(main.getBoundingClientRect().width) : 0,
};
});
const before = await metrics();
const btn = page.getByRole("button", { name: /Colapsar menú/ });
if (!(await btn.count())) throw new Error("no se encontró el botón de colapsar");
await btn.first().click();
await page.waitForTimeout(500);
const after = await metrics();
const navMini = await measureSidebarNav(page);
// Al quedarse sin texto, cada entrada debe seguir teniendo nombre accesible.
const labelled = await page.evaluate(() => {
const aside = [...document.querySelectorAll("aside")].find(
(a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute"
);
const links = [...(aside?.querySelectorAll("nav a") ?? [])];
return {
total: links.length,
named: links.filter((a) => (a.getAttribute("title") || a.getAttribute("aria-label") || a.textContent || "").trim().length > 0).length,
};
});
if (SHOOT) await page.screenshot({ path: `${OUT}/sidebar-collapsed--${dev.name}.png` });
// La preferencia debe persistir tras recargar.
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 20000 });
await page.waitForTimeout(800);
const afterReload = await metrics();
const shrank = after.aside < before.aside - 40;
const mainGrew = after.main > before.main + 40;
const oneColumn = !navMini || navMini.sameRowPairs === 0;
const persisted = Math.abs(afterReload.aside - after.aside) <= 2;
const allNamed = labelled.total > 0 && labelled.named === labelled.total;
if (!shrank) fail(dev.name, "sidebar", "collapse", `no se estrechó: ${before.aside}px → ${after.aside}px`);
if (!mainGrew) fail(dev.name, "sidebar", "collapse", `el contenido no ganó ancho: ${before.main}px → ${after.main}px`);
if (!oneColumn) fail(dev.name, "sidebar", "nav-columns", `menú mini en ${navMini.distinctLefts} columnas`);
if (!persisted) fail(dev.name, "sidebar", "collapse", `no persistió tras recargar: ${afterReload.aside}px`);
if (!allNamed) fail(dev.name, "sidebar", "collapse", `${labelled.total - labelled.named} entradas sin nombre accesible en modo mini`);
const ok = shrank && mainGrew && oneColumn && persisted && allNamed;
console.log(`${ok ? "✓" : "✗"} ${`${dev.name}/sidebar`.padEnd(38)} aside ${before.aside}${after.aside} main ${before.main}${after.main} (+${after.main - before.main}) 1col=${oneColumn ? "sí" : "NO"} persiste=${persisted ? "sí" : "NO"}`);
// Se restaura expandida para no contaminar el estado guardado del perfil.
const openBtn = page.getByRole("button", { name: /Expandir menú/ });
if (await openBtn.count()) await openBtn.first().click();
await page.waitForTimeout(200);
} catch (e) {
fail(dev.name, "sidebar", "load", e.message.split("\n")[0]);
console.log(`✗ ${`${dev.name}/sidebar`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`);
} finally {
await context.close();
}
}
await browser.close();
fs.mkdirSync("screenshots", { recursive: true });
fs.writeFileSync("screenshots/responsive-report.json", JSON.stringify({ engine, staticAudit, rows, findings }, null, 2));
console.log("\n=============== RESUMEN ===============");
console.log(`viewport meta: ${staticAudit.viewport}`);
console.log(`env(safe-area-inset-*) en index.css: ${staticAudit.insetsInCss ? "sí" : "NO"}`);
for (const [f, u] of Object.entries(staticAudit.shellUsage)) {
console.log(` ${f}: safe-top=${u.top ? "sí" : "NO"} safe-bottom=${u.bottom ? "sí" : "NO"} dvh=${u.dvh ? "sí" : "NO"} sin-100vh-crudo=${u.noRawScreen ? "sí" : "NO"}`);
}
const byKind = {};
for (const f of findings) byKind[f.kind] = (byKind[f.kind] || 0) + 1;
console.log(`\nHallazgos: ${findings.length}`);
for (const [k, n] of Object.entries(byKind).sort((a, b) => b[1] - a[1])) console.log(` ${k}: ${n}`);
if (findings.length) {
console.log("\nDetalle (primeros 20):");
for (const f of findings.slice(0, 20)) console.log(` • [${f.kind}] ${f.device}/${f.page}${f.detail}`);
}
console.log(`\nReporte → screenshots/responsive-report.json`);
const blocking = findings.filter((f) =>
["ios-zoom", "overflow-x", "shell-height", "raw-viewport-unit", "gutter", "load", "calendar-scroll-x", "calendar-height", "calendar-too-short", "calendar-view", "modal-fit", "nav-columns", "collapse", "icon-center"].includes(f.kind)
);
console.log(blocking.length ? `\n${blocking.length} hallazgos bloqueantes.` : "\nSin hallazgos bloqueantes.");
process.exitCode = blocking.length ? 1 : 0;
}
run().catch((e) => {
console.error(e);
process.exit(1);
});