SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%

Mobile : FAB decale du bandeau temoins, verrous de scroll lightbox et bottom-sheet, anti-zoom iOS, cible tactile du logo

- CookieConsent.tsx : hauteur du bandeau publiee dans --consent-h sur
  documentElement via ResizeObserver (0 quand ferme) — fin de la collision
  z-index 80/80 avec le FAB (modele lou-ka).
- styles.css : .fab decale de var(--consent-h) et passe a var(--z-bottombar) ;
  .cookie-banner passe a var(--z-toast) ; lightbox et bottom-sheet filtres
  bascules sur var(--z-modal)/var(--z-overlay) ; extension tactile ::after du
  logo (121x27) ; regles anti-zoom iOS generalisees >=16 px en contexte
  tactile (#f-q, selects hors .field que le breakpoint 900 px ratait).
- Product.tsx : verrou du scroll d arriere-plan quand la lightbox est ouverte
  + fermeture Escape (pattern App.tsx).
- Home.tsx : verrou du scroll d arriere-plan quand le bottom-sheet filtres
  est ouvert + fermeture Escape.
- ka/tokens.css : section SOCLE MOBILE OBLIGATOIRE (echelle --z-*, --vh100,
  verrou ka-scroll-lock, filet anti-zoom iOS).
- public/ka-agent.js : agent KA corrige (safe-area, textarea 16 px, plein
  ecran auto <=640 px + verrou scroll).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent 0e46a27

6 changed files +180 −14

modified frontend/public/ka-agent.js +25 −7
@@ -17,18 +17,19 @@
17 17
18 18 /* ---------- styles ---------- */
19 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 21 border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b);
22 22 color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;
23 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 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));
27 − height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee);
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(100vh - 110px));height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee);
28 28 border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85);
29 29 overflow:hidden;font-family:Inter,system-ui,sans-serif}
30 30 .kaa-panel.kaa-open{display:flex}
31 − .kaa-panel.kaa-full{right:0;bottom: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 + html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none}
32 33 .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);
33 34 color:var(--paper,#f5f3ee);flex:none}
34 35 .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
@@ -53,7 +54,7 @@
53 54 border-radius:999px;background:var(--surface-2,#faf9f5)}
54 55 .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;
55 56 padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}
56 − .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;
57 58 border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}
58 59 .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;
59 60 background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer}
@@ -142,15 +143,32 @@
142 143 }
143 144
144 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 + }
145 157 btn.addEventListener("click", function () {
146 158 panel.classList.toggle("kaa-open");
147 − if (panel.classList.contains("kaa-open")) { render(); ta.focus(); }
159 + if (panel.classList.contains("kaa-open")) {
160 + render();
161 + if (fineInput.matches) ta.focus();
162 + }
163 + syncLock();
148 164 });
149 165 panel.querySelector(".kaa-x2").addEventListener("click", function () {
150 166 panel.classList.remove("kaa-open", "kaa-full");
167 + syncLock();
151 168 });
152 169 panel.querySelector(".kaa-x1").addEventListener("click", function () {
153 170 panel.classList.toggle("kaa-full");
171 + syncLock();
154 172 scroll();
155 173 });
156 174
modified frontend/src/components/CookieConsent.tsx +19 −2
@@ -7,7 +7,7 @@
7 7 // mesure d'audience anonyme future. Choix mémorisé en localStorage,
8 8 // ré-ouvrable via l'événement « foodka:openConsent » (lien du pied de page).
9 9 // -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
10 +import { useEffect, useRef, useState } from "react";
11 11 import { Link } from "react-router-dom";
12 12
13 13 const KEY = "foodka-consent-v1";
@@ -47,12 +47,29 @@ export default function CookieConsent() {
47 47 return () => window.removeEventListener("foodka:openConsent", open);
48 48 }, []);
49 49
50 + // Publie la hauteur du bandeau dans --consent-h (documentElement) pour que
51 + // les éléments flottants (ex. .fab) se décalent au-dessus au lieu de se
52 + // superposer ; 0 quand le bandeau est fermé. Mesure vivante (ResizeObserver :
53 + // le mode « Personnaliser » change la hauteur).
54 + const bannerRef = useRef<HTMLDivElement>(null);
55 + useEffect(() => {
56 + const root = document.documentElement;
57 + if (!visible) { root.style.setProperty("--consent-h", "0px"); return; }
58 + const el = bannerRef.current;
59 + if (!el) return;
60 + const apply = () => root.style.setProperty("--consent-h", `${el.offsetHeight + 12}px`);
61 + apply();
62 + const ro = new ResizeObserver(apply);
63 + ro.observe(el);
64 + return () => { ro.disconnect(); root.style.setProperty("--consent-h", "0px"); };
65 + }, [visible]);
66 +
50 67 if (!visible) return null;
51 68
52 69 const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); };
53 70
54 71 return (
55 − <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Gestion des témoins">
72 + <div className="cookie-banner" ref={bannerRef} role="dialog" aria-modal="false" aria-label="Gestion des témoins">
56 73 <div className="cookie-inner">
57 74 <div className="cookie-text">
58 75 <b>🍪 Vos témoins, vos choix.</b>{" "}
modified frontend/src/ka/tokens.css +87 −0
@@ -69,7 +69,24 @@
69 69
70 70 /* Cible tactile minimale */
71 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 91 /* ---- Socle ---- */
75 92 * { box-sizing: border-box; }
@@ -227,3 +244,73 @@ body > * { position: relative; }
227 244 /* ---- Utilitaires responsive ---- */
228 245 .only-mobile { display: initial; } .only-desktop { display: none; }
229 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 +13 −0
@@ -51,6 +51,19 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
51 51 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
52 52 const [sheetOpen, setSheetOpen] = useState(false);
53 53 const [advOpen, setAdvOpen] = useState(false);
54 +
55 + // Bottom-sheet ouvert = verrou du scroll d'arrière-plan + fermeture Escape
56 + // (même patron que le menu mobile du header — App.tsx)
57 + useEffect(() => {
58 + if (!sheetOpen) return;
59 + document.body.style.overflow = "hidden";
60 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
61 + window.addEventListener("keydown", onKey);
62 + return () => {
63 + document.body.style.overflow = "";
64 + window.removeEventListener("keydown", onKey);
65 + };
66 + }, [sheetOpen]);
54 67 const saleForced = aubaines; // la page Aubaines impose « en solde »
55 68 const activeFilters = [q, category, source, brand, priceMin, priceMax,
56 69 !saleForced && onSale ? "1" : ""].filter(Boolean).length;
modified frontend/src/pages/Product.tsx +13 −0
@@ -24,6 +24,19 @@ function Galerie({ images, titre }: { images: string[]; titre: string }) {
24 24 const [zoom, setZoom] = useState(false);
25 25 const track = useRef<HTMLDivElement>(null);
26 26
27 + // Lightbox ouverte = verrou du scroll d'arrière-plan + fermeture Escape
28 + // (le tap n'importe où sur la lightbox ferme déjà — onClick du conteneur)
29 + useEffect(() => {
30 + if (!zoom) return;
31 + document.body.style.overflow = "hidden";
32 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setZoom(false); };
33 + window.addEventListener("keydown", onKey);
34 + return () => {
35 + document.body.style.overflow = "";
36 + window.removeEventListener("keydown", onKey);
37 + };
38 + }, [zoom]);
39 +
27 40 const onScroll = () => {
28 41 const el = track.current;
29 42 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
modified frontend/src/styles.css +23 −5
@@ -41,9 +41,12 @@ button { font-family: inherit; }
41 41 }
42 42 .header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
43 43 .brand {
44 + position: relative;
44 45 font-family: var(--font-display); font-weight: 700; font-size: 26px;
45 46 letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;
46 47 }
48 +/* Cible tactile ≥44 px pour le logo (121×27 au naturel) */
49 +.brand::after { content: ""; position: absolute; inset: -8px; }
47 50 .brand .ka {
48 51 background: var(--accent-deep); color: var(--on-accent); padding: 2px 7px 4px;
49 52 border-radius: 6px; margin-left: 3px; transform: rotate(-2deg);
@@ -527,7 +530,7 @@ button { font-family: inherit; }
527 530
528 531 /* ================= Lightbox ================= */
529 532 .lightbox {
530 − position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100;
533 + position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: var(--z-modal, 900);
531 534 display: flex; align-items: center; justify-content: center; cursor: zoom-out;
532 535 padding: max(16px, env(safe-area-inset-top)) 16px;
533 536 }
@@ -648,7 +651,7 @@ button { font-family: inherit; }
648 651 .filterbar.open .f-adv { margin-top: 4px; }
649 652 .filterbar.open {
650 653 display: flex; flex-direction: column; gap: 12px;
651 − position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
654 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
652 655 margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
653 656 max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
654 657 padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
@@ -669,7 +672,7 @@ button { font-family: inherit; }
669 672 }
670 673 .filterbar.open .sheet-apply { display: block; width: 100%; }
671 674 .sheet-backdrop {
672 − display: block; position: fixed; inset: 0; z-index: 90;
675 + display: block; position: fixed; inset: 0; z-index: var(--z-overlay, 800);
673 676 background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
674 677 }
675 678
@@ -677,7 +680,11 @@ button { font-family: inherit; }
677 680 .fab {
678 681 display: flex; align-items: center; gap: 6px;
679 682 position: fixed; left: 50%; transform: translateX(-50%);
680 − bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80;
683 + /* --consent-h (posé par CookieConsent) décale le FAB au-dessus du
684 + bandeau de témoins au lieu de s'y superposer */
685 + bottom: calc(18px + env(safe-area-inset-bottom) + var(--consent-h, 0px));
686 + z-index: var(--z-bottombar, 600);
687 + transition: bottom 0.25s ease;
681 688 background: var(--accent-deep); color: var(--on-accent); border: 1.5px solid var(--ink);
682 689 border-radius: 999px; padding: 13px 24px; font-family: var(--font-display);
683 690 font-weight: 700; font-size: 15px; cursor: pointer;
@@ -701,6 +708,17 @@ button { font-family: inherit; }
701 708 @media (max-width: 900px) {
702 709 .field input, .field select { font-size: 16px; }
703 710 }
711 +/* Généralisation en contexte tactile : le breakpoint 900 px ratait #f-q et
712 + les selects hors .field (renfort explicite du filet de ka/tokens.css). */
713 +@media (hover: none) and (pointer: coarse) {
714 + select,
715 + input[type="text"],
716 + input[type="search"],
717 + input[type="number"],
718 + #f-q, .f-ctl select, .field input, .field select {
719 + font-size: max(16px, 1em) !important;
720 + }
721 +}
704 722
705 723 /* ================= Footer : KaFooter commun (voir ka/tokens.css) ================= */
706 724
@@ -763,7 +781,7 @@ button { font-family: inherit; }
763 781 /* --- Bandeau de consentement (témoins) ------------------------------------- */
764 782 .cookie-banner {
765 783 position: fixed; left: 12px; right: 12px;
766 − bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80;
784 + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: var(--z-toast, 950);
767 785 animation: cookie-in 0.35s ease;
768 786 }
769 787 @keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } }
770 788