SPB Git

spb/ka-ui

Public

tokens.css : overflow-x clip aussi sur html (quirk WebKit iOS) ; sync.sh : chemin trouve-ka corrigé (apps/web/ka) ; audits : mesure du défilement latéral réel (clip), zone tactile effective (::after), ciblage du vrai bouton menu, compensation via wrapper/#root et bandeaux témoins exclus

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 19, 2026) parent 56447b8 Browse files

4 changed files +60 −20

modified mobile-audit.mjs +45 −16
@@ -38,11 +38,19 @@ const PAGE_AUDIT = `(() => {
38 const mv = document.querySelector('meta[name="viewport"]'); 38 const mv = document.querySelector('meta[name="viewport"]');
39 res.viewport = mv ? mv.getAttribute("content") : null; 39 res.viewport = mv ? mv.getAttribute("content") : null;
40 40
41− // débordement horizontal + fautifs 41+ // débordement horizontal RÉEL (scrollWidth peut mentir quand overflow-x:clip
42+ // est posé — on vérifie que le viewport défile vraiment)
42 const iw = window.innerWidth; 43 const iw = window.innerWidth;
43− res.overflowPx = Math.max( 44+ const rawOverflow = Math.max(
44 document.documentElement.scrollWidth - iw, 45 document.documentElement.scrollWidth - iw,
45 document.body ? document.body.scrollWidth - iw : 0); 46 document.body ? document.body.scrollWidth - iw : 0);
47+ res.overflowPx = 0;
48+ if (rawOverflow > 1) {
49+ const x0 = window.scrollX;
50+ window.scrollBy(rawOverflow, 0);
51+ if (window.scrollX - x0 > 1) res.overflowPx = rawOverflow;
52+ window.scrollTo(x0, 0);
53+ }
46 res.overflowEls = []; 54 res.overflowEls = [];
47 if (res.overflowPx > 1) { 55 if (res.overflowPx > 1) {
48 for (const el of document.querySelectorAll("body *")) { 56 for (const el of document.querySelectorAll("body *")) {
@@ -105,7 +113,11 @@ const PAGE_AUDIT = `(() => {
105 const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; 113 const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0;
106 const main = document.querySelector("main"); 114 const main = document.querySelector("main");
107 const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0; 115 const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0;
108− res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad }; 116+ let wrapPad = 0;
117+ for (const w of document.querySelectorAll("#root, #__next, .app, #app")) {
118+ wrapPad = Math.max(wrapPad, parseFloat(getComputedStyle(w).paddingBottom) || 0);
119+ }
120+ res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad, wrapPadBottom: wrapPad };
109 } 121 }
110 122
111 // champs zoom iOS 123 // champs zoom iOS
@@ -120,13 +132,28 @@ const PAGE_AUDIT = `(() => {
120 if (res.smallInputs.length >= 10) break; 132 if (res.smallInputs.length >= 10) break;
121 } 133 }
122 134
123− // cibles tactiles dans header/nav 135+ // cibles tactiles dans header/nav — en tenant compte des extensions de zone
136+ // par pseudo-élément (::after/::before absolu avec inset négatif)
137+ const hitExt = (el) => {
138+ let ext = 0;
139+ for (const ps of ["::after", "::before"]) {
140+ const cs = getComputedStyle(el, ps);
141+ if (cs.content !== "none" && cs.position === "absolute") {
142+ const t = parseFloat(cs.top), b = parseFloat(cs.bottom);
143+ if (!isNaN(t) && t < 0) ext = Math.max(ext, -t - (isNaN(b) ? 0 : Math.min(b, 0)));
144+ else if (!isNaN(t) && !isNaN(b)) ext = Math.max(ext, Math.max(0, -t) + Math.max(0, -b));
145+ }
146+ }
147+ return ext;
148+ };
124 res.smallTargets = []; 149 res.smallTargets = [];
125 for (const el of document.querySelectorAll("header a, header button, nav a, nav button, [class*=menu] a, [class*=menu] button")) { 150 for (const el of document.querySelectorAll("header a, header button, nav a, nav button, [class*=menu] a, [class*=menu] button")) {
126 const r = el.getBoundingClientRect(); 151 const r = el.getBoundingClientRect();
127 if (r.width === 0 || r.height === 0) continue; 152 if (r.width === 0 || r.height === 0) continue;
128− if ((r.height < 34 || r.width < 34) && r.top >= 0 && r.top < window.innerHeight) { 153+ const ext = hitExt(el);
129− res.smallTargets.push({ sel: sel(el), w: Math.round(r.width), h: Math.round(r.height), text: (el.textContent || "").trim().slice(0, 20) }); 154+ const effH = r.height + 2 * ext, effW = r.width + 2 * ext;
155+ if ((effH < 34 || effW < 34) && r.top >= 0 && r.top < window.innerHeight) {
156+ res.smallTargets.push({ sel: sel(el), w: Math.round(effW), h: Math.round(effH), text: (el.textContent || "").trim().slice(0, 20) });
130 if (res.smallTargets.length >= 10) break; 157 if (res.smallTargets.length >= 10) break;
131 } 158 }
132 } 159 }
@@ -230,8 +257,8 @@ async function auditPage(page, url, tag, shot) {
230 if (d.overflowPx > 1) r.issues.push(`débordement horizontal +${d.overflowPx}px : ${(d.overflowEls || []).map((e) => e.sel).slice(0, 4).join(", ")}`); 257 if (d.overflowPx > 1) r.issues.push(`débordement horizontal +${d.overflowPx}px : ${(d.overflowEls || []).map((e) => e.sel).slice(0, 4).join(", ")}`);
231 for (const f of d.fixed || []) if (f.brokenBy) r.issues.push(`fixed cassé par ancêtre ${f.brokenBy} → [${f.sel}]`); 258 for (const f of d.fixed || []) if (f.brokenBy) r.issues.push(`fixed cassé par ancêtre ${f.brokenBy} → [${f.sel}]`);
232 const bb = d.bottomBar; 259 const bb = d.bottomBar;
233− if (bb && bb.bodyPadBottom < bb.h - 8 && bb.mainPadBottom < bb.h - 8) 260+ 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)
234− 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)`); 261+ 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)`);
235 if (bar && !/env\(|calc\(/.test(bar.cssBottom || "") && r.css.safeArea === 0) 262 if (bar && !/env\(|calc\(/.test(bar.cssBottom || "") && r.css.safeArea === 0)
236 r.issues.push(`aucune trace de safe-area-inset-bottom (barre basse [${bar.sel}])`); 263 r.issues.push(`aucune trace de safe-area-inset-bottom (barre basse [${bar.sel}])`);
237 if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`); 264 if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`);
@@ -239,22 +266,24 @@ async function auditPage(page, url, tag, shot) {
239 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(", ")}`); 266 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(", ")}`);
240 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(", ")}`); 267 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(", ")}`);
241 268
242− // test tap : ouvrir le premier bouton menu/hamburger trouvé 269+ // test tap : ouvrir le VRAI bouton de menu (aria-label/menu-btn/burger en
270+ // priorité — pas n'importe quel bouton du header), vérifier qu'un panneau
271+ // s'ouvre réellement (aria-expanded / classe open / dialog) puis le verrou.
243 try { 272 try {
244− const btn = page.locator('header button, [class*="burger"], [class*="hamburger"], button[aria-label*="menu" i], button[aria-label*="Menu"]').first(); 273+ const btn = page.locator('button[aria-label*="menu" i], .menu-btn, [class*="burger"], [class*="hamburger"], label[for*="menu"]').first();
245 if (await btn.count()) { 274 if (await btn.count()) {
246− const before = await page.evaluate(() => document.body.innerHTML.length);
247 await btn.tap({ timeout: 3000 }).catch(() => btn.click({ timeout: 3000 })); 275 await btn.tap({ timeout: 3000 }).catch(() => btn.click({ timeout: 3000 }));
248 await page.waitForTimeout(600); 276 await page.waitForTimeout(600);
249 const after = await page.evaluate(() => ({ 277 const after = await page.evaluate(() => ({
250− len: document.body.innerHTML.length, 278+ opened: !!document.querySelector('.mobile-menu.open, [role="dialog"], .gk-mobile-overlay, [aria-expanded="true"], .mm-backdrop, input[id*="menu"]:checked'),
251− bodyOverflow: getComputedStyle(document.body).overflow, 279+ bodyOverflow: (document.body.style.overflow || getComputedStyle(document.body).overflow),
252 htmlOverflow: getComputedStyle(document.documentElement).overflow, 280 htmlOverflow: getComputedStyle(document.documentElement).overflow,
281+ htmlLock: document.documentElement.classList.contains("ka-scroll-lock") || document.documentElement.classList.contains("kaa-lock"),
253 })); 282 }));
254− const opened = Math.abs(after.len - before) > 50; 283+ if (after.opened && !after.htmlLock &&
255− if (opened && after.bodyOverflow !== "hidden" && after.htmlOverflow !== "hidden") 284+ !/hidden/.test(after.bodyOverflow) && !/hidden/.test(after.htmlOverflow))
256 r.issues.push("menu ouvert SANS blocage du scroll d'arrière-plan"); 285 r.issues.push("menu ouvert SANS blocage du scroll d'arrière-plan");
257− if (shot) await page.screenshot({ path: shot.replace(".png", "-menu.png") }); 286+ if (shot && after.opened) await page.screenshot({ path: shot.replace(".png", "-menu.png") });
258 await page.keyboard.press("Escape").catch(() => {}); 287 await page.keyboard.press("Escape").catch(() => {});
259 await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {}); 288 await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {});
260 } 289 }
modified regression-mobile.mjs +10 −2
@@ -33,8 +33,16 @@ for (const w of widths) {
33 33
34 const CHECK = `(() => { 34 const CHECK = `(() => {
35 const iw = window.innerWidth; 35 const iw = window.innerWidth;
36− const out = { overflow: Math.max(document.documentElement.scrollWidth - iw, 36+ const raw = Math.max(document.documentElement.scrollWidth - iw,
37− document.body ? document.body.scrollWidth - iw : 0), smallInputs: 0, viewport: null }; 37+ document.body ? document.body.scrollWidth - iw : 0);
38+ let overflow = 0;
39+ if (raw > 1) { // scrollWidth ment quand overflow-x:clip — tester le défilement réel
40+ const x0 = window.scrollX;
41+ window.scrollBy(raw, 0);
42+ if (window.scrollX - x0 > 1) overflow = raw;
43+ window.scrollTo(x0, 0);
44+ }
45+ const out = { overflow, smallInputs: 0, viewport: null };
38 const mv = document.querySelector('meta[name="viewport"]'); 46 const mv = document.querySelector('meta[name="viewport"]');
39 out.viewport = mv ? mv.getAttribute("content") : null; 47 out.viewport = mv ? mv.getAttribute("content") : null;
40 for (const el of document.querySelectorAll("input, select, textarea")) { 48 for (const el of document.querySelectorAll("input, select, textarea")) {
modified sync.sh +1 −1
@@ -20,7 +20,7 @@ immo-ka|M4M64a|~/apps/immo-ka|frontend/src/ka
20 fabri-ka|M4M64a|~/fabri-ka|frontend/src/ka 20 fabri-ka|M4M64a|~/fabri-ka|frontend/src/ka
21 auto-ka|M4M64b|~/auto-ka|frontend/src/ka 21 auto-ka|M4M64b|~/auto-ka|frontend/src/ka
22 food-ka|M4M64b|~/apps/food-ka|frontend/src/ka 22 food-ka|M4M64b|~/apps/food-ka|frontend/src/ka
23−trouve-ka|M2M32|~/trouve-ka|frontend/src/ka 23+trouve-ka|M2M32|~/trouve-ka|apps/web/ka
24 " 24 "
25 25
26 only=("$@") 26 only=("$@")
modified tokens.css +4 −1
@@ -90,7 +90,10 @@
90 90
91 /* ---- Socle ---- */ 91 /* ---- Socle ---- */
92 * { box-sizing: border-box; } 92 * { box-sizing: border-box; }
93−html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } 93+/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul
94+ ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;
95+ clip (≠ hidden) ne casse pas position:sticky. */
96+html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }
94 body { 97 body {
95 margin: 0; 98 margin: 0;
96 background: var(--paper); 99 background: var(--paper);
97 100
Menu

Theme