SPB Git

Repository Public

Commit

Mobile : backdrop du menu compte via createPortal, verrou scroll de la feuille filtres, champs ≥16 px, logo tactile, socle mobile tokens.css, ka-agent corrigé

- frontend/src/App.tsx : .account-backdrop rendu à la racine via createPortal
  (le backdrop-filter du header re-parentait le position:fixed → voile de
  66 px) + fermeture du menu compte à Escape.
- frontend/src/pages/Home.tsx : feuille de filtres mobile (sheetOpen) — gel du
  scroll d arrière-plan (pattern du hamburger) + fermeture à Escape.
- frontend/src/styles.css : backdrop compte plein écran sous le header (z-40,
  le menu ancré dans le header reste au-dessus) ; champs de filtres ≥16 px sur
  mobile (f-search, f-native, f-ctl select, ms-sort) ; zone tactile ≥44 px du
  logo .brand (::after inset -8px, rendu inchangé).
- frontend/src/ka/tokens.css : socle mobile obligatoire (z-index communs,
  --vh100, .ka-scroll-lock, .ka-bottombar, filet anti-zoom iOS 16 px).
- frontend/public/ka-agent.js : safe-areas, textarea 16 px, plein écran
  ≤640 px + verrou, pas de focus clavier forcé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 19, 2026 parent 73bc289
5 changed files +153 −16 Browse files
modified frontend/public/ka-agent.js +24 −12
@@ -17,19 +17,19 @@
17 17
18 /* ---------- styles ---------- */ 18 /* ---------- styles ---------- */
19 var css = ` 19 var css = `
20− .kaa-btn{position:fixed;right:18px;bottom:18px;z-index:2147483000;width:58px;height:58px; 20+ .kaa-btn{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;width:58px;height:58px;
21 border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b); 21 border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b);
22 color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif; 22 color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;
23 cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center; 23 cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center;
24− justify-content:center;transition:transform .15s;padding-bottom:env(safe-area-inset-bottom,0)} 24+ justify-content:center;transition:transform .15s}
25 .kaa-btn:hover{transform:translate(-2px,-2px)} 25 .kaa-btn:hover{transform:translate(-2px,-2px)}
26− .kaa-panel{position:fixed;right:16px;bottom:86px;z-index:2147483001;width:min(392px,calc(100vw - 24px)); 26+ .kaa-panel{position:fixed;right:16px;bottom:calc(86px + env(safe-area-inset-bottom,0px));z-index:2147483001;width:min(392px,calc(100vw - 24px));
27− height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee); 27+ height:min(580px,calc(100vh - 110px));height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee);
28 border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85); 28 border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85);
29 overflow:hidden;font-family:Inter,system-ui,sans-serif} 29 overflow:hidden;font-family:Inter,system-ui,sans-serif}
30 .kaa-panel.kaa-open{display:flex} 30 .kaa-panel.kaa-open{display:flex}
31− .kaa-panel.kaa-full{right:0;bottom:0;left:0;top:0;width:100vw;height:100dvh;border-radius:0;border:0;box-shadow:none; 31+ .kaa-panel.kaa-full{right:0;bottom:0;width:100vw;height:100vh;height:100dvh;border-radius:0;border:0;box-shadow:none}
32− padding-bottom:env(safe-area-inset-bottom)} 32+ html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none}
33 .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814); 33 .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);
34 color:var(--paper,#f5f3ee);flex:none} 34 color:var(--paper,#f5f3ee);flex:none}
35 .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em} 35 .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
@@ -54,7 +54,7 @@
54 border-radius:999px;background:var(--surface-2,#faf9f5)} 54 border-radius:999px;background:var(--surface-2,#faf9f5)}
55 .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none; 55 .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;
56 padding-bottom:calc(11px + env(safe-area-inset-bottom,0))} 56 padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}
57− .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:11px 12px;font:13.5px/1.4 Inter,system-ui,sans-serif; 57+ .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:10px 12px;font:16px/1.4 Inter,system-ui,sans-serif;
58 border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none} 58 border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}
59 .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px; 59 .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;
60 background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer} 60 background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer}
@@ -98,7 +98,7 @@
98 panel.setAttribute("aria-label", "KA Agent"); 98 panel.setAttribute("aria-label", "KA Agent");
99 panel.innerHTML = 99 panel.innerHTML =
100 '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' + 100 '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' +
101− '<button class="kaa-hbtn kaa-x1" title="Réduire / plein écran" aria-label="Réduire ou remettre en plein écran">⛶</button>' + 101+ '<button class="kaa-hbtn kaa-x1" title="Plein écran" aria-label="Plein écran">⛶</button>' +
102 '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' + 102 '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' +
103 '<div class="kaa-log"></div>' + 103 '<div class="kaa-log"></div>' +
104 '<div class="kaa-in"><textarea rows="1" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' + 104 '<div class="kaa-in"><textarea rows="1" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +
@@ -143,20 +143,32 @@
143 } 143 }
144 144
145 /* ---------- ouverture / plein écran ---------- */ 145 /* ---------- ouverture / plein écran ---------- */
146+ /* Sur téléphone : panneau plein écran d'office + scroll d'arrière-plan
147+ verrouillé tant qu'il est ouvert (html.kaa-lock). Pas de focus clavier
148+ forcé au tap (le clavier ne s'ouvre que si l'utilisateur touche le champ). */
149+ var mobileMq = window.matchMedia("(max-width: 640px)");
150+ var fineInput = window.matchMedia("(hover: hover) and (pointer: fine)");
151+ function syncLock() {
152+ var open = panel.classList.contains("kaa-open");
153+ if (open && mobileMq.matches) panel.classList.add("kaa-full");
154+ document.documentElement.classList.toggle(
155+ "kaa-lock", open && panel.classList.contains("kaa-full"));
156+ }
146 btn.addEventListener("click", function () { 157 btn.addEventListener("click", function () {
147 panel.classList.toggle("kaa-open"); 158 panel.classList.toggle("kaa-open");
148 if (panel.classList.contains("kaa-open")) { 159 if (panel.classList.contains("kaa-open")) {
149− panel.classList.add("kaa-full"); /* toujours plein écran à l'ouverture */ 160+ render();
150− render(); ta.focus(); 161+ if (fineInput.matches) ta.focus();
151− } else {
152− panel.classList.remove("kaa-full");
153 } 162 }
163+ syncLock();
154 }); 164 });
155 panel.querySelector(".kaa-x2").addEventListener("click", function () { 165 panel.querySelector(".kaa-x2").addEventListener("click", function () {
156 panel.classList.remove("kaa-open", "kaa-full"); 166 panel.classList.remove("kaa-open", "kaa-full");
167+ syncLock();
157 }); 168 });
158 panel.querySelector(".kaa-x1").addEventListener("click", function () { 169 panel.querySelector(".kaa-x1").addEventListener("click", function () {
159 panel.classList.toggle("kaa-full"); 170 panel.classList.toggle("kaa-full");
171+ syncLock();
160 scroll(); 172 scroll();
161 }); 173 });
162 174
modified frontend/src/App.tsx +15 −1
@@ -4,6 +4,7 @@
4 // App.tsx : layout global (header + ticker en direct + footer encre) et routage 4 // App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 // ----------------------------------------------------------------------------- 5 // -----------------------------------------------------------------------------
6 import { useEffect, useState } from "react"; 6 import { useEffect, useState } from "react";
7+import { createPortal } from "react-dom";
7 import { NavLink, Route, Routes, useLocation } from "react-router-dom"; 8 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 import { 9 import {
9 fetchAuthConfig, fetchFacets, fetchSources, fetchStats, 10 fetchAuthConfig, fetchFacets, fetchSources, fetchStats,
@@ -93,6 +94,14 @@ function AccountMenu() {
93 fetchAuthConfig().then((c) => setEnabled(c.ka || c.google)).catch(() => {}); 94 fetchAuthConfig().then((c) => setEnabled(c.ka || c.google)).catch(() => {});
94 }, []); 95 }, []);
95 96
97+ // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop)
98+ useEffect(() => {
99+ if (!menuOpen) return;
100+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };
101+ window.addEventListener("keydown", onKey);
102+ return () => window.removeEventListener("keydown", onKey);
103+ }, [menuOpen]);
104+
96 if (!enabled) return null; 105 if (!enabled) return null;
97 if (!me) { 106 if (!me) {
98 return ( 107 return (
@@ -132,7 +141,12 @@ function AccountMenu() {
132 </button> 141 </button>
133 {menuOpen && ( 142 {menuOpen && (
134 <> 143 <>
135− <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> 144+ {/* Backdrop rendu à la racine (createPortal) : le backdrop-filter du
145+ header re-parentait le position:fixed → voile limité au header. */}
146+ {createPortal(
147+ <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />,
148+ document.body,
149+ )}
136 <div className="account-menu" role="menu"> 150 <div className="account-menu" role="menu">
137 <div className="account-id"> 151 <div className="account-id">
138 <b>{me.name || "Mon compte"}</b> 152 <b>{me.name || "Mon compte"}</b>
modified frontend/src/ka/tokens.css +87 −0
@@ -69,7 +69,24 @@
69 69
70 /* Cible tactile minimale */ 70 /* Cible tactile minimale */
71 --touch: 44px; 71 --touch: 44px;
72+
73+ /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----
74+ grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,
75+ purement visuel). Tout composant interactif se place sous 9999 : */
76+ --z-content: 1; /* contenu positionné ordinaire */
77+ --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */
78+ --z-header: 500; /* header du site */
79+ --z-bottombar: 600; /* tab bar / barre d'action basse */
80+ --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */
81+ --z-overlay: 800; /* voile sous modale/panneau */
82+ --z-modal: 900; /* modales, panneaux de filtres, galeries */
83+ --z-toast: 950; /* notifications */
84+
85+ /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se
86+ replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */
87+ --vh100: 100vh;
72 } 88 }
89+@supports (height: 100dvh) { :root { --vh100: 100dvh; } }
73 90
74 /* ---- Socle ---- */ 91 /* ---- Socle ---- */
75 * { box-sizing: border-box; } 92 * { box-sizing: border-box; }
@@ -227,3 +244,73 @@ body > * { position: relative; }
227 /* ---- Utilitaires responsive ---- */ 244 /* ---- Utilitaires responsive ---- */
228 .only-mobile { display: initial; } .only-desktop { display: none; } 245 .only-mobile { display: initial; } .only-desktop { display: none; }
229 @media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } } 246 @media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
247+
248+/* =============================================================================
249+ SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.
250+ RÈGLES pour tout composant futur :
251+ · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;
252+ le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).
253+ · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.
254+ · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/
255+ will-change/backdrop-filter — sinon monter l'élément à la racine (body).
256+ · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>.
257+ · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé).
258+ · z-index : uniquement l'échelle --z-* ci-dessus.
259+ · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).
260+ ========================================================================== */
261+
262+/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px
263+ déclenche un zoom automatique de page sur Safari iOS). !important assumé :
264+ c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi
265+ qu'il arrive — ne s'applique qu'aux écrans tactiles. */
266+@media (hover: none) and (pointer: coarse) {
267+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
268+ select, textarea { font-size: max(16px, 1em) !important; }
269+}
270+
271+/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer
272+ son rendu (débord de la zone cliquable via pseudo-élément). */
273+@media (pointer: coarse) {
274+ .gk-badge { position: relative; }
275+ .gk-badge::after { content: ""; position: absolute; inset: -8px; }
276+}
277+
278+/* Hauteurs viewport dynamiques */
279+.h-viewport { height: 100vh; height: 100dvh; }
280+.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
281+
282+/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les
283+ transforms accidentels, et compensation d'espace via body.has-bottombar. */
284+.ka-bottombar {
285+ position: fixed; left: 0; right: 0; bottom: 0;
286+ z-index: var(--z-bottombar);
287+ padding-bottom: env(safe-area-inset-bottom, 0px);
288+ background: var(--surface);
289+ border-top: 1.5px solid var(--ink);
290+ transform: none !important; filter: none !important;
291+}
292+body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }
293+
294+/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres
295+ ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */
296+html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }
297+
298+/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox
299+ incluses), défilement interne avec élan, jamais plus haut que l'écran. */
300+.ka-menu {
301+ position: absolute; z-index: var(--z-dropdown);
302+ min-width: 180px;
303+ max-height: min(60vh, 420px); max-height: min(60dvh, 420px);
304+ overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
305+ background: var(--surface); border: 1.5px solid var(--ink);
306+ border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
307+}
308+.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] {
309+ display: flex; align-items: center; min-height: var(--touch);
310+ padding: 10px 14px; width: 100%; text-decoration: none;
311+}
312+/* Voile plein écran sous une modale / un panneau */
313+.ka-overlay {
314+ position: fixed; inset: 0; z-index: var(--z-overlay);
315+ background: rgba(20, 24, 20, 0.45);
316+}
modified frontend/src/pages/Home.tsx +12 −0
@@ -73,6 +73,18 @@ export default function Home() {
73 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop 73 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
74 const [sheetOpen, setSheetOpen] = useState(false); 74 const [sheetOpen, setSheetOpen] = useState(false);
75 const [advOpen, setAdvOpen] = useState(false); 75 const [advOpen, setAdvOpen] = useState(false);
76+ // feuille ouverte : gel du scroll d'arrière-plan + fermeture à Escape
77+ // (même pattern que le menu hamburger d'App.tsx)
78+ useEffect(() => {
79+ document.body.style.overflow = sheetOpen ? "hidden" : "";
80+ if (!sheetOpen) return () => { document.body.style.overflow = ""; };
81+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
82+ window.addEventListener("keydown", onKey);
83+ return () => {
84+ document.body.style.overflow = "";
85+ window.removeEventListener("keydown", onKey);
86+ };
87+ }, [sheetOpen]);
76 const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType, 88 const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType,
77 dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length; 89 dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length;
78 const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length; 90 const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length;
modified frontend/src/styles.css +15 −3
@@ -643,9 +643,17 @@ html, body { overflow-x: clip; }
643 .footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); } 643 .footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }
644 } 644 }
645 645
646−/* Anti-zoom iOS : les champs doivent faire >= 16px */ 646+/* Anti-zoom iOS : les champs doivent faire >= 16px
647+ (filet global : tokens.css force aussi 16 px au doigt) */
647 @media (max-width: 900px) { 648 @media (max-width: 900px) {
648− .field input, .field select { font-size: 16px; } 649+ .field input, .field select,
650+ .f-search input, .f-native, .f-ctl select, .ms-sort select { font-size: 16px; }
651+}
652+
653+/* Cible tactile ≥44 px du logo (124×30 rendus) : zone étendue invisible */
654+@media (pointer: coarse) {
655+ .brand { position: relative; }
656+ .brand::after { content: ""; position: absolute; inset: -8px; }
649 } 657 }
650 658
651 /* ================= Footer ================= */ 659 /* ================= Footer ================= */
@@ -1193,7 +1201,11 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1193 } 1201 }
1194 .account-btn img { width: 100%; height: 100%; object-fit: cover; } 1202 .account-btn img { width: 100%; height: 100%; object-fit: cover; }
1195 .account-initial { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--orange); } 1203 .account-initial { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--orange); }
1196−.account-backdrop { position: fixed; inset: 0; z-index: 90; } 1204+/* Click-catcher plein écran rendu via createPortal à la racine de <body>
1205+ (un fixed descendant du header blurré se re-parentait → 66 px de haut).
1206+ z-index SOUS le header (50) : le menu .account-menu reste ancré DANS le
1207+ header et doit peindre au-dessus du voile. */
1208+.account-backdrop { position: fixed; inset: 0; z-index: 40; }
1197 .account-menu { 1209 .account-menu {
1198 position: absolute; right: 0; top: calc(100% + 10px); z-index: 91; 1210 position: absolute; right: 0; top: calc(100% + 10px); z-index: 91;
1199 min-width: 220px; background: var(--surface); border: 1.5px solid var(--ink); 1211 min-width: 220px; background: var(--surface); border: 1.5px solid var(--ink);
1200 1212
Menu

Theme