Files
AgendaPro/visual-audit.mjs
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

201 lines
8.7 KiB
JavaScript

import { chromium } from "playwright";
import fs from "node:fs";
const BASE = "http://localhost:5173";
const OUT = "screenshots";
fs.mkdirSync(OUT, { recursive: true });
const VIEWPORTS = [
{ name: "mobile", width: 375, height: 720 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 850 },
{ name: "wide", width: 1536, height: 900 },
];
const PAGES = [
// `/` ahora sirve la landing pública; el login vive en `/login`.
{ name: "landing", path: "/", login: false },
{ name: "login", path: "/login", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
{ name: "calendar-week", path: "/calendar", login: true, role: "owner", waitFor: ".fc" },
{ name: "employees", path: "/employees", login: true, role: "owner" },
{ name: "services", path: "/services", login: true, role: "owner" },
{ name: "clients", path: "/clients", login: true, role: "owner" },
{ name: "tickets", path: "/tickets", login: true, role: "owner" },
{ name: "cash", path: "/cash", login: true, role: "owner" },
{ name: "notifications", path: "/notifications", login: true, role: "owner" },
{ name: "settings", path: "/settings", login: true, role: "owner" },
{ name: "emp-calendar", path: "/calendar", login: true, role: "employee", waitFor: ".fc" },
{ name: "admin-overview", path: "/admin", login: true, role: "admin" },
{ name: "admin-businesses", path: "/admin/businesses", login: true, role: "admin" },
// El slug del negocio demo es "negocio" (businesses.slug); con el slug anterior
// la API devolvía 404 y esta página se auditaba en su estado de error.
{ name: "public-booking", path: "/b/negocio", login: false },
];
const results = [];
const consoleErrors = [];
const pageErrors = [];
async function login(browser, viewport, role) {
const page = await browser.newPage({ viewport });
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push({ where: "login", text: m.text() });
});
page.on("pageerror", (e) => pageErrors.push({ where: "login", text: e.message }));
await page.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
const email =
role === "admin"
? "[email protected]"
: role === "owner"
? "[email protected]"
: "[email protected]";
// Fill the login form
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: 8000 });
await page.waitForTimeout(8000); // let queries resolve
// Save storage state
const state = await page.context().storageState();
await page.close();
return state;
}
async function measureOverflow(page) {
return page.evaluate(() => {
const docWidth = document.documentElement.clientWidth;
const bodyWidth = document.body.scrollWidth;
const htmlWidth = document.documentElement.scrollWidth;
const overflow = Math.max(bodyWidth, htmlWidth) - docWidth;
// find offending elements
const offenders = [];
document.querySelectorAll("*").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.right > docWidth + 2 && r.width > 50) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || "").toString().slice(0, 60),
right: Math.round(r.right),
width: Math.round(r.width),
});
}
});
return { docWidth, scrollWidth: Math.max(bodyWidth, htmlWidth), overflow, offenders: offenders.slice(0, 5) };
});
}
async function run() {
const browser = await chromium.launch();
// Pre-login for owner, employee and admin to grab storage state
const ownerState = await login(browser, VIEWPORTS[2], "owner");
const empState = await login(browser, VIEWPORTS[2], "employee");
const adminState = await login(browser, VIEWPORTS[2], "admin");
for (const vp of VIEWPORTS) {
for (const p of PAGES) {
const tag = `${vp.name}/${p.name}`;
const state = p.role === "employee" ? empState : p.role === "admin" ? adminState : ownerState;
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: state });
const page = await context.newPage();
const localErrors = [];
page.on("console", (m) => {
if (m.type() === "error") {
localErrors.push(m.text());
consoleErrors.push({ where: tag, text: m.text() });
}
});
page.on("pageerror", (e) => {
localErrors.push("PAGEERROR: " + e.message);
pageErrors.push({ where: tag, text: e.message });
});
try {
await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 20000 });
if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 15000 });
await page.waitForTimeout(2500);
const overflow = await measureOverflow(page);
const file = `${OUT}/${p.name}--${vp.width}.png`;
await page.screenshot({ path: file, fullPage: true });
// also a viewport-only shot for above-the-fold view
await page.screenshot({ path: `${OUT}/${p.name}--${vp.width}-fold.png` });
results.push({ tag, vp: vp.width, status: "ok", overflow: overflow.overflow, offenders: overflow.offenders, errors: localErrors });
console.log(`✓ ${tag} (overflow=${overflow.overflow}px${overflow.offenders.length ? ", " + overflow.offenders.length + " offenders" : ""})`);
} catch (e) {
results.push({ tag, vp: vp.width, status: "fail", error: e.message, errors: localErrors });
console.log(`✗ ${tag}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
}
// Interaction test: open appointment modal on mobile + desktop
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: ownerState });
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(2000);
// open mobile menu on small screens (screenshot only, then reset)
if (vp.width < 1024) {
const menuBtn = await page.locator('button:has(svg.lucide-menu)').first();
if (await menuBtn.isVisible()) {
await menuBtn.click();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/calendar-mobile-menu--${vp.width}.png` });
// reset state by reloading
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(1500);
}
}
// Click "Nueva cita"
const newBtn = page.getByRole("button", { name: /Nueva cita/ }).first();
if (await newBtn.isVisible()) {
await newBtn.click();
await page.waitForTimeout(1500);
await page.screenshot({ path: `${OUT}/appointment-modal--${vp.width}.png` });
// check modal scroll
const modalOverflow = await page.evaluate(() => {
const modal = document.querySelector('[class*="fixed inset-0"] [class*="overflow-y-auto"]');
return modal ? { scrollH: modal.scrollHeight, clientH: modal.clientHeight } : null;
});
results.push({ tag: `interaction/modal@${vp.width}`, status: "ok", modalOverflow });
}
} catch (e) {
console.log(`✗ interaction@${vp.width}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
await browser.close();
fs.writeFileSync("screenshots/report.json", JSON.stringify({ results, consoleErrors, pageErrors }, null, 2));
// Summary
const failed = results.filter((r) => r.status === "fail");
const overflowIssues = results.filter((r) => r.overflow > 4);
const totalErrors = consoleErrors.length + pageErrors.length;
console.log("\n========== SUMMARY ==========");
console.log(`Screens/visits: ${results.length}`);
console.log(`Failed loads: ${failed.length}`);
console.log(`Horizontal overflow issues (>4px): ${overflowIssues.length}`);
console.log(` ${overflowIssues.map((o) => `${o.tag}(+${o.overflow}px)`).join(", ") || " none"}`);
console.log(`Console/page errors: ${totalErrors}`);
if (totalErrors) {
const uniq = [...new Set([...consoleErrors.map((e) => e.text), ...pageErrors.map((e) => e.text)])];
uniq.slice(0, 10).forEach((e) => console.log(` • ${e.slice(0, 180)}`));
}
console.log(`\nReport → screenshots/report.json`);
console.log(`Screenshots → screenshots/*.png`);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});