nav/ : standard KA Nav v2 — menu mobile plein écran fixed (2026-08-25)
NAV-STANDARD.md (règles), ka-nav.css + ka-nav.js (référence vanilla à adapter par app), convert-mobile-menu.py (conversion des .mobile-menu dropdown des apps React). Déployé ce jour sur les 14 sites publics du groupe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +176 −0
added
nav/NAV-STANDARD.md
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +# Standard KA Nav v2 — menu mobile plein écran (2026-08-25) | |
| 2 | + | |
| 3 | +## Problème corrigé | |
| 4 | +Sur mobile, plusieurs sites KA affichaient soit des liens de nav qui wrappent dans le | |
| 5 | +header (2-3 lignes), soit un dropdown `position:absolute` ancré au header. Quand le | |
| 6 | +header n'est pas sticky (ou que le panneau est ancré au flux du document), ouvrir le | |
| 7 | +menu en bas de page ne montre RIEN — le panneau se dessine hors écran. Rendu incohérent | |
| 8 | +d'un site à l'autre. | |
| 9 | + | |
| 10 | +## Standard (obligatoire sur tous les sites publics KA) | |
| 11 | +- Desktop (>760px) : nav pilules inline inchangée. | |
| 12 | +- Mobile (≤760px) : les liens inline sont masqués, remplacés par un bouton hamburger | |
| 13 | + 44×44 (3 barres → ✕ animé, `aria-expanded`). | |
| 14 | +- Panneau : `position:fixed; inset:0; z-index:var(--z-modal,900); background:var(--paper)` | |
| 15 | + → TOUJOURS visible, peu importe la position de scroll (c'est le fix du bug). | |
| 16 | + Structure : barre du haut (marque + bouton ✕ 44px) / liens géants numérotés | |
| 17 | + (font-display 700, min-height 52px, bordure pointillée) / pied (badge Groupe KA + | |
| 18 | + liens secondaires), `padding-bottom:max(20px, env(safe-area-inset-bottom))`. | |
| 19 | +- Verrou de scroll : classe `ka-menu-locked` sur `<html>` (`overflow:hidden`). | |
| 20 | +- Fermeture : clic sur un lien, ✕, touche Escape. Le panneau s'ouvre sans animation | |
| 21 | + lourde (fade+translate 160ms max). | |
| 22 | +- A11y : `role="dialog" aria-modal="true"`, `aria-label="Menu"`, focus visible. | |
| 23 | +- Thème automatique par app via tokens : `--paper, --ink, --ink-2, --ink-3, --accent, | |
| 24 | + --on-accent, --accent-soft, --font-display, --font-mono, --z-modal`. | |
| 25 | +- Référence Next.js : vrai-prix `src/components/HeaderNav.tsx` (checkbox + peer-checked). | |
| 26 | +- Référence vanilla SPA : `ka-nav.css` + `ka-nav.js` de ce dossier (copiés/adaptés | |
| 27 | + dans chaque index.html monolithique). | |
| 28 | + | |
| 29 | +## Règles | |
| 30 | +- JAMAIS de dropdown `position:absolute` ancré au header pour le menu mobile. | |
| 31 | +- Le hamburger et le ✕ font ≥44×44px (cible tactile). | |
| 32 | +- Les liens du panneau reprennent EXACTEMENT les liens de la nav desktop (+ liens | |
| 33 | + secondaires du footer si pertinent), i18n fr-CA. | |
| 34 | +- L'état actif (page courante) est marqué dans le panneau (fond --ink, texte --accent | |
| 35 | + ou inversion propre au thème). | |
added
nav/convert-mobile-menu.py
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""KA Nav v2 (2026-08-25) — convertit le .mobile-menu dropdown absolute 480px | |
| 3 | +en panneau plein écran position:fixed inset:0 dans un styles.css d'app KA. | |
| 4 | +Usage : convert-mobile-menu.py <styles.css> <has|menu-open> | |
| 5 | + has → le header est remonté via .header:has(.mobile-menu.open) | |
| 6 | + menu-open → le header est remonté via .header.menu-open | |
| 7 | +Affiche n1/n2/n3 (chaque compteur doit être ≥1, sauf n3 qui peut être 0).""" | |
| 8 | +import re | |
| 9 | +import sys | |
| 10 | + | |
| 11 | +path, variant = sys.argv[1], sys.argv[2] | |
| 12 | +css = open(path).read() | |
| 13 | + | |
| 14 | +# 1) bloc principal : absolute top:100% + max-height:0 → fixed inset:0 | |
| 15 | +pat = re.compile( | |
| 16 | + r"\.mobile-menu \{\s*\n" | |
| 17 | + r"(?:[^}]*?\n)*?" | |
| 18 | + r"\s*transition: max-height[^\n]*\n" | |
| 19 | + r"\}" | |
| 20 | +) | |
| 21 | +new_block = ( | |
| 22 | + "/* KA Nav v2 (2026-08-25) : panneau PLEIN ECRAN fixed inset:0 - visible peu\n" | |
| 23 | + " importe le scroll (l'ancien dropdown absolute 480px clippait/se coupait). */\n" | |
| 24 | + ".mobile-menu {\n" | |
| 25 | + " display: none; position: fixed; inset: 0;\n" | |
| 26 | + " background: var(--paper);\n" | |
| 27 | + " padding: 76px 16px calc(24px + env(safe-area-inset-bottom));\n" | |
| 28 | + " overflow-y: auto; overscroll-behavior: contain;\n" | |
| 29 | + "}" | |
| 30 | +) | |
| 31 | +css, n1 = pat.subn(new_block, css, count=1) | |
| 32 | +if n1 == 0: # variante mono-ligne (immo-ka) | |
| 33 | + pat1b = re.compile(r"\.mobile-menu \{ display: none; position: absolute;[^}]*\}") | |
| 34 | + css, n1 = pat1b.subn(new_block, css, count=1) | |
| 35 | + | |
| 36 | +# 2) l'ancienne ligne z-index dediee (lou-ka / resto-ka) devient inutile : | |
| 37 | +# le panneau doit peindre SOUS le rang du header (header-inner z:60) | |
| 38 | +css = css.replace(".mobile-menu { z-index: var(--z-modal, 900); }\n", "") | |
| 39 | + | |
| 40 | +# 3) regle d'ouverture + rang du header au-dessus + verrou de scroll CSS pur | |
| 41 | +raise_rule = ( | |
| 42 | + ".header:has(.mobile-menu.open) .header-inner { position: relative; z-index: 60; }" | |
| 43 | + if variant == "has" | |
| 44 | + else ".header.menu-open .header-inner { position: relative; z-index: 60; }" | |
| 45 | +) | |
| 46 | +open_block = ( | |
| 47 | + ".mobile-menu.open { display: block; animation: mm-in 0.16s ease; }\n" | |
| 48 | + "@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }\n" | |
| 49 | + "/* le rang du header (marque + burger devenu X) reste au-dessus du panneau */\n" | |
| 50 | + + raise_rule + "\n" | |
| 51 | + "/* verrou du scroll d'arriere-plan quand le menu est ouvert (CSS pur) */\n" | |
| 52 | + "html:has(.mobile-menu.open) { overflow: hidden; }" | |
| 53 | +) | |
| 54 | +pat2 = re.compile(r"\.mobile-menu\.open \{ max-height: 480px; opacity: 1;(?: overflow-y: auto;)? \}") | |
| 55 | +css, n2 = pat2.subn(open_block, css, count=1) | |
| 56 | + | |
| 57 | +# 4) dans la media query mobile, le display:block permanent disparait | |
| 58 | +# (le display est desormais porte par .open) | |
| 59 | +css, n3 = re.subn(r"\s*\.mobile-menu \{ display: block; \}", "", css) | |
| 60 | + | |
| 61 | +open(path, "w").write(css) | |
| 62 | +print(f"n1={n1} n2={n2} n3={n3}") | |
added
nav/ka-nav.css
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +/* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) ===== | |
| 2 | + Panneau en position:fixed inset:0 → toujours visible, peu importe le scroll. | |
| 3 | + Thémé par les tokens de l'app : --paper --ink --ink-2 --ink-3 --accent | |
| 4 | + --on-accent --font-display --font-mono --z-modal. Préfixe ka-mnav (zéro collision). */ | |
| 5 | + | |
| 6 | +/* bouton hamburger (masqué en desktop) */ | |
| 7 | +.ka-mnav-btn{display:none;width:44px;height:44px;margin-left:auto;flex:none; | |
| 8 | + flex-direction:column;align-items:center;justify-content:center;gap:5px; | |
| 9 | + background:var(--paper);border:1.5px solid var(--ink);border-radius:999px; | |
| 10 | + cursor:pointer;padding:0} | |
| 11 | +.ka-mnav-btn span{display:block;width:18px;height:2px;background:var(--ink); | |
| 12 | + border-radius:2px;transition:transform .18s ease,opacity .14s ease} | |
| 13 | +.ka-mnav-btn[aria-expanded="true"] span:nth-child(1){transform:translateY(7px) rotate(45deg)} | |
| 14 | +.ka-mnav-btn[aria-expanded="true"] span:nth-child(2){opacity:0} | |
| 15 | +.ka-mnav-btn[aria-expanded="true"] span:nth-child(3){transform:translateY(-7px) rotate(-45deg)} | |
| 16 | + | |
| 17 | +/* panneau plein écran */ | |
| 18 | +.ka-mnav{position:fixed;inset:0;z-index:var(--z-modal,900);background:var(--paper); | |
| 19 | + display:none;flex-direction:column;overflow-y:auto;overscroll-behavior:contain} | |
| 20 | +.ka-mnav.open{display:flex;animation:ka-mnav-in .16s ease} | |
| 21 | +@keyframes ka-mnav-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} | |
| 22 | + | |
| 23 | +.ka-mnav-top{display:flex;align-items:center;justify-content:space-between;flex:none; | |
| 24 | + min-height:64px;padding:0 18px;border-bottom:2px solid var(--ink);background:var(--paper)} | |
| 25 | +.ka-mnav-close{width:44px;height:44px;flex:none;display:flex;align-items:center; | |
| 26 | + justify-content:center;border:1.5px solid var(--ink);border-radius:999px; | |
| 27 | + background:var(--ink);color:var(--accent,var(--paper));font-size:17px;font-weight:700; | |
| 28 | + font-family:var(--font-display,inherit);cursor:pointer;padding:0;line-height:1} | |
| 29 | + | |
| 30 | +.ka-mnav-links{display:flex;flex-direction:column;justify-content:center;flex:1; | |
| 31 | + padding:28px 24px} | |
| 32 | +.ka-mnav-links a{display:flex;align-items:baseline;gap:14px;padding:17px 0; | |
| 33 | + border-bottom:1.5px dashed color-mix(in srgb,var(--ink) 18%,transparent); | |
| 34 | + text-decoration:none;color:var(--ink)} | |
| 35 | +.ka-mnav-links a:last-child{border-bottom:0} | |
| 36 | +.ka-mnav-links a i{font-style:normal;font-family:var(--font-mono,monospace); | |
| 37 | + font-size:12px;font-weight:700;color:var(--accent-deep,var(--ink-3,#777))} | |
| 38 | +.ka-mnav-links a b{font-family:var(--font-display,inherit);font-weight:700; | |
| 39 | + font-size:27px;line-height:1;letter-spacing:-.03em;text-transform:uppercase} | |
| 40 | +.ka-mnav-links a.active b{background:var(--ink);color:var(--accent,var(--paper)); | |
| 41 | + border-radius:6px;padding:4px 10px 6px} | |
| 42 | + | |
| 43 | +.ka-mnav-foot{flex:none;border-top:2px solid var(--ink);padding:16px 24px; | |
| 44 | + padding-bottom:max(20px,env(safe-area-inset-bottom))} | |
| 45 | +.ka-mnav-foot .ka-mnav-sec{display:flex;flex-wrap:wrap;gap:6px 20px;margin-top:12px; | |
| 46 | + font-family:var(--font-mono,monospace);font-size:10.5px;text-transform:uppercase; | |
| 47 | + letter-spacing:.06em} | |
| 48 | +.ka-mnav-foot .ka-mnav-sec a{color:var(--ink-3,var(--ink));text-decoration:none} | |
| 49 | + | |
| 50 | +/* verrou de scroll quand le panneau est ouvert */ | |
| 51 | +html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden} | |
| 52 | + | |
| 53 | +/* bascule mobile : masquer la nav inline, montrer le hamburger. | |
| 54 | + ⚠️ PAR APP : remplacer `.nav` par le sélecteur réel de la nav desktop. */ | |
| 55 | +@media(max-width:760px){ | |
| 56 | + .ka-mnav-btn{display:flex} | |
| 57 | +} | |
added
nav/ka-nav.js
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +/* ===== KA Nav v2 — comportement du menu mobile plein écran (2026-08-25) ===== | |
| 2 | + Ouvre/ferme le panneau #ka-mnav via #ka-mnav-btn ; verrou de scroll sur <html> ; | |
| 3 | + fermeture au clic sur un lien, sur ✕ (data-ka-mnav-close) ou avec Escape. */ | |
| 4 | +(function () { | |
| 5 | + var btn = document.getElementById("ka-mnav-btn"); | |
| 6 | + var panel = document.getElementById("ka-mnav"); | |
| 7 | + if (!btn || !panel) return; | |
| 8 | + function set(open) { | |
| 9 | + panel.classList.toggle("open", open); | |
| 10 | + btn.setAttribute("aria-expanded", open ? "true" : "false"); | |
| 11 | + document.documentElement.classList.toggle("ka-menu-locked", open); | |
| 12 | + } | |
| 13 | + btn.addEventListener("click", function () { | |
| 14 | + set(!panel.classList.contains("open")); | |
| 15 | + }); | |
| 16 | + panel.addEventListener("click", function (e) { | |
| 17 | + if (e.target.closest("a") || e.target.closest("[data-ka-mnav-close]")) set(false); | |
| 18 | + }); | |
| 19 | + document.addEventListener("keydown", function (e) { | |
| 20 | + if (e.key === "Escape") set(false); | |
| 21 | + }); | |
| 22 | +})(); | |
| 23 | ||