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]>
655 lines
31 KiB
JavaScript
655 lines
31 KiB
JavaScript
// 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);
|
||
});
|