// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ka-ui/mobile-audit.mjs — audit mobile approfondi des 13 sites Groupe KA. // Usage : cd ~/Desktop/ka-ui && node mobile-audit.mjs [site…] // Pour chaque site (chromium mobile 390×844 + webkit iPhone) : // - meta viewport, débordement horizontal (+ éléments fautifs) // - inventaire des éléments fixed/sticky : ancrage bas, ancêtre transform/filter // (casse le fixed), safe-area, compensation padding, stabilité au scroll // - scan CSS : 100vh sans dvh, env(safe-area-inset-bottom), z-index extrêmes // - menus déroulants : règles :hover-reveal, cibles tactiles <44px, test tap // - champs avec font-size <16px (zoom iOS) // Sortie : audit/report.json + captures audit/*.png + résumé console. import { chromium, webkit, devices } from "playwright"; import { mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const here = dirname(fileURLToPath(import.meta.url)); const eco = JSON.parse(readFileSync(join(here, "ecosystem.json"), "utf8")); const outDir = join(here, "audit"); mkdirSync(outDir, { recursive: true }); const only = process.argv.slice(2); const sites = eco.sites.filter((s) => !only.length || only.includes(s.id)); // ---------- code injecté dans la page ---------- const PAGE_AUDIT = `(() => { const res = {}; const sel = (el) => { if (!el || el === document.documentElement) return "html"; let s = el.tagName.toLowerCase(); if (el.id) return s + "#" + el.id; const c = (el.className && typeof el.className === "string") ? el.className.trim().split(/\\s+/).slice(0, 3).join(".") : ""; return c ? s + "." + c : s; }; // meta viewport const mv = document.querySelector('meta[name="viewport"]'); res.viewport = mv ? mv.getAttribute("content") : null; // débordement horizontal RÉEL (scrollWidth peut mentir quand overflow-x:clip // est posé — on vérifie que le viewport défile vraiment) const iw = window.innerWidth; const rawOverflow = Math.max( document.documentElement.scrollWidth - iw, document.body ? document.body.scrollWidth - iw : 0); res.overflowPx = 0; if (rawOverflow > 1) { const x0 = window.scrollX; window.scrollBy(rawOverflow, 0); if (window.scrollX - x0 > 1) res.overflowPx = rawOverflow; window.scrollTo(x0, 0); } res.overflowEls = []; if (res.overflowPx > 1) { for (const el of document.querySelectorAll("body *")) { const r = el.getBoundingClientRect(); if (r.width > 1 && (r.right > iw + 1 || r.left < -1) && r.height > 4) { const cs = getComputedStyle(el); if (cs.position === "fixed" && r.left >= -1 && r.right <= iw + 1) continue; res.overflowEls.push({ sel: sel(el), left: Math.round(r.left), right: Math.round(r.right), w: Math.round(r.width) }); if (res.overflowEls.length >= 12) break; } } } // fixed / sticky const breaksFixed = (el) => { let p = el.parentElement; while (p && p !== document.documentElement) { const cs = getComputedStyle(p); if (cs.transform !== "none" || cs.filter !== "none" || cs.backdropFilter && cs.backdropFilter !== "none" || cs.perspective !== "none" || (cs.willChange && /transform|filter|perspective/.test(cs.willChange)) || (cs.contain && /layout|paint|strict|content/.test(cs.contain))) return sel(p) + " (" + [ cs.transform !== "none" ? "transform" : "", cs.filter !== "none" ? "filter" : "", cs.backdropFilter && cs.backdropFilter !== "none" ? "backdrop-filter" : "", cs.perspective !== "none" ? "perspective" : "", cs.willChange && /transform|filter/.test(cs.willChange) ? "will-change" : "", ].filter(Boolean).join(",") + ")"; p = p.parentElement; } return null; }; res.fixed = []; for (const el of document.querySelectorAll("body *")) { const cs = getComputedStyle(el); if (cs.position !== "fixed" && cs.position !== "sticky") continue; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8 || cs.display === "none") continue; const vh = window.innerHeight; /* une « barre » basse = pleine largeur (≥60 % du viewport) ; un bouton flottant (FAB, bulle KA Agent) n'exige pas de compensation de contenu */ const bottomAnchored = cs.position === "fixed" && r.bottom > vh - 120 && r.top > vh * 0.4 && r.width >= window.innerWidth * 0.6; res.fixed.push({ sel: sel(el), pos: cs.position, rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) }, bottomAnchored, z: cs.zIndex, brokenBy: cs.position === "fixed" ? breaksFixed(el) : null, padBottom: cs.paddingBottom, cssBottom: cs.bottom, }); if (res.fixed.length >= 25) break; } // compensation : padding-bottom du body/main vs barre basse const bar = res.fixed.find((f) => f.bottomAnchored); if (bar) { const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; const main = document.querySelector("main"); const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0; let wrapPad = 0; /* la compensation peut aussi être portée par le footer (pattern tab bar : le dernier bloc dégage la barre, le contenu défile dessous) */ for (const w of document.querySelectorAll("#root, #__next, .app, #app, footer, .ka-footer")) { wrapPad = Math.max(wrapPad, parseFloat(getComputedStyle(w).paddingBottom) || 0); } res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad, wrapPadBottom: wrapPad }; } // champs zoom iOS res.smallInputs = []; for (const el of document.querySelectorAll("input, select, textarea")) { const cs = getComputedStyle(el); if (el.type === "hidden" || el.type === "checkbox" || el.type === "radio" || cs.display === "none") continue; const rr = el.getBoundingClientRect(); if (rr.width < 4 || rr.height < 4) continue; /* sr-only / clip */ const fs = parseFloat(cs.fontSize); if (fs && fs < 16) res.smallInputs.push({ sel: sel(el), fs }); if (res.smallInputs.length >= 10) break; } // cibles tactiles dans header/nav — en tenant compte des extensions de zone // par pseudo-élément (::after/::before absolu avec inset négatif) const hitExt = (el) => { let ext = 0; for (const ps of ["::after", "::before"]) { const cs = getComputedStyle(el, ps); if (cs.content !== "none" && cs.position === "absolute") { const t = parseFloat(cs.top), b = parseFloat(cs.bottom); if (!isNaN(t) && t < 0) ext = Math.max(ext, -t - (isNaN(b) ? 0 : Math.min(b, 0))); else if (!isNaN(t) && !isNaN(b)) ext = Math.max(ext, Math.max(0, -t) + Math.max(0, -b)); } } return ext; }; res.smallTargets = []; for (const el of document.querySelectorAll("header a, header button, nav a, nav button, [class*=menu] a, [class*=menu] button")) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; const ext = hitExt(el); const effH = r.height + 2 * ext, effW = r.width + 2 * ext; if ((effH < 34 || effW < 34) && r.top >= 0 && r.top < window.innerHeight) { res.smallTargets.push({ sel: sel(el), w: Math.round(effW), h: Math.round(effH), text: (el.textContent || "").trim().slice(0, 20) }); if (res.smallTargets.length >= 10) break; } } // candidats menus déroulants res.dropdownCandidates = []; for (const el of document.querySelectorAll('[aria-haspopup], [class*="dropdown"], [class*="Dropdown"], details, [data-menu], [class*="select"]:not(select)')) { const r = el.getBoundingClientRect(); if (r.width === 0) continue; res.dropdownCandidates.push({ sel: sel(el), tag: el.tagName.toLowerCase() }); if (res.dropdownCandidates.length >= 15) break; } // feuilles de style (mêmes origines) pour scan texte res.styleSheets = [...document.styleSheets].map((ss) => ss.href).filter(Boolean); res.inlineCss = [...document.querySelectorAll("style")].map((s) => s.textContent || "").join("\\n"); return res; })()`; // détection des règles :hover qui révèlent un sous-menu function scanCss(text) { const out = { vh100: 0, dvh: 0, svh: 0, safeArea: 0, hoverReveal: [], zMax: 0 }; if (!text) return out; out.vh100 = (text.match(/100vh/g) || []).length; out.dvh = (text.match(/dvh/g) || []).length; out.svh = (text.match(/svh/g) || []).length; out.safeArea = (text.match(/safe-area-inset-bottom/g) || []).length; const rules = text.match(/[^{}]+\{[^{}]*\}/g) || []; for (const rule of rules) { const [selPart, body] = rule.split("{"); if (!selPart || !body) continue; if (/:hover/.test(selPart) && /(display\s*:\s*(block|flex|grid)|visibility\s*:\s*visible|opacity\s*:\s*1|pointer-events\s*:\s*auto)/.test(body) && /(menu|dropdown|nav|sub)/i.test(selPart)) { out.hoverReveal.push(selPart.trim().replace(/\s+/g, " ").slice(0, 90)); if (out.hoverReveal.length >= 8) break; } const zm = body.match(/z-index\s*:\s*(\d+)/g) || []; for (const z of zm) out.zMax = Math.max(out.zMax, parseInt(z.replace(/\D+/g, ""), 10) || 0); } return out; } async function auditPage(page, url, tag, shot) { const r = { url, tag, issues: [], data: null }; const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 }); if (!resp || resp.status() >= 400) { r.issues.push(`HTTP ${resp ? resp.status() : "??"}`); return r; } await page.waitForLoadState("networkidle", { timeout: 20000 }).catch(() => {}); await page.waitForTimeout(1500); const d = await page.evaluate(PAGE_AUDIT); r.data = d; // scan CSS (inline + feuilles même origine) let cssText = d.inlineCss || ""; const origin = new URL(url).origin; for (const href of d.styleSheets.slice(0, 8)) { try { if (!href.startsWith(origin) && !href.startsWith("/")) continue; const t = await page.evaluate(async (h) => { try { const x = await fetch(h); return await x.text(); } catch { return ""; } }, href); cssText += "\n" + t; } catch {} } r.css = scanCss(cssText); delete d.inlineCss; // stabilité au scroll de la barre basse const bar = (d.fixed || []).find((f) => f.bottomAnchored); if (bar) { const track = await page.evaluate(async (barSel) => { const find = () => { for (const el of document.querySelectorAll("body *")) { const cs = getComputedStyle(el); if (cs.position !== "fixed") continue; const r = el.getBoundingClientRect(); if (r.bottom > innerHeight - 120 && r.top > innerHeight * 0.4 && r.height >= 8) return el; } return null; }; const el = find(); if (!el) return null; const pos = []; for (let i = 0; i < 4; i++) { window.scrollBy(0, 600); await new Promise((r2) => setTimeout(r2, 350)); const r = el.getBoundingClientRect(); pos.push({ b: Math.round(innerHeight - r.bottom), visible: r.height > 0 && getComputedStyle(el).display !== "none" }); } window.scrollTo(0, 0); return pos; }, bar.sel).catch(() => null); if (track) { const bottoms = track.map((p) => p.b); const drift = Math.max(...bottoms) - Math.min(...bottoms); if (drift > 2) r.issues.push(`barre basse instable au scroll (dérive ${drift}px) [${bar.sel}]`); if (track.some((p) => !p.visible)) r.issues.push(`barre basse disparaît au scroll [${bar.sel}]`); } } // signalements if (!d.viewport || !/width=device-width/.test(d.viewport)) r.issues.push(`meta viewport: ${d.viewport || "ABSENTE"}`); if (d.overflowPx > 1) r.issues.push(`débordement horizontal +${d.overflowPx}px : ${(d.overflowEls || []).map((e) => e.sel).slice(0, 4).join(", ")}`); for (const f of d.fixed || []) if (f.brokenBy) r.issues.push(`fixed cassé par ancêtre ${f.brokenBy} → [${f.sel}]`); const bb = d.bottomBar; if (bb && !/cookie|consent|banner/i.test(bb.sel) && bb.bodyPadBottom < bb.h - 8 && bb.mainPadBottom < bb.h - 8 && (bb.wrapPadBottom ?? 0) < bb.h - 8) r.issues.push(`pas de compensation padding sous la barre basse [${bb.sel}] h=${bb.h}px (body ${bb.bodyPadBottom}px / main ${bb.mainPadBottom}px / wrapper ${bb.wrapPadBottom ?? 0}px)`); if (bar && !/env\(|calc\(/.test(bar.cssBottom || "") && r.css.safeArea === 0) r.issues.push(`aucune trace de safe-area-inset-bottom (barre basse [${bar.sel}])`); if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`); if (r.css.hoverReveal.length) r.issues.push(`menus :hover-reveal CSS : ${r.css.hoverReveal.slice(0, 3).join(" | ")}`); if (d.smallInputs.length) r.issues.push(`${d.smallInputs.length} champ(s) <16px (zoom iOS) : ${d.smallInputs.slice(0, 3).map((i) => i.sel).join(", ")}`); if (d.smallTargets.length) r.issues.push(`${d.smallTargets.length} cible(s) tactile(s) <34px : ${d.smallTargets.slice(0, 3).map((t) => t.sel + "(" + t.w + "×" + t.h + ")").join(", ")}`); // test tap : ouvrir le VRAI bouton de menu (aria-label/menu-btn/burger en // priorité — pas n'importe quel bouton du header), vérifier qu'un panneau // s'ouvre réellement (aria-expanded / classe open / dialog) puis le verrou. try { const btn = page.locator('button[aria-label*="menu" i], .menu-btn, [class*="burger"], [class*="hamburger"], label[for*="menu"]').first(); if (await btn.count()) { await btn.tap({ timeout: 3000 }).catch(() => btn.click({ timeout: 3000 })); await page.waitForTimeout(600); const after = await page.evaluate(() => ({ opened: !!document.querySelector('.mobile-menu.open, [role="dialog"], .gk-mobile-overlay, [aria-expanded="true"], .mm-backdrop, input[id*="menu"]:checked'), bodyOverflow: (document.body.style.overflow || getComputedStyle(document.body).overflow), htmlOverflow: getComputedStyle(document.documentElement).overflow, htmlLock: document.documentElement.classList.contains("ka-scroll-lock") || document.documentElement.classList.contains("kaa-lock"), })); if (after.opened && !after.htmlLock && !/hidden/.test(after.bodyOverflow) && !/hidden/.test(after.htmlOverflow)) r.issues.push("menu ouvert SANS blocage du scroll d'arrière-plan"); if (shot && after.opened) await page.screenshot({ path: shot.replace(".png", "-menu.png") }); await page.keyboard.press("Escape").catch(() => {}); await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {}); } } catch {} if (shot) { await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(300); await page.screenshot({ path: shot }); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(500); await page.screenshot({ path: shot.replace(".png", "-bas.png") }); } return r; } const engines = [ { name: "android", launcher: chromium, ctx: { ...devices["Pixel 7"] } }, { name: "ios", launcher: webkit, ctx: { ...devices["iPhone 13"] } }, ]; const report = []; for (const engine of engines) { const browser = await engine.launcher.launch(); for (const site of sites) { const ctx = await browser.newContext(engine.ctx); const page = await ctx.newPage(); const siteRep = { site: site.id, engine: engine.name, pages: [] }; try { const home = `https://${site.domain}/`; const shot = join(outDir, `${site.id}-${engine.name}.png`); siteRep.pages.push(await auditPage(page, home, "accueil", shot)); // pages internes : 2 liens de nav distincts (chromium seulement pour limiter le temps) if (engine.name === "android") { const links = await page.evaluate(() => { const seen = new Set(); const out = []; for (const a of document.querySelectorAll("a[href]")) { const h = a.getAttribute("href"); if (!h || !h.startsWith("/") || h === "/" || h.startsWith("//") || h.startsWith("/#")) continue; const p = h.split("?")[0].split("#")[0]; if (seen.has(p) || /\.(png|jpg|pdf|xml|js)$/.test(p)) continue; seen.add(p); out.push(p); if (out.length >= 6) break; } return out; }).catch(() => []); for (const l of links.slice(0, 2)) { siteRep.pages.push(await auditPage(page, `https://${site.domain}${l}`, l, null) .catch((e) => ({ url: l, issues: ["ERREUR " + String(e).slice(0, 80)] }))); } } } catch (e) { siteRep.pages.push({ url: site.domain, issues: ["ERREUR " + String(e).split("\n")[0].slice(0, 120)] }); } report.push(siteRep); const n = siteRep.pages.reduce((a, p) => a + (p.issues || []).length, 0); console.log(`${n ? "⚠️ " : "✅"} ${engine.name.padEnd(7)} ${site.id.padEnd(10)} ${n} problème(s)`); for (const p of siteRep.pages) for (const i of p.issues || []) console.log(` [${p.tag || p.url}] ${i}`); await ctx.close(); } await browser.close(); } writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2)); console.log(`\nRapport : ${join(outDir, "report.json")}`);