PageSourceSearch

https://www.chargeops.net/assets/tech-ui-mobile-Dm0DMKIR.js

js chargeops.net collected 2026-10-02 02:06:53 UTC 461,105 bytes, 111 lines download raw bytes

1import{j as e}from"./vendor-react-QE7wLE3r.js";import{D as x,i as j,a as o,P as i,e as s,O as p,L as r,c as t,S as n,U as l,h as u,m as c,C as d,d as a,K as h}from"./ui-BfT7Kz5y.js";import"./index-C5UqyXey.js";import"./vendor-radix-C7HJG0re.js";import"./vendor-motion-DpnOviVe.js";function q(){return e.jsxs(x,{title:"10 — UI/UX, Mobile, Branding et Configuration système",lastEdited:"2026-09-30",intro:e.jsxs(i,{children:["Documentation technique exhaustive de la couche présentation de"," ",e.jsx(n,{children:"ChargeOps"})," : système de design, composants Radix, theming light/dark, animations Framer Motion, stratégie responsive mobile-first, branding personnalisable (logos Polara/ChargeOps, marqueurs cartes, animations de chargement), configuration système (marques de bornes, modèles, carriers SIM, liens utiles) et internationalisation FR/EN."]}),children:[e.jsx(j,{items:[{label:"Développeur frontend",tone:"indigo"},{label:"Designer",tone:"violet"}]}),e.jsx(o,{id:"sources",title:"Sources",children:e.jsxs(i,{children:["Sources principales : ",e.jsx(s,{children:"client/src/App.tsx"}),","," ",e.jsx(s,{children:"client/src/components/app-layout.tsx"}),","," ",e.jsx(s,{children:"client/src/components/ui/"}),","," ",e.jsx(s,{children:"client/src/components/shell/"}),","," ",e.jsx(s,{children:"client/src/pages/branding.tsx"}),","," ",e.jsx(s,{children:"client/src/pages/configuration.tsx"}),","," ",e.jsx(s,{children:"client/src/hooks/"}),","," ",e.jsx(s,{children:"client/src/lib/translations.ts"}),","," ",e.jsx(s,{children:"client/src/index.css"}),", ",e.jsx(s,{children:"tailwind.config.ts"}),","," ",e.jsx(s,{children:"convex/schema.ts"}),", ",e.jsx(s,{children:"convex/configuration/"}),","," ",e.jsx(s,{children:"extension/"}),", ",e.jsx(s,{children:"vite.extension.config.ts"}),","," ",e.jsx(s,{children:"convex/extension/queries.ts"}),","," ",e.jsx(s,{children:"scripts/package-extension.mjs"}),","," ",e.jsx(s,{children:"extension/store/"}),"."]})}),e.jsx(o,{id:"table-matieres",title:"Table des matières",children:e.jsxs(p,{children:[e.jsx(r,{children:"Partie 1 — Système UI général"}),e.jsx(r,{children:"Partie 2 — Mobile et responsive"}),e.jsx(r,{children:"Partie 3 — Branding et customisation"}),e.jsx(r,{children:"Partie 4 — Configuration système"}),e.jsx(r,{children:"Partie 5 — Internationalisation (i18n)"}),e.jsx(r,{children:"Partie 6 — Analytics frontend"}),e.jsx(r,{children:"Partie 7 — Extension Chrome officielle (MV3)"}),e.jsx(r,{children:"Partie 8 — Mode terrain hors ligne"})]})}),e.jsxs(o,{id:"partie-1",title:"Partie 1 — Système UI général",children:[e.jsxs(t,{id:"vue-ensemble",title:"1. Vue d'ensemble du design",children:[e.jsxs(i,{children:["ChargeOps adopte une esthétique ",e.jsx(n,{children:"moderne"}),","," ",e.jsx(n,{children:"glassmorphique"})," et"," ",e.jsx(n,{children:"pleinement bi-thème"})," (clair / sombre). Les principes directeurs sont :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Glassmorphism"})," : panneaux flous (",e.jsx(s,{children:"backdrop-blur"}),") avec bordures translucides (",e.jsx(s,{children:"rgba(255,255,255,0.x)"}),"), saturations relevées (",e.jsx(s,{children:"saturate(1.8)"}),") et lueurs colorées subtiles. Visible dans ",e.jsx(s,{children:"header-glass"}),", ",e.jsx(s,{children:"glass-toast"}),", le menu profil (",e.jsx(s,{children:"!bg-white/55 dark:!bg-gray-900/60 backdrop-blur-2xl"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Dark mode natif"})," : pilote"," ",e.jsx(s,{children:'darkMode: ["class"]'})," dans ",e.jsx(s,{children:"tailwind.config.ts"}),". La classe ",e.jsx(s,{children:"dark"})," est ajoutée à ",e.jsx(s,{children:"<html>"})," ","par ",e.jsx(s,{children:"ThemeProvider"}),". Les variables CSS HSL changent selon ",e.jsx(s,{children:":root"})," et ",e.jsx(s,{children:".dark"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Transitions de page"})," : Framer Motion gère les apparitions de blocs (",e.jsx(s,{children:"initial"}),", ",e.jsx(s,{children:"animate"}),","," ",e.jsx(s,{children:"transition"}),") avec"," ",e.jsxs(s,{children:["LazyMotion features=","{domAnimation}"]})," au sommet de l'arbre (voir ",e.jsx(s,{children:"App.tsx"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Accessibilité"})," :",e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(s,{children:":focus-visible"})," global ",e.jsx(n,{children:"bi-ton"})," : un contour opaque ",e.jsx(s,{children:"var(--focus-ring)"})," (émis après la couche components, donc à spécificité égale il l'emporte sur le ",e.jsx(s,{children:"outline-none"})," propre à"," ",e.jsx(s,{children:".btn-3d"}),") plus un halo"," ",e.jsx(s,{children:"var(--focus-halo)"})," déclaré dans"," ",e.jsx(s,{children:"@layer base"}),", qui remplit l'écart ouvert par"," ",e.jsx(s,{children:"outline-offset"}),". Les deux teintes s'inversent entre thèmes clair et sombre, de sorte qu'au moins l'une ressorte sur n'importe quel fond — dont l'en-tête marine, qui est un wrapper ",e.jsx(s,{children:"dark"})," local. Le halo est en"," ",e.jsx(s,{children:"base"})," et non hors couche parce que les utilitaires"," ",e.jsx(s,{children:"ring-*"})," de Tailwind sont eux-mêmes des"," ",e.jsx(s,{children:"box-shadow"})," : un élément qui porte son propre"," ",e.jsx(s,{children:"focus-visible:ring-*"})," le remplace proprement au lieu d'être écrasé."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Boutons"})," : le ",e.jsx(s,{children:"Button"})," shadcn porte"," ",e.jsx(s,{children:"focus-visible:outline-none"}),", un utilitaire (0,2,0) qui l'emporte sur le contour global (0,1,0) — le"," ",e.jsx(s,{children:"@layer"})," de Tailwind 3 n'est pas une vraie couche de cascade, c'est la spécificité qui tranche — et son"," ",e.jsx(s,{children:"focus-visible:ring-2"})," est un box-shadow que chaque halo ",e.jsx(s,{children:"html.theme-cleo .btn-*"})," (0,2,1) remplace. Aucune variante ",e.jsx(s,{children:".btn-3d"})," ne montrait donc de focus, jusqu'à ce que"," ",e.jsx(s,{children:"html.theme-cleo .btn-3d:focus-visible"})," (0,3,1) dans ",e.jsx(s,{children:"theme-cleo.css"})," rétablisse le contour (CHARGEOPS-523, verrouillé par"," ",e.jsx(s,{children:"components/ui/__tests__/button-focus-visible.test.ts"}),"). Tout autre élément qui combine"," ",e.jsx(s,{children:"focus-visible:outline-none"})," et son propre box-shadow a besoin du même traitement."]}),e.jsxs(r,{children:["Les anneaux de focus s'écrivent"," ",e.jsx(s,{children:"focus-visible:"})," et jamais ",e.jsx(s,{children:"focus:"}),", pour qu'un clic de souris ne laisse pas d'anneau. Exception volontaire : ",e.jsx(s,{children:"focus:bg-accent"})," sur les items Radix (",e.jsx(s,{children:"DropdownMenuItem"}),", ",e.jsx(s,{children:"ContextMenuItem"}),","," ",e.jsx(s,{children:"SelectItem"}),") et les jours de"," ",e.jsx(s,{children:"react-day-picker"})," — Radix leur donne le focus"," ",e.jsx(n,{children:"au survol de la souris"}),", que"," ",e.jsx(s,{children:":focus-visible"})," ne capte pas. Les deux règles sont verrouillées par"," ",e.jsx(s,{children:"components/ui/__tests__/focus-visible.test.ts"}),"."]}),e.jsxs(r,{children:["Préférence ",e.jsx(s,{children:"prefers-reduced-motion: reduce"})," honorée — les durées d'animation passent à ",e.jsx(s,{children:"0.01ms"}),"."]}),e.jsxs(r,{children:["Labels ARIA présents (",e.jsx(s,{children:"aria-label"}),","," ",e.jsx(s,{children:"data-testid"})," partout pour les tests)."]}),e.jsx(r,{children:"Targets touch ≥ 32 px sur mobile."})]})]})]})]}),e.jsxs(t,{id:"catalogue-radix",title:"2. Composants Radix UI — catalogue",children:[e.jsxs(i,{children:["Tous les composants partagés vivent dans"," ",e.jsx(s,{children:"client/src/components/ui/"}),". Ils sont basés sur"," ",e.jsx(n,{children:"Radix UI primitives"})," stylées via Tailwind +"," ",e.jsx(s,{children:"class-variance-
1authority"}),". Catalogue complet :"]}),e.jsx(u,{headers:["Catégorie","Composants"],rows:[[e.jsx(n,{children:"Overlays"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"dialog.tsx"}),", ",e.jsx(s,{children:"alert-dialog.tsx"}),","," ",e.jsx(s,{children:"sheet.tsx"}),", ",e.jsx(s,{children:"drawer.tsx"}),","," ",e.jsx(s,{children:"popover.tsx"}),", ",e.jsx(s,{children:"hover-card.tsx"}),","," ",e.jsx(s,{children:"tooltip.tsx"}),", ",e.jsx(s,{children:"consolidated-tooltip.tsx"}),","," ",e.jsx(s,{children:"animated-tooltip.tsx"}),","," ",e.jsx(s,{children:"portal-tooltip.tsx"}),","," ",e.jsx(s,{children:"left-docked-tooltip.tsx"}),","," ",e.jsx(s,{children:"truncated-text.tsx"})]})],[e.jsx(n,{children:"Menus"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"dropdown-menu.tsx"}),","," ",e.jsx(s,{children:"context-menu.tsx"}),", ",e.jsx(s,{children:"menubar.tsx"}),","," ",e.jsx(s,{children:"navigation-menu.tsx"}),", ",e.jsx(s,{children:"command.tsx"})]})],[e.jsx(n,{children:"Inputs"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"input.tsx"}),", ",e.jsx(s,{children:"textarea.tsx"}),","," ",e.jsx(s,{children:"input-otp.tsx"}),", ",e.jsx(s,{children:"select.tsx"}),","," ",e.jsx(s,{children:"checkbox.tsx"}),", ",e.jsx(s,{children:"switch.tsx"}),","," ",e.jsx(s,{children:"radio-group.tsx"}),", ",e.jsx(s,{children:"slider.tsx"}),","," ",e.jsx(s,{children:"toggle.tsx"}),", ",e.jsx(s,{children:"toggle-group.tsx"}),","," ",e.jsx(s,{children:"date-picker.tsx"}),", ",e.jsx(s,{children:"calendar.tsx"})]})],[e.jsx(n,{children:"Layout"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"card.tsx"}),", ",e.jsx(s,{children:"field-card.tsx"}),","," ",e.jsx(s,{children:"separator.tsx"}),", ",e.jsx(s,{children:"aspect-ratio.tsx"}),","," ",e.jsx(s,{children:"resizable.tsx"}),", ",e.jsx(s,{children:"scroll-area.tsx"}),","," ",e.jsx(s,{children:"sidebar.tsx"}),", ",e.jsx(s,{children:"accordion.tsx"}),","," ",e.jsx(s,{children:"collapsible.tsx"}),", ",e.jsx(s,{children:"tabs.tsx"})]})],[e.jsx(n,{children:"Feedback"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"toast.tsx"}),", ",e.jsx(s,{children:"toaster.tsx"}),","," ",e.jsx(s,{children:"progress.tsx"}),", ",e.jsx(s,{children:"skeleton.tsx"}),","," ",e.jsx(s,{children:"alert.tsx"}),", ",e.jsx(s,{children:"badge.tsx"})]})],[e.jsx(n,{children:"Navigation"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"breadcrumb.tsx"}),", ",e.jsx(s,{children:"pagination.tsx"}),","," ",e.jsx(s,{children:"view-mode-toggle.tsx"})]})],[e.jsx(n,{children:"Forms"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"form.tsx"}),", ",e.jsx(s,{children:"label.tsx"})]})],[e.jsx(n,{children:"Tableaux"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"table.tsx"}),", ",e.jsx(s,{children:"carousel.tsx"})]})],[e.jsx(n,{children:"Boutons"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"button.tsx"}),", ",e.jsx(s,{children:"shimmer-button.tsx"})]})],[e.jsx(n,{children:"Décoratifs"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"aurora-text.tsx"}),", ",e.jsx(s,{children:"shine-border.tsx"}),","," ",e.jsx(s,{children:"spotlight-card.tsx"}),", ",e.jsx(s,{children:"light-rays.tsx"}),","," ",e.jsx(s,{children:"animated-grid-pattern.tsx"})]})],[e.jsx(n,{children:"Branding"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"branded-logo.tsx"}),", ",e.jsx(s,{children:"favicon-icon.tsx"}),","," ",e.jsx(s,{children:"theme-toggle.tsx"})]})],[e.jsx(n,{children:"Loaders"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"page-loading.tsx"}),", ",e.jsx(s,{children:"section-loading.tsx"}),","," ",e.jsx(s,{children:"tab-loading.tsx"})]})],[e.jsx(n,{children:"Avatars"}),e.jsx(s,{children:"avatar.tsx"})],[e.jsx(n,{children:"Footer"}),e.jsx(s,{children:"footer.tsx"})]]}),e.jsxs(i,{children:["Les composants sont importés via l'alias"," ",e.jsx(s,{children:"@/components/ui/*"})," (configuré dans"," ",e.jsx(s,{children:"tsconfig.json"}),")."]}),e.jsxs(i,{children:[e.jsxs(n,{children:[e.jsx(s,{children:"truncated-text.tsx"})," — texte tronqué révélé au survol"]})," ",": ",e.jsx(s,{children:"<TruncatedText text={…} />"})," rend une ligne"," ",e.jsx(s,{children:"truncate"})," et n'ouvre une infobulle"," ",e.jsx(n,{children:"que si le texte déborde réellement"}),". Un"," ",e.jsx(s,{children:"ResizeObserver"})," compare"," ",e.jsx(s,{children:"scrollWidth > clientWidth + 1"})," (la tolérance d'un pixel évite les faux positifs dus aux largeurs sous-pixel) et réévalue à chaque changement de largeur de colonne ; l'état est exposé en ",e.jsx(s,{children:"data-truncated"}),". Le déclencheur reste toujours monté — seul ",e.jsx(s,{children:"TooltipContent"})," est conditionnel — pour que l'observateur ne perde jamais son nœud. Le rendu est un"," ",e.jsx(s,{children:'<span class="block">'})," et non un ",e.jsx(s,{children:"div"})," : les listes de liaison tronquent leur titre à l'intérieur d'un"," ",e.jsx(s,{children:"button"}),", où un ",e.jsx(s,{children:"div"})," serait invalide. Usages : le titre des cas dans le tableau de ",e.jsx(s,{children:"/support"}),", la carte des rapports liés (",e.jsx(s,{children:"ticket-details-card.tsx"}),") et les deux fenêtres de liaison (",e.jsx(s,{children:"link-ticket-dialog.tsx"}),","," ",e.jsx(s,{children:"link-service-report-dialog.tsx"}),")."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Lignes cliquables et ouverture dans un nouvel onglet"})," ",": un ",e.jsx(s,{children:"onClick"})," sur un ",e.jsx(s,{children:"<tr>"})," navigue, mais il ne peut ",e.jsx(n,{children:"pas"})," ouvrir d'onglet — le clic du milieu émet ",e.jsx(s,{children:"auxclick"}),", jamais ",e.jsx(s,{children:"click"}),", et le menu contextuel n'a aucun lien à proposer. Le patron retenu dans les listes de cas (",e.jsx(s,{children:"support-tickets.tsx"}),","," ",e.jsx(s,{children:"portal/cases.tsx"}),") enveloppe donc le titre dans un"," ",e.jsx(s,{children:"<Link>"})," wouter — un vrai ",e.jsx(s,{children:"<a href>"}),", dont le navigateur gère nativement le clic du milieu, le Ctrl/⌘ + clic et « Ouvrir dans un nouvel onglet ». Deux garde-fous l'accompagnent : le lien fait ",e.jsx(s,{children:"stopPropagation()"})," au clic gauche (sinon la ligne naviguerait une seconde fois vers la même route), et le ",e.jsx(s,{children:"onClick"})," de la ligne ignore les clics"," ",e.jsx(s,{children:"button !== 0"})," ou porteurs de"," ",e.jsx(s,{children:"ctrl/meta/alt/shift"})," — wouter court-circuite justement son propre gestionnaire dans ces cas-là, si bien qu'un Ctrl + clic enverrait autrement le même clic vers deux destinations (nouvel onglet ",e.jsx("em",{children:"et"})," navigation de l'onglet courant). Dans"," ",e.jsx(s,{children:"/support"})," la ligne reste l'unique arrêt de tabulation (le lien porte ",e.jsx(s,{children:"tabIndex={-1}"}),", la ligne gardant son ",e.jsx(s,{children:"aria-label"})," et sa touche Entrée) ; dans le portail, où la ligne n'était pas focusable, le lien devient au contraire le chemin clavier vers le cas. La corbeille (",e.jsx(s,{children:"showDeleted"}),") n'a pas de lien : un cas supprimé n'a pas de page de détail."]})]}),e.jsxs(t,{id:"theming",title:"3. Theming — variables CSS et persistance",children:[e.jsxs(i,{children:["Les couleurs sont exposées comme"," ",e.jsx(n,{children:"variables CSS HSL"})," (",e.jsx(s,{children:"--background"}),","," ",e.jsx(s,{children:"--foreground"}),", ",e.jsx(s,{children:"--card"}),", ",e.jsx(s,{children:"--primary"}),", etc.) dans ",e.jsx(s,{children:"client/src/index.css"})," et consommées par"," ",e.jsx(s,{children:"tailwind.config.ts"})," via"," ",e.jsx(s,{children:'colors: { background: "var(--background)" }'}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Mode clair"})," (",e.jsx(s,{children:":root"}),") :"]}),e.jsx(c,{language:"css",children:`--background: hsl(0, 0%, 100%);
2--foreground: hsl(20, 14.3%, 4.1%);
3--primary:    hsl(223, 89%, 55%);
4--card:       hsl(0, 0%, 100%);`}),e.jsxs(i,{children:[e.jsx(n,{children:"Mode sombre"})," (",e.jsx(s,{children:".dark"}),") :"]}),e.jsx(c,{language:"css",children:`--background: hsl(240, 10%, 3.9%);
5--foreground: hsl(0, 0%, 98%);
6--primary:    hsl(223, 89%, 55%);
7--card:       hsl(240, 10%, 3.9%);`}),e.jsxs(i,{children:[e.jsx(n,{children:"Persistance"})," —"," ",e.jsx(s,{children:"client/src/hooks/use-theme.tsx"})," :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Clé localStorage : ",e.jsx(s,{children:"app-theme"})," (valeurs :"," ",e.jsx(s,{children:'"light"'})," | ",e.jsx(s,{children:'"dark"'}),")."]}),e.jsxs(r,{children:["L'état initial lit ",e.jsx(s,{children:'localStorage.getItem("app-theme")'})," ","au premier rendu (évite le flash)."]}),e.jsxs(r,{children:["À chaque changement, la classe ",e.jsx(s,{children:"dark"})," est ajoutée/retirée de ",e.jsx(s,{children:"document.documentElement"}),"."]}),e.jsxs(r,{children:["Le ",e.jsx(s,{children:"ThemeToggle"})," (",e.jsx(s,{children:"ui/theme-toggle.tsx"}
7),") utilise l'",e.jsx(n,{children:"API View Transitions"})," (",e.jsx(s,{children:"document.startViewTransition"}),") pour produire un effet"," ",e.jsx(n,{children:"clip-path circulaire"})," qui se déploie depuis le bouton lors du switch."]})]})]}),e.jsxs(t,{id:"cleo-design-system",title:"Système de design Cléo (le seul design)",children:[e.jsxs(i,{children:["Cléo ",e.jsx(n,{children:"est"})," le design de l'application : il n'existe pas de second design et rien à basculer. Aucun flag, provider, hook ni onglet de réglages n'en décide à l'exécution — la classe"," ",e.jsx(s,{children:"theme-cleo"})," est écrite en dur sur"," ",e.jsx(s,{children:"<html>"})," dans ",e.jsx(s,{children:"client/index.html"}),"."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Mécanisme"})," : sous cette classe, deux feuilles habillent l'application sans modifier les layouts, les routes ni les textes. ",e.jsx(s,{children:"client/src/styles/theme-cleo.css"})," est la couche de jetons (palette indigo Cléo, typographie Indivisible auto-hébergée, boutons en pilule, cartes à rayon 16 px) ;"," ",e.jsx(s,{children:"client/src/styles/theme-cleo-extras.css"})," est la couche de composants (les surfaces ",e.jsx(s,{children:".co-*"})," et les surcharges de classes maison)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"En place avant le premier rendu."})," La classe étant dans le document HTML lui-même, le thème s'applique"," ",e.jsx("em",{children:"avant le premier rendu"})," — rien n'attend l'hydratation, donc un chargement à froid n'affiche jamais de surfaces non stylées."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le balisage ne porte aucun embranchement de design."})," ","Un composant nomme sa surface — ",e.jsx(s,{children:"co-card"}),","," ",e.jsx(s,{children:"co-card-head"}),", ",e.jsx(s,{children:"co-card-title"}),","," ",e.jsx(s,{children:"co-card-inset"}),", ",e.jsx(s,{children:"co-tile"}),","," ",e.jsx(s,{children:"co-chip"}),", ",e.jsx(s,{children:"co-pill"}),","," ",e.jsx(s,{children:"co-field"}),", ",e.jsx(s,{children:"co-row"}),","," ",e.jsx(s,{children:"co-thead"}),", ",e.jsx(s,{children:"co-empty"}),","," ",e.jsx(s,{children:"co-nums"}),", ",e.jsx(s,{children:"co-link-chip"}),", et, pour la plaque signalétique des rapports de maintenance,"," ",e.jsx(s,{children:"co-plate"}),", ",e.jsx(s,{children:"co-plate-band"}),","," ",e.jsx(s,{children:"co-plate-flag"}),", ",e.jsx(s,{children:"co-plate-action"}),","," ",e.jsx(s,{children:"co-led"}),", ",e.jsx(s,{children:"co-spec"}),","," ",e.jsx(s,{children:"co-spec-label"}),", ",e.jsx(s,{children:"co-readout"}),","," ",e.jsx(s,{children:"co-readout-boxed"}),", ",e.jsx(s,{children:"co-readout-cell"}),","," ",e.jsx(s,{children:"co-sev-ink"}),", ",e.jsx(s,{children:"co-tag"})," — et la feuille de style l'habille. Il n'y a ni nom de classe conditionnel ni second chemin de code à garder synchronisé ; restyler une surface est un changement de feuille de style, pas de balisage."]}),e.jsxs(r,{children:[e.jsxs(n,{children:[e.jsx(s,{children:"co-hero"})," : la bande bleu nuit de marque."]})," ","L'en-tête d'identité d'une page de détail sur ordinateur (passeport de borne, côté personnel comme côté portail, page d'organisation, cas de support, rapport d'intervention, rapport de maintenance, garantie prolongée et bon de commande) repose sur"," ",e.jsx(s,{children:"#00004B"})," dans les deux thèmes, avec un titre en typographie d'affichage (",e.jsx(s,{children:"display-heading"}),") et un filet du dégradé de marque en pied. Ce n'est"," ",e.jsx("em",{children:"pas"})," un îlot ",e.jsx(s,{children:".dark"})," : plus de 60 règles Cléo visent ",e.jsx(s,{children:"html.theme-cleo.dark"})," et ne suivraient pas une classe posée plus bas. La bande redéfinit plutôt les jetons que son contenu lit (",e.jsx(s,{children:"--foreground"}),","," ",e.jsx(s,{children:"--muted-foreground"}),", ",e.jsx(s,{children:"--secondary"}),","," ",e.jsx(s,{children:"--muted"}),"…) avec les valeurs de Cléo sombre, les états de ",e.jsx(s,{children:"co-chip"})," et de ",e.jsx(s,{children:"co-pill"})," à couleur littérale rejoignent leur règle sombre (la pastille d'état en direct est un ",e.jsx(s,{children:"co-pill"}),"), et l'anneau de focus clavier prend la paire ",e.jsx(s,{children:"--focus-ring"})," /"," ",e.jsx(s,{children:"--focus-halo"})," de ",e.jsx(s,{children:".dark"}),". Tout ce qu'on y place doit lire des jetons (",e.jsx(s,{children:"text-foreground"}),", jamais ",e.jsx(s,{children:"text-gray-900"}),") ; ",e.jsx(s,{children:"co-hero.test.ts"})," le vérifie. La bande ne porte que l'identité : les rangées d'actions (boutons outline, listes déroulantes) restent sur la carte en dessous, car ce sont des contrôles clairs sans variante de bande. Un lien d'entité placé dedans reçoit ",e.jsx(s,{children:"heroLinkClass"})," (",e.jsx(s,{children:"entity-links.tsx"}),") : son survol par défaut passe au"," ",e.jsx(s,{children:"brand-600"})," en clair, environ 1,4:1 sur le bleu nuit. Sur téléphone, l'en-tête reste celui de"," ",e.jsx(s,{children:"DetailLayout"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La couleur de marque, ce sont des jetons."})," ",e.jsx(s,{children:"brand-*"}),", ",e.jsx(s,{children:"sky-*"})," et"," ",e.jsx(s,{children:"indigo-*"})," sont des familles de couleurs Tailwind adossées aux ",e.jsx("em",{children:"triplets de canaux"})," ",e.jsx(s,{children:"--brand-N"})," / ",e.jsx(s,{children:"--sky-N"})," /"," ",e.jsx(s,{children:"--indigo-N"})," (",e.jsx(s,{children:"74 58 214"}),", pas un hex), que la config lit comme"," ",e.jsx(s,{children:"rgb(var(--brand-500) / <alpha-value>)"}),"."," ",e.jsx(s,{children:"theme-cleo.css"})," réassigne les 33 — voir « v2 » ci-dessous."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Les neutres aussi sont des jetons."})," ",e.jsx(s,{children:"slate-*"})," et ",e.jsx(s,{children:"gray-*"})," lisent"," ",e.jsx(s,{children:"--slate-N"})," / ",e.jsx(s,{children:"--gray-N"}),", avec le triplet de Tailwind comme valeur de repli du ",e.jsx(s,{children:"var()"})," : un paquet qui ne charge jamais ",e.jsx(s,{children:"index.css"})," (la page publique des rapports) affiche Tailwind tel quel. Cléo sombre ne réassigne que 700 à 950, vers ses surfaces bleu nuit (950 = le fond de page, 900 = le palier d'encart"," ",e.jsx(s,{children:"--muted"}),", 800 / 700 = la carte + 10 % / 20 % de blanc), chacune calée sur la luminance de l'échelon qu'elle remplace : ",e.jsx(s,{children:"dark:bg-slate-900"})," est bleu nuit, avec le contraste qu'il a toujours eu. Le cadre d'aperçu du Studio d'image de marque pose"," ",e.jsx(s,{children:"data-theme-preview"})," pour prendre l'échelle du thème qu'il montre. Pour un texte neutre, écrire"," ",e.jsx(s,{children:"text-slate-500 dark:text-slate-400"}),", jamais l'inverse ",e.jsx(s,{children:"text-slate-400 dark:text-slate-500"})," : c'est le choix le plus pâle dans les deux thèmes, et"," ",e.jsx(s,{children:"neutral-ramp.test.ts"})," le maintient à zéro."]}
7),e.jsxs(r,{children:[e.jsxs(n,{children:["Le préfixe ",e.jsx(s,{children:"html.theme-cleo"})," reste, et ce n'est pas un oubli."]})," ","La classe étant toujours présente, il ne sélectionne plus rien — il ",e.jsx("em",{children:"porte de la spécificité"}),". Ces feuilles sont"," ",e.jsx(s,{children:"@import"}),"ées avant ",e.jsx(s,{children:"@tailwind utilities"}),", donc à spécificité égale un utilitaire gagne par ordre d'apparition ; le ",e.jsx(s,{children:"(0,1,1)"})," du préfixe est ce qui tient le bloc de jetons devant le ",e.jsx(s,{children:":root"})," d'",e.jsx(s,{children:"index.css"}),". Mesuré : le retirer change 588 déclarations calculées sur 236 des 238 états testés."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Un seul nom par surface."})," Les classes"," ",e.jsx(s,{children:"cleo-*"})," et ",e.jsx(s,{children:"co-*"})," désignaient les mêmes surfaces et devaient être citées ensemble via"," ",e.jsx(s,{children:":is(.cleo-x, .co-x)"})," dans chaque règle. Une règle l'a oublié — le relèvement des encarts imbriqués dans"," ",e.jsx(s,{children:".settings-canvas"})," — et 130 des 147 encarts gardaient la couleur du panneau qui les contient. L'écriture"," ",e.jsx(s,{children:"cleo-*"})," est retirée (550 occurrences, 117 fichiers) et un test échoue si elle revient."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"v2 — couverture étendue, puis jetons."})," Une couche générée de 87 Ko remappait toute la palette Tailwind bleu/sky/indigo via des sélecteurs"," ",e.jsx(s,{children:"html.theme-cleo .util"}),". Elle est"," ",e.jsx(n,{children:"supprimée"})," : les trois familles sont maintenant des couleurs Tailwind adossées à des jetons (",e.jsx(s,{children:"--brand-N"}),","," ",e.jsx(s,{children:"--sky-N"}),", ",e.jsx(s,{children:"--indigo-N"}),"), que Cléo réassigne dans ",e.jsx(s,{children:"styles/theme-cleo.css"}),". Le thème échange 33 variables au lieu de surcharger ~2 000 sélecteurs, et le piège de spécificité disparaît avec la feuille. Les surcharges de classes personnalisées (",e.jsx(s,{children:"theme-cleo-extras.css"}),") et les couleurs de graphiques adaptées à Cléo restent. La palette des transformateurs reste un arc-en-ciel distinct sous Cléo (T1 utilise un accent clair pour ne pas se confondre avec l'indigo T5)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Les redéfinitions d'utilitaires Tailwind écrites à la main sont parties avec."})," ",e.jsx(s,{children:"index.css"})," portait une vingtaine de règles nommées d'après les utilitaires de Tailwind eux-mêmes —"," ",e.jsx(s,{children:".text-green-800"}),", ",e.jsx(s,{children:".dark .text-purple-600"}),","," ",e.jsx(s,{children:".from-purple-50"})," — non scopées et placées après la couche d'utilitaires, donc gagnantes par ordre d'apparition. Trois défauts sortaient de cette seule forme, et aucun ne pouvait faire échouer un build :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Le nom mentait."})," ",e.jsx(s,{children:".text-green-800"})," ","peignait un vert menthe (emerald-400), pas le vert forêt"," ",e.jsx(s,{children:"#166534"})," — donc une étiquette"," ",e.jsx(s,{children:"bg-green-100 text-green-800"})," était à"," ",e.jsx(n,{children:"1,75:1"}),", où le plancher pour du texte est 4,5:1. Elle est à 6,49."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Les arrêts de dégradé posaient un fond complet."})," ",e.jsx(s,{children:"from-purple-50 via-purple-100 to-purple-200"})," se réduisait à celui des trois qui venait en dernier dans le fichier."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La moitié `.dark` volait la variante du markup."})," ",e.jsx(s,{children:".dark .text-purple-800"})," est à (0,2,0) et l'emportait sur le ",e.jsx(s,{children:"dark:text-purple-300"})," demandé au point d'appel, à (0,1,0) : violet quasi noir sur fond sombre,"," ",e.jsx(n,{children:"1,08:1"}),". Onze bordures"," ",e.jsx(s,{children:"border-gray-200/50"})," demandaient cinq gris différents et recevaient le même."]})]}),e.jsxs(i,{children:["Les 15 emplacements en ",e.jsx(s,{children:"-600"})," (icônes, chiffres) sans variante sombre en ont reçu une dans la même passe : le"," ",e.jsx(s,{children:"-600"})," honnête de Tailwind est plus foncé que la menthe que ces règles peignaient. Les neuf paires encre/fond mesurées progressent toutes."," ",e.jsx(s,{children:"tailwind-color-tokens.test.ts"})," échoue si une règle nommée d'après un utilitaire de couleur Tailwind réapparaît."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Contours des contrôles en mode sombre"})," : le critère WCAG 1.4.11 exige 3:1 pour le bord qui montre un contrôle."," ",e.jsx(s,{children:"--primary"})," vaut ",e.jsx(s,{children:"#5648E0"})," en Cléo sombre, 3:1 sur la carte et moins sur chaque surface plus claire : une case à cocher dessinée par ",e.jsx(s,{children:"border-primary"})," seul mesurait 2,56 à 3,20:1 (composé dans un rendu réel sur la carte, l'encart, le fond atténué, la ligne survolée, la tuile, la fenêtre et le popover), tout comme le curseur du slider. ",e.jsx(s,{children:"ui/checkbox.tsx"}),","," ",e.jsx(s,{children:"ui/radio-group.tsx"})," et ",e.jsx(s,{children:"ui/slider.tsx"})," ","ajoutent ",e.jsx(s,{children:"dark:border-brand-300"})," : 5,56 à 6,96:1 (le point du bouton radio suit son anneau avec"," ",e.jsx(s,{children:"dark:text-brand-300"}),"). Une case cochée garde ce contour autour de son remplissage"," ",e.jsx(s,{children:"bg-primary"})," : un contour qui suit le remplissage retombe à 2,56:1 sur une ligne survolée. Les coches et boutons radio dessinés à la main (sélecteur de bornes du rapport de service, filtre de stations des sessions, choix d'organisation de l'assistant de site) tracent maintenant le même contour ; le leur était"," ",e.jsx(s,{children:"border-border"})," (1,24 à 1,34:1) ou"," ",e.jsx(s,{children:"border-muted-foreground
7/40"})," (2,08 à 2,21:1), et sous le seuil en mode clair aussi (1,23 à 1,69:1, maintenant 7,9 à 9,9:1). Les options de la fenêtre « Déplacer la borne » remplissent leur point de la teinte de l'option une fois choisies : leur anneau vide reste donc neutre mais fonce,"," ",e.jsx(s,{children:"border-slate-500 dark:border-slate-400"})," (4,76:1 et 7,15 à 7,46:1, contre 1,48 et 2,42 à 2,52:1)."," ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," échoue si un"," ",e.jsx(s,{children:"border-primary"})," au repos n'a pas de jumeau"," ",e.jsx(s,{children:"dark:border-brand-*"})," (teintes 50 à 300) sur la même ligne, sauf une courte liste d'accents qui ne portent seuls ni le contour ni l'état : la barre de l'ancre active, l'étape courante de l'assistant et la carte d'organisation choisie."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Interrupteurs et anneaux de sélection"})," : même seuil de 3:1, mesuré dans le même genre de rendu (CHARGEOPS-456)."," ",e.jsx(s,{children:"ui/switch.tsx"})," dessinait sa piste en position arrêt avec"," ",e.jsx(s,{children:"--input"}),", une encre à 18 % : 1,45 à 1,47:1 en mode clair, 1,61 à 1,74:1 en sombre, et le bouton rond à 1,43 à 2,18:1 dessus. La piste passe à ",e.jsx(s,{children:"bg-slate-500"})," dans les deux thèmes : 3,82 à 4,76:1 en clair et 3,32 à 4,16:1 en sombre, bouton à 4,64 et 4,16:1. Le remplissage activé ",e.jsx(s,{children:"bg-primary"})," ","mesurait 2,56 à 3,20:1 en sombre : l'interrupteur se sert de son"," ",e.jsx(s,{children:"border-2"})," transparent pour un contour"," ",e.jsx(s,{children:"dark:data-[state=checked]:border-brand-300"})," (5,56 à 6,96:1). Une sélection ou le jour courant marqués par un seul anneau avaient le même défaut : ",e.jsx(s,{children:"ring-brand-500"})," ","(",e.jsx(s,{children:"#4A3AD6"}),") mesurait 2,16 à 2,70:1 sur les surfaces sombres et ",e.jsx(s,{children:"ring-primary"})," 2,56 à 3,20:1. Ils prennent"," ",e.jsx(s,{children:"dark:ring-brand-300"})," (5,56 à 6,96:1, 5,13:1 au pire pour le bouton en encart du calendrier des visites, sans espace de décalage) : le calendrier des visites (visite choisie, jour courant dans les deux vues), les pastilles de teinte des files et des étiquettes de dossier, la couleur d'organisation de l'assistant de site, le choix d'autocollant QR, la constatation OCPP mise en évidence, l'événement mis en évidence du passeport de borne (son jumeau ",e.jsx(s,{children:"dark:ring-brand-500/60"})," mesurait 1,56 à 1,69:1), la cible de dépôt du gestionnaire de fichiers et la tuile d'indicateur de la conformité, dont le jumeau"," ",e.jsx(s,{children:"dark:ring-brand-500"})," était lui-même le défaut. Les tuiles qui portaient déjà ",e.jsx(s,{children:"dark:ring-brand-400"})," (3,32 à 4,15:1) le gardent. ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," échoue sur un ",e.jsx(s,{children:"ring-brand-500"})," (ou plus foncé) ou un"," ",e.jsx(s,{children:"ring-primary"})," au repos sans jumeau"," ",e.jsx(s,{children:"dark:ring-brand-*"})," de teinte 50 à 400 sur la même ligne, sur tout ",e.jsx(s,{children:"dark:ring-brand-500"})," ou"," ",e.jsx(s,{children:"dark:ring-primary"})," (translucide aussi, à côté de"," ",e.jsx(s,{children:"ring-2"}),"), et si l'interrupteur revient à"," ",e.jsx(s,{children:"--input"}),". La coche « Non liés seulement » de Cléo Link, dessinée à la main, était ",e.jsx(s,{children:"border-slate-400"})," (2,06 à 2,56:1 en clair) et, cochée, ",e.jsx(s,{children:"bg-amber-500"})," sur son étiquette ",e.jsx(s,{children:"amber-50"})," (2,07:1) : elle passe à"," ",e.jsx(s,{children:"border-slate-500 dark:border-slate-400"})," (3,82 à 4,76:1) et à ",e.jsx(s,{children:"amber-700"})," en clair (4,84:1), l'ambre 500 restant en sombre (6,92 à 8,55:1). Son entrée ",e.jsx(s,{children:"sr-only"})," n'avait aucun focus visible : l'étiquette trace maintenant"," ",e.jsx(s,{children:"has-[:focus-visible]:ring-2 ring-ring"})," avec un décalage, visible au clavier mais pas après un clic."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Options, puces et tuiles sélectionnées"})," : le reste des indicateurs de sélection, mesurés de la même façon (CHARGEOPS-466). Un anneau brand-300 de 1 px, avec un jumeau sombre brand-700, marquait ta propre réaction dans une conversation, le rapport ou le dossier choisi dans les deux dialogues de liaison, la réaction choisie dans le sélecteur de réactions et le chiffre actif de la bande de statistiques d'une liste : 2,52 à 2,84:1 en clair et 1,40 à 1,57:1 en sombre, sur une teinte de fond à 1,01 à 1,13:1 de la surface, qui ne porte donc rien. Ils reposent maintenant sur"," ",e.jsx(s,{children:"ring-brand-400"})," (4,22 à 4,77:1) avec un jumeau"," ",e.jsx(s,{children:"dark:ring-brand-300"})," (6,20 à 6,96:1) ; la bordure de la pastille de réaction suit son anneau. Une sélection tracée par un"," ",e.jsx(s,{children:"border-brand-500"})," au repos sur une teinte atteint 6,49 à 7,32:1 en clair, mais seulement 2,41 à 2,70:1 en sombre, et la barre gauche ",e.jsx(s,{children:"border-brand-600"})," d'une ligne choisie dans les listes des cartes clients et modems 1,79 à 2,01:1. Elles prennent"," ",e.jsx(s,{children:"dark:border-brand-300"}
7)," (6,20 à 6,96:1) : la tuile choisie du gestionnaire de fichiers, les puces d'inspection du rapport de maintenance, les options de borne de l'assistant, les cartes de langue du dialogue d'envoi et les deux lignes de liste de carte. L'option de l'assistant et la carte de langue portent aussi une coche, qui distinguait déjà l'état ; la bordure est maintenant d'accord avec elle. La forme à bordure pâle du même indicateur, une bordure brand-300 avec un jumeau sombre 700 ou 800 (1,05:1 pour le jumeau 800 sur le fond ",e.jsx(s,{children:"dark:bg-slate-800"})," des listes), passe à ",e.jsx(s,{children:"border-brand-400"})," avec"," ",e.jsx(s,{children:"dark:border-brand-300"})," (5,14:1 sur ce fond) : la pastille de réaction sous une publication, le choix de sections du rapport de maintenance et le locataire actif des listes du Studio de marque et de la personnalisation du menu. La carte choisie de la liste des soumissions, marquée seulement par un anneau à 30 %, prend la paire d'anneaux ci-dessus, et l'option choisie du dialogue de déplacement de borne (une bordure sombre translucide 500/50, 1,51:1) prend la paire de bordures, ou"," ",e.jsx(s,{children:"border-emerald-600 dark:border-emerald-400"})," (3,66 à 3,77:1 en clair, 9,65 à 10,29:1 en sombre) pour son option verte."," ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," épingle ces sites par classe : comme motif de ligne, on ne peut pas les distinguer des bords décoratifs de badges. Les filtres actifs de la liste des bornes (champ de recherche, déclencheurs de la grille de filtres et bouton Plus de filtres) et l'étape en cours du dialogue de déplacement de borne avaient la même bordure pâle, avec un jumeau sombre 700 ou 500/40 translucide : 2,52 à 2,84:1 en clair et 1,34 à 1,57:1 en sombre, plus faible en sombre que la bordure ardoise d'un déclencheur inactif (1,70 à 1,91:1). Ils prennent la même paire : 4,22 à 4,77:1 en clair, 6,20 à 6,96:1 en sombre, et sont épinglés dans le même test."," ","La cible de dépôt de l'onglet Fichiers était un anneau emerald-400, 1,70 à 1,92:1 en clair : elle passe à"," ",e.jsx(s,{children:"ring-emerald-600"})," (3,34 à 3,77:1), en gardant"," ",e.jsx(s,{children:"dark:ring-emerald-400"})," en sombre (9,17 à 10,29:1). La case de visite de la carte du planificateur a été mesurée et laissée telle quelle : son ",e.jsx(s,{children:"accent-brand-600"})," est à 8,18 à 9,85:1 sur la carte, dont le style ne suit pas le thème de l'application."," ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," échoue maintenant aussi sur un anneau qui a un jumeau sombre mais repose sur une teinte 300 ou plus pâle, sur un jumeau d'anneau sombre de teinte 600 ou plus foncée (le halo de glisser-déposer du studio de marque est listé : sa bordure brand-400 porte l'état), et sur une bordure brand-500 ou plus foncée au repos à côté d'une teinte ",e.jsx(s,{children:"bg-brand-50"})," ou"," ",e.jsx(s,{children:"bg-brand-100"})," sans jumeau de bordure sombre de teinte 50 à 400 (le badge Actif du résumé de licence est listé : son libellé nomme le statut)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Texte de marque en mode sombre"})," : Cléo garde la même rampe violette dans les deux thèmes, donc ",e.jsx(s,{children:"--brand-400"})," ","(et ",e.jsx(s,{children:"--indigo-400"}),") vaut ",e.jsx(s,{children:"110 94 230"})," sur la carte marine ",e.jsx(s,{children:"#0D0D38"})," : 3,3 à 4,2:1 mesuré, sous le seuil AA de 4,5:1. Les liens et libellés utilisent donc"," ",e.jsx(s,{children:"dark:text-brand-300"})," (5,6 à 7:1 sur toutes les surfaces sombres) et leur survol passe à ",e.jsx(s,{children:"brand-200"}),"."," ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," échoue si un"," ","texte sombre en ",e.jsx(s,{children:"brand-400"})," ou ",e.jsx(s,{children:"indigo-400"})," ","revient, et vérifie que ",e.jsx(s,{children:"brand-300"})," reste au-dessus de 4,5:1 sur chaque jeton de surface sombre. Un survol clair vers une teinte foncée (",e.jsx(s,{children:"hover:text-brand-600"}),","," ",e.jsx(s,{children:"group-hover:text-brand-500"}),") s'applique aussi en mode sombre s'il n'a pas de jumeau ",e.jsx(s,{children:"dark:"}),", et faisait tomber les liens à 1,6 à 2,7:1 au survol ; une base"," ",e.jsx(s,{children:"dark:text-*"})," ne protège pas d'un"," ",e.jsx(s,{children:"group-hover:"}),", qui la dépasse à (0,3,0). Le même test exige donc un ",e.jsx(s,{children:"dark:hover:text-*"})," (ou"," ",e.jsx(s,{children:"dark:group-hover:text-*"}),") sur la même ligne que tout"," ",e.jsx(s,{children:"hover:text-brand-500"})," à ",e.jsx(s,{children:"-900"})," ou son équivalent indigo (variantes focus comprises), en brand ou indigo"," ",e.jsx(s,{children:"50"})," à ",e.jsx(s,{children:"300"}),". La règle couvre aussi"," ",e.jsx(s,{children:"hover:text-primary"})," : ",e.jsx(s,{children:"--primary"})," vaut"," ",e.jsx(s,{children:"#5648E0"})," en Cléo sombre, 3:1 sur la carte, donc chaque"," ",e.jsx(s,{children:"(group-)hover:text-primary"})," porte un jumeau"," ",e.jsx(s,{children:"dark:(group-)hover:text-brand-200"}
7)," (2,6 à 3,2:1 avant, 9 à 11:1 après). Le jeton, lui, ne bouge pas : le libellé"," ",e.jsx(s,{children:"#FCFCFC"})," des boutons ",e.jsx(s,{children:"bg-primary"})," exige une couleur principale d'au plus 0,177 de luminance, alors qu'un texte principal lisible sur la carte en demande au moins 0,20 ; aucune valeur ne sert les deux."]}),e.jsxs(i,{children:[e.jsxs(n,{children:[e.jsx(s,{children:"text-primary"})," au repos en mode sombre"]})," :"," ",e.jsx(s,{children:"--primary"})," vaut ",e.jsx(s,{children:"#5648E0"})," en Cléo sombre, 3:1 sur la carte et moins sur une teinte ",e.jsx(s,{children:"bg-primary/10"}),", une carte de section des paramètres ou un bouton contour (jusqu'à 2,4:1). Chaque"," ",e.jsx(s,{children:"text-primary"})," au repos (la variante ",e.jsx(s,{children:"link"})," de"," ",e.jsx(s,{children:"ui/button.tsx"}),", l'ancre et l'élément de menu mobile actifs, pastilles, badges, l'alerte d'information, icônes et indicateurs de chargement) porte donc"," ",e.jsx(s,{children:"dark:text-brand-300"})," sur la même ligne. Mesuré par composition dans un rendu réel : de 2,4 à 3,1:1 pour le texte et de 2,9 à 3,3:1 pour les icônes avant, de 5,1 à 6,7:1 et de 6,3 à 7,1:1 après. Les icônes aussi : 3:1 est le plancher non textuel, sans aucune marge ; l'anneau du bouton radio prend de même"," ",e.jsx(s,{children:"dark:border-brand-300"}),". Un bouton lien qui impose sa propre couleur doit aussi en donner la forme ",e.jsx(s,{children:"dark:"}),", sinon le jumeau de la variante l'emporte en mode sombre (le « Tout Effacer » des sessions de recharge porte ",e.jsx(s,{children:"dark:text-muted-foreground"}),")."," ",e.jsx(s,{children:"dark-text-contrast.test.ts"})," échoue sur un"," ",e.jsx(s,{children:"text-primary"})," au repos sans jumeau"," ",e.jsx(s,{children:"dark:text-*"})," simple en brand ou indigo ",e.jsx(s,{children:"50"})," ","à ",e.jsx(s,{children:"300"}),"."]}),e.jsxs(i,{children:[e.jsxs(n,{children:["Jamais ",e.jsx(s,{children:"hsl(var(--primary))"})]})," ",": ",e.jsx(s,{children:"--primary"})," et ",e.jsx(s,{children:"--muted-foreground"})," sont des couleurs complètes (hex sous Cléo, ",e.jsx(s,{children:"hsl(…)"})," dans"," ",e.jsx(s,{children:"index.css"}),"), pas des canaux. ",e.jsx(s,{children:"hsl(#5648E0)"})," ","est invalide au moment du calcul : aucune erreur, la déclaration retombe simplement à ",e.jsx(s,{children:"unset"}),". Les liens de l'éditeur de rapport de service prenaient ainsi la couleur du texte, le bouton actif de sa barre d'outils perdait teinte et bordure, et l'étiquette ",e.jsx(s,{children:".settings-microlabel"})," n'était pas atténuée (CHARGEOPS-444). On utilise le jeton comme couleur —"," ",e.jsx(s,{children:"var(--primary)"}),", et"," ",e.jsx(s,{children:"color-mix(in srgb, var(--primary) 12%, transparent)"})," ","pour une teinte ; seules les familles à triplets de canaux se laissent envelopper (",e.jsx(s,{children:"rgb(var(--brand-300))"}),"). En mode sombre, les liens de l'éditeur et la bordure du bouton actif passent à ",e.jsx(s,{children:"brand-300"})," : 5,1:1 et 6,3:1 sur les surfaces ardoise de l'éditeur, où ",e.jsx(s,{children:"#5648E0"})," n'atteignait que 2,4 et 2,9:1."," ",e.jsx(s,{children:"token-colour-wrappers.test.ts"})," échoue si un"," ",e.jsx(s,{children:"hsl()"}),"/",e.jsx(s,{children:"rgb()"})," enveloppe à nouveau un jeton qui contient déjà une couleur."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Indicateurs opérationnels"})," : le tableau de bord de"," ",e.jsx(s,{children:"/operations-dashboard"})," (lien dans le menu profil — l'avatar en haut à droite — ouvert à tous les rôles) fusionne l'ancien « Centre des opérations » et les « Statistiques » : tuiles KPI (bornes, puissance, sites, véhicules, % Polara, GDP, garanties, moyennes), filtre organisation/site (combo de l'accueil, arg"," ",e.jsx(s,{children:"siteFilter"})," côté serveur), l'alerte active, top sites, répartition CA/CC, garanties, détail marque/modèle, véhicules, crédits carbone, détail par site et exports PDF/Excel — le tout depuis ",e.jsx(s,{children:"getChargerStatistics"}),". L'ancienne route"," ",e.jsx(s,{children:"/statistics"})," redirige ici."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Découpage en catégories"})," : la page est une liste de sept ",e.jsx(s,{children:"<section>"})," nommées —"," ",e.jsx(s,{children:"ops-overview"}),", ",e.jsx(s,{children:"ops-service"}),","," ",e.jsx(s,{children:"ops-chargers"}),", ",e.jsx(s,{children:"ops-warranty"}),","," ",e.jsx(s,{children:"ops-sites"}),", ",e.jsx(s,{children:"ops-vehicles"}),","," ",e.jsx(s,{children:"ops-carbon"})," — chacune coiffée d'une bannière"," ",e.jsx(s,{children:"SectionHeading"})," (",e.jsx(s,{children:"components/operations/section-heading.tsx"}),", extraite du bandeau du bloc service pour que tous les groupes partagent la même grammaire : puce ",e.jsx(s,{children:"co-chip"}),", titre, sous-titre, action optionnelle à droite). ",e.jsx(s,{children:"DashboardSection"})," emballe bannière + panneaux ; le bloc service garde son propre rendu parce que le sélecteur de période glissante voyage dans sa bannière. L'",e.jsx(s,{children:"id"})," de la bannière est l'ancre de navigation, et son"," ",e.jsx(s,{children:"h2"})," porte ",e.jsx(s,{children:"{id}-title"})," pour l'",e.jsx(s,{children:"aria-labelledby"})," de la section."]}),e.jsxs(d,{tone:"info",title:"Hauteur du chrome collant (--chrome-h)",children:[e.jsxs(i,{children:["La pile collante — en-tête marine ",e.jsx(s,{children:"h-14"})," + rangée"," ",e.jsx(s,{children:"HeaderLinks"})," — fait ",e.jsx(s,{children:"114 px"}),", publiés dans"," ",e.jsx(s,{children:"--chrome-h"})," (",e.jsx(s,{children:"--chrome-h-mobile"}
7)," vaut"," ",e.jsx(s,{children:"calc(56px + var(--safe-top))"})," pour la barre du haut de la coquille téléphone, la zone sécurisée en haut d'un iPhone étant nulle ailleurs). Toute sous-navigation collante se positionne avec"," ",e.jsx(s,{children:"top: var(--chrome-h)"})," plutôt qu'un"," ",e.jsx(s,{children:"top-[114px]"})," codé en dur : sinon un changement de hauteur de l'en-tête décolle silencieusement tout ce qui est en dessous."]}),e.jsxs(i,{children:["Revue design §B/12 : ",e.jsx(s,{children:"useCompactChrome"})," pose"," ",e.jsx(s,{children:'data-chrome="compact"'})," sur ",e.jsx(s,{children:"<html>"})," ","au défilement, ce qui fait passer ",e.jsx(s,{children:"--chrome-h"})," à"," ",e.jsx(s,{children:"102 px"})," — 12 px rendus au contenu. Seule la"," ",e.jsx(n,{children:"rangée de pastilles"})," se resserre (padding"," ",e.jsx(s,{children:"10 px → 4 px"}),") : l'en-tête marine garde sa hauteur à toutes les positions de défilement, parce qu'il porte le logo, l'identité de la page et le bloc utilitaire — les redimensionner sous le curseur coûte plus en stabilité que les 20 px gagnés. Aucune barre ne disparaît : masquer l'en-tête coûterait l'accès aux notifications, à la messagerie et au menu de profil sans remonter en haut de page."]}),e.jsxs(i,{children:["L'attribut est écrit ",e.jsx(n,{children:"directement dans le DOM"})," ","depuis un écouteur ",e.jsx(s,{children:"scroll"})," passif — le faire passer par un state re-rendrait ",e.jsx(s,{children:"AppLayout"}),", donc toute la page, à la frame où le seuil est franchi. Les seuils de resserrement (96 px) et de réouverture (32 px) sont volontairement distincts : un seuil unique produit un battement, et chaque bascule décale toute la page. Le soulignement de la pastille active se positionne sur ",e.jsx(s,{children:"--nav-pad"}),", la variable qui porte ce padding, sinon il flotte sous le bandeau dans l'un des deux états."]})]}),e.jsxs(d,{tone:"info",title:"Surfaces encastrées : .co-card-inset et .co-tile",children:[e.jsxs(i,{children:["Ces deux surfaces s'écrivaient en chaînes d'utilitaires bruts à 179 endroits, et 
7ces utilitaires avaient dérivé vers"," ",e.jsx(n,{children:"69 et 21 habillages distincts"})," — tous des tentatives de « panneau légèrement encastré », en gris, ardoise, blanc ou teinte de marque. Le nom de classe est stable maintenant, et c'est la feuille de style qui l'habille."]}),e.jsx(i,{children:"La distinction entre les deux reste celle que Cléo faisait déjà : l'encart est statique, la tuile se soulève au survol."}),e.jsxs(i,{children:[e.jsx(n,{children:"Trois niveaux de profondeur."})," Dans un"," ",e.jsx(s,{children:".settings-canvas"})," (Configuration, console plateforme, studio de marque) une carte est déjà rétrogradée en panneau, donc un encart placé à l'intérieur remonte à la surface carte pour rester lisible. Cette règle ne visait qu'une des deux écritures du nom de classe et manquait 130 des 147 encarts concernés ; depuis qu'il n'y a qu'un nom, elle les couvre tous."]})]}),e.jsxs(d,{tone:"info",title:"Rangées d'actions : voir vs écrire",children:[e.jsxs(i,{children:["Revue design §C/19. La barre de contrôles de"," ",e.jsx(s,{children:"all-chargers"})," est coupée par un séparateur vertical unique, et la règle qui décide du côté est simple : avant lui, tout ce qui change ",e.jsx(n,{children:"ce que l'on voit"})," (vues enregistrées, densité, colonnes, exports) ; après lui, tout ce qui change ",e.jsx(n,{children:"les données"})," (déplacer, ajouter). L'export est une lecture — il a l'air d'une commande, ce qui explique qu'il se retrouvait du mauvais côté. ",e.jsx(s,{children:"Reset Columns"})," vit maintenant ",e.jsx(n,{children:"dans"})," le menu des colonnes : sa portée est la liste de cases, pas la page."]}),e.jsxs(i,{children:["Revue design §D/23. L'en-tête d'accueil n'a plus qu'un seul bouton plein (la Carte des clients) ; le déclencheur des liens utiles est passé en ",e.jsx(s,{children:"outline"}),", et DocsForge est devenu une entrée de ce menu via la prop ",e.jsx(s,{children:"extraLinks"}),". Les deux boutons d'en-tête configurables restent en place : un administrateur qui active un réglage nommé « bouton d'en-tête » s'attend à le voir là, et le Studio d'image de marque le prévisualise à cet endroit."]}),e.jsxs(i,{children:["Le geste de survol est un composant unique —"," ",e.jsx(s,{children:"components/ui/hover-lift.tsx"}),". Le même ressort"," ",e.jsx(s,{children:"{ scale: 1.05, y: -1 }"})," était recopié à cinq endroits ; un test échoue si une sixième copie apparaît."]}),e.jsxs(i,{children:["Un geste n'ajoute jamais d'arrêt de Tab (CHARGEOPS-505). framer-motion pose ",e.jsx(s,{children:'tabindex="0"'})," sur tout élément non natif qui a ",e.jsx(s,{children:"whileTap"})," sans tabindex, donc un enveloppant muni d'un ressort au toucher devient un arrêt sans anneau ni rôle, sur lequel Entrée et Espace ne font rien. D'où trois règles. Un enveloppant autour d'un contrôle porte"," ",e.jsxs(s,{children:["tabIndex=","{-1}"]})," — HoverLift le pose lui-même et n'accepte pas de prop ",e.jsx(s,{children:"tabIndex"}),". Une surface cliquable est un"," ",e.jsx(s,{children:'<motion.button type="button">'})," (les cartes de méthode d'Ajout de véhicules), jamais un ",e.jsx(s,{children:"motion.div"})," ","avec un ",e.jsx(s,{children:"onClick"})," ; une navigation est un"," ",e.jsx(s,{children:"<Button asChild>"})," autour du"," ",e.jsx(s,{children:"<Link>"})," (la Carte des clients de l'accueil), jamais un bouton dans un lien. Et ",e.jsx(s,{children:"whileTap"})," ne va jamais sur l'élément qu'un ",e.jsx(s,{children:"…Trigger asChild"})," Radix clone : sur Entrée, l'appui clavier de framer déclenche un"," ",e.jsx(s,{children:"pointerdown"})," synthétique sur l'élément focalisé, le déclencheur le lit comme une bascule, puis son propre gestionnaire d'Entrée bascule encore et le menu s'ouvre et se referme d'une seule touche. Le déclencheur des liens utiles est donc le Button lui-même, avec le ressort sur un enveloppant en"," ",e.jsxs(s,{children:["tabIndex=","{-1}"]})," (",e.jsx(s,{children:"focus"})," ne remonte pas, donc l'enveloppant n'arme jamais ce mécanisme). Une tuile qui n'est pas un contrôle n'a pas de ",e.jsx(s,{children:"whileTap"})," du tout (les tuiles de contact du site)."," ",e.jsx(s,{children:"whiletap-single-tab-stop.test.tsx"})," balaie"," ",e.jsx(s,{children:"client/src"})," pour ces trois formes."]})]}),e.jsxs(d,{tone:"info",title:"Couleur et chargement : les deux balayages",children:[e.jsxs(i,{children:["Revue design §A/4. Les pages ne portent plus ",e.jsx(n,{children:"aucun"})," ","littéral hexadécimal — 115 occurrences, 58 valeurs distinctes, dont des quasi-doublons (",e.jsx(s,{children:"#3021C3"})," contre"," ",e.jsx(s,{children:"#3120c3"}),"). Trois destinations selon le consommateur : un jeton CSS quand c'est du CSS (",e.jsx(s,{children:"--brand-page-bg"}),","," ",e.jsx(s,{children:"--surface-dialog"}),", ",e.jsx(s,{children:"--tab-active-ink"}),","," ",e.jsx(s,{children:"--cleo-tier-*"}),", ",e.jsx(s,{children:"--session-*"}),") ;
7 un module JS quand le consommateur peint sur son propre canevas et ne peut pas lire une variable CSS (",e.jsx(s,{children:"useChartTheme"})," pour Chart.js et les épingles Maps, ",e.jsx(s,{children:"lib/auth-decor.ts"})," pour les trois pages d'authentification) ; la suppression quand la valeur ne servait à rien — les 227 lignes de styles Google Maps de"," ",e.jsx(s,{children:"customer-map"})," étaient définies et jamais utilisées."]}),e.jsxs(i,{children:["Un test échoue si un hexadécimal réapparaît dans une page. Les"," ",e.jsx(n,{children:"sections de documentation"})," en sont exclues : la couleur y est le sujet, pas le style."]}),e.jsxs(i,{children:["Revue design §E/26. Onze indicateurs de chargement qui"," ",e.jsx(n,{children:"remplaçaient du contenu"})," sont devenus des squelettes (",e.jsx(s,{children:"SkeletonList"}),", nouveau, plus"," ",e.jsx(s,{children:"SkeletonTable"})," et ",e.jsx(s,{children:"SkeletonBlock"}),"). Les 91 restants annotent une ",e.jsx(n,{children:"action en cours"})," — bouton qui enregistre, ligne qui bascule, page suivante qui se charge — et c'est exactement l'usage que la revue conserve. Un tournoiement dit « attendez » sans dire de quoi ni combien ; sur un panneau vide c'est une régression, sur un bouton c'est la bonne réponse."]})]}),e.jsxs(d,{tone:"info",title:"Plan des titres : l'en-tête possède le h1",children:[e.jsxs(i,{children:[e.jsx(s,{children:"AppLayout"})," et ",e.jsx(s,{children:"PortalLayout"})," posent le"," ",e.jsx(s,{children:"<h1>"})," de la page. Une page montée dans l'un des deux ne doit donc ",e.jsx(n,{children:"pas"})," ajouter son propre"," ",e.jsx(s,{children:"h1"})," : les titres de section sont des"," ",e.jsx(s,{children:"h2"}),", les titres de carte des ",e.jsx(s,{children:"h3"}),". Dans un dialogue, ",e.jsx(s,{children:"DialogTitle"})," de Radix est déjà un"," ",e.jsx(s,{children:"h2"}),", donc le contenu commence à ",e.jsx(s,{children:"h3"}),"."]}),e.jsxs(i,{children:["Rien de tout cela ne se voit : le Preflight de Tailwind remet chaque titre à ",e.jsx(s,{children:"font-size: inherit"})," et"," ",e.jsx(s,{children:"font-weight: inherit"}),". Le niveau ne porte que le sens, les classes portent l'apparence — c'est précisément pour ça que le plan dérivait, puisqu'un mauvais niveau ne change rien à l'écran."," ",e.jsx(s,{children:"client/src/__tests__/heading-hierarchy.test.ts"})," est le seul garde-fou."]}),e.jsxs(i,{children:["Il vérifie aussi les composants, pas seulement les fichiers de page : il suit chaque import jusqu'à l'endroit où le composant est monté et juge ses titres là. Aucun composant n'ajoute de"," ",e.jsx(s,{children:"h1"})," (sauf les layouts et les quelques cas listés dans"," ",e.jsx(s,{children:"OWNS_H1"}),") ; un composant commence sous la section qui le contient — un corps de dialogue, ou les enfants d'un composant titré comme ",e.jsx(s,{children:"SettingsSectionShell"}),", dont le titre est déjà le ",e.jsx(s,{children:"h2"})," ; et un composant en ",e.jsx(s,{children:"hN"})," doit avoir un ",e.jsx(s,{children:"h(N-1)"})," au-dessus de lui. Si le niveau d'un composant dépend de l'endroit où tu le montes, donne-lui une prop"," ",e.jsx(s,{children:"headingLevel"})," et déclare-le dans"," ",e.jsx(s,{children:"POLYMORPHIC"})," : chaque appel est alors vérifié au niveau qu'il passe."]}),e.jsxs(i,{children:["Les onglets sont jugés un par un : un seul panneau est affiché à la fois, donc le ",e.jsx(s,{children:"h3"})," d'un onglet voisin ne compte pas comme titre parent du tien. Un ",e.jsx(s,{children:"TabsContent"})," Radix et une branche ",e.jsx(s,{children:'{activeTab === "x" && …}'})," comptent comme panneaux (un ternaire ",e.jsx(s,{children:'tab === "x" ? … : …'}),", pas encore) ; un titre écrit dans un panneau doit, ce panneau affiché, avoir un ",e.jsx(s,{children:"h(N-1)"})," au-dessus de lui."]}),e.jsxs(i,{children:["La documentation est vérifiée elle aussi : chaque chapitre est monté dans ",e.jsx(s,{children:"AppLayout"}),", dont le ",e.jsx(s,{children:"h1"})," est « Documentation ». Le titre du chapitre (",e.jsx(s,{children:"DocPage"}),") est donc un ",e.jsx(s,{children:"h2"}),", ",e.jsx(s,{children:"DocSection"})," un"," ",e.jsx(s,{children:"h3"})," et ",e.jsx(s,{children:"DocSubSection"})," un ",e.jsx(s,{children:"h4"}),". Pour une sous-section dans une sous-section (un « 2.1 » sous un « 2. »), prends ",e.jsx(s,{children:"DocSubSubSection"})," (",e.jsx(s,{children:"h5"}),", même apparence). Le HTML cité dans un exemple de code n'est pas un titre : le test ignore le contenu des gabarits entre accents graves."]})]}),e.jsxs(d,{tone:"info",title:"CardTitle rend un titre, pas un div",children:[e.jsxs(i,{children:[e.jsx(s,{children:"CardTitle"}
7)," rendait un ",e.jsx(s,{children:"<div>"}),", donc chaque carte de l'application était absente du plan du document. Il rend maintenant un ",e.jsx(s,{children:"h3"})," par défaut — un cran sous une section de page — avec une prop ",e.jsx(s,{children:"as"})," pour les cas où l'imbrication diffère : ",e.jsx(s,{children:'as="h2"'})," quand la carte"," ",e.jsx("em",{children:"est"})," la section de tête de la page,"," ",e.jsx(s,{children:'as="h4"'})," pour une carte dans une carte,"," ",e.jsx(s,{children:'as="div"'})," pour un titre purement décoratif."]}),e.jsxs(i,{children:["Même règle pour les classes ",e.jsx(s,{children:"co-card-title"})," et"," ",e.jsx(s,{children:"co-card-title"})," : ce qui les porte est le titre d'une carte, donc ce doit être un élément de titre. Le test"," ",e.jsx(s,{children:"heading-hierarchy.test.ts"})," échoue si l'une des deux se retrouve sur un ",e.jsx(s,{children:"span"})," ou un ",e.jsx(s,{children:"div"}),"."]})]}),e.jsxs(d,{tone:"info",title:"Configuration : une section, un fichier",children:[e.jsxs(i,{children:[e.jsx(s,{children:"configuration.tsx"})," tenait 7 098 lignes, dont un seul"," ",e.jsx(s,{children:"<SettingsSectionShell>"})," de 5 000 lignes. La page fait maintenant ",e.jsx(n,{children:"993 lignes"})," : elle possède la coquille, le routage d'onglet et la sous-navigation, et rien d'autre. Chaque onglet vit dans"," ",e.jsx(s,{children:"components/configuration/sections/"})," et possède son propre état, ses requêtes et ses mutations."]}),e.jsxs(i,{children:["Le découpage a été possible parce que les onglets ne partageaient presque rien : sur 269 déclarations de la page, seules 45 étaient utilisées par plus d'un onglet ou par la coquille. Les sections redérivent ce dont elles ont besoin (",e.jsx(s,{children:"useQuery"})," dédoublonne les abonnements identiques, donc aucun aller-retour supplémentaire) plutôt que de le recevoir en props."]}),e.jsxs(i,{children:["Les neuf sections sont en ",e.jsx(s,{children:"React.lazy"}),", sous la même frontière ",e.jsx(s,{children:"Suspense"})," que les douze panneaux déjà découpés — un seul onglet peut être monté à la fois."]})]}),e.jsxs(d,{tone:"warning",title:"Jetons de couleur : l'opacité doit passer par calc(alpha * …)",children:[e.jsxs(i,{children:[e.jsx(s,{children:'border: "var(--border)"'})," est l'écriture naturelle et elle est ",e.jsx(n,{children:"silencieusement fausse"}),". Tailwind doit injecter une valeur alpha pour construire"," ",e.jsx(s,{children:"border-border/60"})," ; il ne peut pas l'injecter dans une chaîne opaque, donc il n'émet ",e.jsx(n,{children:"aucune règle"})," — l'élément retombe sur la bordure gris clair de Preflight, ou sur aucun fond. Pas d'erreur de build, pas d'avertissement : le problème avait atteint 338 appels."]}),e.jsxs(i,{children:["Les jetons passent maintenant par"," ",e.jsx(s,{children:"rgb(from var(--x) r g b / calc(alpha * <alpha-value>))"}),". La syntaxe de couleur relative lit les canaux sur la variable, donc les jetons restent en hexadécimal, ",e.jsx(s,{children:"hsl()"})," ou"," ",e.jsx(s,{children:"rgba()"})," dans les quatre feuilles qui les définissent."]}),e.jsxs(i,{children:["Le ",e.jsx(s,{children:"calc(alpha * …)"})," est ",e.jsx(n,{children:"indispensable"}),", pas cosmétique : Cléo définit"," ",e.jsx(s,{children:"--border: rgba(20, 20, 30, 0.10)"})," (et"," ",e.jsx(s,{children:"rgba(255, 255, 255, 0.10)"})," en sombre). Remplacer la valeur alpha rendrait un simple ",e.jsx(s,{children:"border-border"})," ",e.jsx(n,{children:"totalement opaque"})," sous ce thème — un filet blanc plein sur chaque surface Cléo sombre. La multiplication compose les deux, ce qui est aussi le sens de l'appel : 60 % d'une couleur à 10 %, c'est 6 %."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"Barre d'accès rapide"})," :"," ",e.jsx(s,{children:"components/operations/section-nav.tsx"})," — une pastille par catégorie, ",e.jsx(s,{children:"sticky"})," sous le chrome de l'application (56 px en mobile, 112 px en desktop : en-tête ",e.jsx(s,{children:"h-14"})," + rangée ",e.jsx(s,{children:"HeaderLinks"}),", valeurs qui reflètent"," ",e.jsx(s,{children:"AppLayout"})," et ",e.jsx(s,{children:"useIsMobile()"}),"). Le défilement cible est ",e.jsx(n,{children:"mesuré"})," et non codé en dur : au clic, le composant lit la hauteur réelle de sa propre barre puis borne le résultat à sa position collée, ce qui évite un sous-défilement quand on part du haut de la page. Le suivi de section (« scroll-spy ») est un écouteur ",e.jsx(s,{children:"scroll"})," passif étranglé par"," ",e.jsx(s,{children:"requestAnimationFrame"}),", avec un cas particulier en bas de page pour la dernière section, trop courte pour franchir le seuil. La barre vit ",e.jsx(n,{children:"hors de"})," ",e.jsx(s,{children:"exportRef"})," ",": c'est du chrome de page, jamais de la donnée exportée."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Habillage"})," : la barre porte"," ",e.jsx(s,{children:".section-nav-bar"})," (",e.jsx(s,{children:"index.css"}),"), troisième membre de la famille ",e.jsx(s,{children:"#3120c3"})," avec"," ",e.jsx(s,{children:".tab-glass-container"})," (onglets d'une page de site) et"," ",e.jsx(s,{children:".bulk-action-bar"})," — même corps indigo plein, pastille blanche à texte ",e.jsx(s,{children:"#3120c3"})," sur l'élément actif, blanc-alpha pour le reste. Hauteur automatique (pas les 64 px fixes du bandeau d'onglets) : sept éléments qui défilent horizontalement plutôt que de passer à la ligne. Le thème Cléo échange le bleu contre ",e.jsx(s,{children:"#3021C3"})," dans"," ",e.jsx(s,{children:"theme-cleo.css"}),", comme pour les deux autres."]}),e.jsxs(i,{children:["La pastille active est un unique ",e.jsx(s,{children:"motion.span"})," partagé (",e.jsx(s,{children:"layoutId"}),") : framer-motion mesure le changement de position ",e.jsx(n,{children:"et de largeur"}),", donc la pastille glisse et se déforme entre « Sites » et « Garanties et installations » au lieu de sauter. Contrairement aux autres barres collantes, passées à ",e.jsx(s,{children:"nav-pill.tsx"}),", celle-ci peut garder son"," ",e.jsx(s,{children:"layoutId"})," : un clic place la pastille avant que son propre défilement commence et le repérage la recible après le défilement, donc aucun défilement ne tombe entre la capture et la mise en page (mesuré sur des défilements au clic et à la molette). La rangée est en ",e.jsx(s,{children:"justify-between"}),", qui répartit les pastilles sur un large bandeau et retombe sur"," ",e.jsx(s,{children:"flex-start"})," dès qu'elle déborde — contrairement à"," ",e.jsx(s,{children:"center"}),"/",e.jsx(s,{children:"end"}),", qui rendraient la première pastille inatteignable dans le conteneur défilant."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Synchronisation pastille / défilement"})," : un clic anime la page sur notre propre boucle ",e.jsx(s,{children:"rAF"})," plutôt que par ",e.jsx(s,{children:'behavior: "smooth"'}),", parce que le navigateur choisit sa durée en fonction de la distance alors qu'un ressort se stabilise en un temps à peu près constant — mesuré, la pastille arrivait à 408 ms pour un défilement de 675 ms. En possédant la timeline, la page et la pastille partagent une durée et une courbe (",e.jsx(s,{children:"easeOutCubic"}),") et atterrissent ensemble par construction : 625 ms contre 641 ms sur un long saut. Le scroll-spy se met en retrait tant que le clic est en vol (",e.jsx(s,{children:"pendingRef"}),") — sinon il recalcule dès le premier événement, sur une page qui n'a pas encore bougé, ramène la pastille à son point de départ puis la traîne à travers chaque section survolée. Une molette, un toucher ou une touche rendent la main immédiatement, et ",e.jsx(s,{children:"prefers-reduced-motion"})," saute directement."]}),e.jsxs(d,{tone:"danger",title:"scroll-behavior global",children:[e.jsx(s,{children:"index.css"})," pose un"," ",e.jsxs(s,{children:["html ","{ scroll-behavior: smooth }"]})," global. Chaque"," ",e.jsx(s,{children:"window.scrollTo"})," par image de la boucle repartait donc en défilement animé vers une cible déjà dépassée, et la page traînait d'une durée entière derrière la timeline. Les appels de la boucle passent ",e.jsx(s,{children:'behavior: "instant"'})," — le retirer réintroduit le décalage."]}),e.jsxs(i,{children:["Le contenu d'une pastille est ",e.jsx(s,{children:"#3120c3"})," quand elle est active et blanc sinon : chacun n'est lisible que sur le fond que la pastille y met. Comme la pastille est une couche séparée qui balaie l'écran, la bascule de couleur doit être calée sur elle — trop tôt, le libellé est indigo sur le bandeau indigo ; trop tard, l'icône est blanche sur la pastille blanche déjà arrivée. Un fondu sur toute la durée ne marche pas non plus : le recouvrement n'est pas linéaire dans le temps (0 % jusqu'à ~45 % du trajet, 100 % à ~72 %). Le contenu garde donc son ancienne couleur puis bascule vite à l'intérieur de cette fenêtre de balayage."]}),e.jsxs(d,{tone:"warning",title:"Surface indigo permanente",children:[e.jsx(s,{children:".section-nav-bar"})," est indigo dans ",e.jsx(n,{children:"tous"})," ","les thèmes, clair comme sombre : c'est une surface « navy » au sens du §palette. N'y utilisez que du blanc-alpha et le littéral"," ",e.jsx(s,{children:"#3120c3"})," — jamais un utilitaire blue/indigo/sky, dont le remap Cléo (0-2-1) bat ",e.jsx(s,{children:".dark .dark:util"})," mais perd contre ",e.jsx(s,{children:".dark .dark:hover:util"}),", ce qui scinderait les états repos et survol d'une pastille entre deux mondes de thème."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Bloc « Service et support »"})," : sous l'alerte active et ",e.jsx(n,{children:"avant"})," les panneaux de parc (ce sont les chiffres actionnables ; l'inventaire du parc est de la donnée de référence), un bloc unique regroupe les indicateurs du département de service —"," ",e.jsx(s,{children:"client/src/components/operations/service-kpi-section.tsx"}),", alimenté par les trois requêtes de"," ",e.jsx(s,{children:"convex/statistics/serviceKpis.ts"})," (doc 08 §36 bis). Une bannière porte le titre du groupe et le sélecteur de"," ",e.jsx(n,{children:"période glissante"})," (30/90/180/365 jours), appliqué aux trois sous-bandes d'un coup ; le filtre organisation/site de la page les cadre également. Chaque sous-bande affiche son propre squelette tant que sa requête n'a pas résolu, sans bloquer les deux autres — et ces placeholders n'utilisent ",e.jsx(n,{children:"pas"})," ",e.jsx(s,{children:"animate-pulse"}),", réservé au squelette de chargement du parc. Composants ajoutés : ",e.jsx(s,{children:"ranked-metric-list.tsx"})," ","(généralisation de ",e.jsx(s,{children:"TopSitesPanel"})," : même grammaire de ligne + mini-barre, DOM pur donc sans canvas),"," ",e.jsx(s,{children:"metric-pill-strip.tsx"})," (généralisation des pastilles de"," ",e.jsx(s,{children:"WarrantyHealthStrip"}),", avec support d'une couleur hex pour la palette de gravité ",e.jsx(s,{children:"SEVERITY_COLORS"}),") et"," ",e.jsx(s,{children:"metric-table.tsx"})," (tableau compact,"," ",e.jsx(s,{children:"data-pdf-atom"})," par ligne)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Un site nommé porte son organisation"})," : le champ"," ",e.jsx(s,{children:"sub"})," de ",e.jsx(s,{children:"RankedMetricList"})," — rendu"," ",e.jsx(n,{children:"au-dessus"})," de la mini-barre, donc lu comme une deuxième ligne d'identité et non comme une légende de la métrique — reçoit ",e.jsx(s,{children:"organizationName"}),". Même ligne dans"," ",e.jsx(s,{children:"TopSitesPanel"}),", sous le nom du site dans"," ",e.jsx(s,{children:"PerSiteTable"}),", dans la tuile"," ",e.jsx(s,{children:"serviceTopSites"})," de la rangée KPI de l'accueil, et dans le document PDF (",e.jsx(s,{children:"RankedList"})," et la première colonne de"," ",e.jsx(s,{children:"DocTable"}),", tous deux dotés du même champ"," ",e.jsx(s,{children:"sub"}),"). Une valeur absente ne rend aucune ligne : un site sans organisation garde la hauteur de rangée d'origine."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Rapport PDF des indicateurs"})," : l'export de la page n'est plus une capture d'écran découpée (",e.jsx(s,{children:"dom-to-pdf.ts"}),", supprimé) mais un vrai document imprimé produit par le"," ",e.jsx(n,{children:"moteur partagé"})," des rapports d'entretien et de service (",e.jsx(s,{children:"branded-report-pdf.ts"}),", réglages par défaut : letter portrait, échelle 3) : un document hors écran dédié (",e.jsx(s,{children:"operations/pdf/operations-report-document.tsx"}),", feuille fixe de 600 px, styles en ligne uniquement) recompose les données des mêmes requêtes en mise en page d'impression — page couverture (logo du locataire, couleurs de rapport, périmètre, chiffres clés, table des sections incluses), cellules KPI compactes, graphiques Chart.js pleine largeur (",e.jsx(s,{children:"animation:false"})," — html2canvas copie le bitmap du ",e.jsx(s,{children:"<canvas>"})," tel quel), tableaux denses, dos de couverture et pieds de page numérotés."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Pagination par blocs — aucun graphique coupé"})," : chaque bande de catégorie, grille de KPI, panneau et tableau du document est un ",e.jsx(s,{children:"data-pdf-block"})," ; un bloc qui tient dans une page y est posé entier, ou renvoyé ENTIER à la page suivante quand il n'y tient pas — jamais scindé. Seuls les blocs réellement plus hauts qu'une page (longs tableaux) sont tranchés, aux limites"," ",e.jsx(s,{children:"data-pdf-atom"})," (chaque ligne en porte un) : un atome qui ne tient pas dans l'espace restant est lui aussi déplacé ENTIER vers la tranche suivante, jamais coupé en deux. Les bandes de catégorie portent à la fois ",e.jsx(s,{children:'data-pdf-block="page"'})," ","et le marqueur dédié ",e.jsx(s,{children:"data-pdf-band"})," qui les distingue de tout autre bloc de type « page » — chaque catégorie ouvre une nouvelle page. Le tableau comparatif et le Pareto des anomalies de la section Fiabilité (CHARGEOPS-31) n'utilisent volontairement AUCUN saut de page forcé : un forçage inconditionnel a été essayé puis retiré (il ouvrait une page presque vide même pour un tableau à deux lignes) au profit d'un plafond de lignes (15, avec une légende « Affichage de N sur M » au-delà) qui garde chaque tableau sous une page et laisse le flux ordinaire des blocs faire le reste. Les sous-titres portent"," ",e.jsx(s,{children:"data-pdf-keep-with-next"})," (",e.jsx(s,{children:"keepWithNextNeedsBreak"}),", qui exige le bloc suivant ENTIER quand celui-ci tient dans une page) : un titre ne finit jamais une page seul. Les sections décochées ne sont simplement pas rendues par le document."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Rendu déterministe"})," : le document étant hors écran et autonome (styles en ligne, couleurs littérales), l'exp
7ort ne dépend ni de la largeur de fenêtre, ni du thème sombre, ni des points de rupture Tailwind, ni du défilement — les classes de bogues qui frappaient la capture de la page vivante. Le tableau de bord à l'écran n'est pas touché pendant la génération ; le dialogue de sélection reste ouvert comme indicateur de progression. Contrat de structure testé dans"," ",e.jsx(s,{children:"operations/pdf/__tests__/operations-report-document.test.tsx"})," ","(blocs jamais imbriqués, lignes = atomes, sections respectant la sélection)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Cartes de la page d'accueil"})," : les cartes de l'accueil utilisent un traitement plat « Direction A » (classe"," ",e.jsx(s,{children:".co-card"})," dans ",e.jsx(s,{children:"theme-cleo-extras.css"}),") — rayon de 16 px, bordure fine, ombre douce discrète, survol subtil et titres en graisse d'affichage. Il a remplacé le chrome"," ",e.jsx(s,{children:"gradient-border-card"}),"/flou/",e.jsx(s,{children:"shadow-xl"}),", qu'aucun balisage n'utilise plus."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Cartes de toute l'application"})," : le même traitement plat couvre toutes les autres pages authentifiées (bornes, sites, sessions, analytique et statistiques, entrepôt, RMA, licences, garanties, puissance, véhicules, maintenance, rapports de service, clients, contacts, organisations, utilisateurs, journaux d'audit, sauvegarde, image de marque, gestionnaire de fichiers, carte des clients, cleo-link). En plus de"," ",e.jsx(s,{children:".co-card"}),"/",e.jsx(s,{children:".co-card-head"}),"/",e.jsx(s,{children:".co-card-title"}),", un vocabulaire d'intérieur partagé aplatit les blocs internes : ",e.jsx(s,{children:".co-card-inset"})," et"," ",e.jsx(s,{children:".co-tile"})," (sous-surfaces), ",e.jsx(s,{children:".co-chip"})," ","(pastille d'icône), ",e.jsx(s,{children:".co-empty"})," (état vide),"," ",e.jsx(s,{children:".co-row"}),"/",e.jsx(s,{children:".co-thead"})," (tableaux),"," ",e.jsx(s,{children:".co-pill"})," (badge), ",e.jsx(s,{children:".co-field"})," (valeur en lecture seule) et ",e.jsx(s,{children:".co-nums"})," (chiffres tabulaires) — tous dans ",e.jsx(s,{children:"theme-cleo-extras.css"}),", chacun avec sa variante sombre ",e.jsx(s,{children:"html.theme-cleo.dark"}),". Les noms de classes sont inconditionnels dans le balisage : la feuille de style est le seul endroit où se décide l'apparence d'une surface."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Pastille de navigation active"})," : la pastille de la page active — dans la barre de nav principale (",e.jsx(s,{children:"HeaderLinks"}),") comme dans chaque sous-nav (Véhicules, RMA, Maintenance, Entrepôt, Configuration, Analytique) — porte l'identité Cléo sans toucher à l'animation qui la fait glisser d'un onglet à l'autre. Toutes ces barres sont collantes, donc la pastille est un seul indicateur positionné par rapport au ",e.jsx(s,{children:"<nav>"})," (",e.jsx(s,{children:"components/nav-pill.tsx"}),") qui n'anime que"," ",e.jsx(s,{children:"x"})," et la largeur — jamais un ",e.jsx(s,{children:"layoutId"})," ","Framer Motion, qui mesure en coordonnées de page : quand un onglet plus court ramenait le défilement, l'écart de défilement devenait le point de départ de la pastille et elle montait depuis le bas de l'écran. Sa hauteur suit les éléments (",e.jsx(s,{children:"h-9"})," dans la nav principale, ",e.jsx(s,{children:"h-8"})," dans les sous-navs,"," ",e.jsx(s,{children:"h-full"})," pour le soulignement des groupes de Configuration). Les barres Maintenance et Entrepôt sont rendues par chaque page, donc remontées à chaque clic d'onglet : une"," ",e.jsx(s,{children:"handoffKey"})," (",e.jsx(s,{children:"navKey"})," sur"," ",e.jsx(s,{children:"SectionSubNav"}),") fait glisser la pastille de la nouvelle barre depuis la position de l'ancienne, pourvu que l'ancienne soit partie depuis moins d'une seconde. Chaque élément mesuré est un enfant direct ",e.jsx(s,{children:"relative"})," de ce ",e.jsx(s,{children:"<nav>"})," ","et joignable au clavier : un onglet interne à la page (Véhicules, RMA, Configuration, Analytique) est un"," ",e.jsx(s,{children:'<motion.button type="button">'})," qui porte"," ",e.jsx(s,{children:'aria-current="page"'})," quand il est actif — jamais un"," ",e.jsx(s,{children:"div"})," nu avec un ",e.jsx(s,{children:"onClick"})," :"," ",e.jsx(s,{children:"whileTap"})," y pose ",e.jsx(s,{children:'tabindex="0"'}),", donc Tab s'y arrête, mais Entrée ne fait que rejouer l'animation de pression (événements pointeur synthétiques, aucun ",e.jsx(s,{children:"click"}),") et Espace ne fait rien —, tandis qu'un onglet de route (Maintenance, Entrepôt) est un seul ",e.jsx(s,{children:"motion.a"})," que le"," ",e.jsx(s,{children:"<Link asChild>"})," wouter clone avec son"," ",e.jsx(s,{children:"href"})," et son gestionnaire de clic, et qui porte lui-même ",e.jsx(s,{children:"aria-current"}),". Jamais un"," ",e.jsx(s,{children:"motion.div"})," avec ",e.jsx(s,{children:"whileTap"})," dans un"," ",e.jsx(s,{children:"<Link>"})," simple : le div reçoit son propre"," ",e.jsx(s,{children:'tabindex="0"'}),", un second arrêt de Tab sans anneau ni"," ",e.jsx(s,{children:"aria-current"}),". Les pastilles de l'en-tête principal (",e.jsx(s,{children:"header-links.tsx"}),") et celles du Portail exploitant (",e.jsx(s,{children:"portal-layout.tsx"}),") suivent la même règle depuis CHARGEOPS-449 — un seul ",e.jsx(s,{children:"motion.a"})," par élément, qui porte ",e.jsx(s,{children:"aria-current"})," et ",e.jsx(s,{children:"NAV_FOCUS_RING"}),", le même anneau avec son décalage en ",e.jsx(s,{children:"--brand-subnav-bg"})," (le milieu plat du dégradé ",e.jsx(s,{children:".brand-subnav"})," de ces bandes) ;
7 le nav du portail, qui défile à l'horizontale, garde les 4px de l'anneau dans son propre ",e.jsx(s,{children:"p-1"}),", car"," ",e.jsx(s,{children:"overflow-x-auto"})," rogne aussi à la verticale. Dans la densité compacte (icônes seules) de l'en-tête, le lien porte aussi son libellé en"," ",e.jsx(s,{children:"aria-label"}),", et son info-bulle s'ouvre au focus clavier comme au survol. L'élément focalisable porte"," ",e.jsx(s,{children:"SUB_NAV_FOCUS_RING"})," (",e.jsx(s,{children:"nav-pill.tsx"}),") : un anneau ",e.jsx(s,{children:"focus-visible"})," décalé de 2px, décalage peint dans le ",e.jsx(s,{children:"--brand-page-subnav-bg"})," de la barre (pas"," ",e.jsx(s,{children:"ring-offset-background"}),", une bande blanchâtre sur la barre lilas), lisible sur le remplissage de la pastille active et assez petit pour que le ",e.jsx(s,{children:"py-2"})," du nav ne le rogne pas. La rangée des groupes de Configuration le porte aussi, avec"," ",e.jsx(s,{children:'aria-current="true"'})," sur le groupe actif (la pastille de section en dessous garde ",e.jsx(s,{children:'"page"'}),"). Ces barres suivent le modèle de liens de navigation (Tab d'une pastille à l'autre,"," ",e.jsx(s,{children:"aria-current"})," sur l'active), pas celui d'une"," ",e.jsx(s,{children:"tablist"})," : pas de déplacement aux flèches. La barre de sections d'Opérations garde son ",e.jsx(s,{children:"layoutId"})," (mesuré : sa pastille ne voit jamais de défilement entre les deux mesures). La barre latérale de la documentation suit la même règle à la verticale : son surlignage actif est un seul indicateur (",e.jsx(s,{children:"useRailPill"})," /"," ",e.jsx(s,{children:"RailPillIndicator"}),") positionné par rapport à la liste, qui n'anime que ",e.jsx(s,{children:"y"})," et la hauteur, et qui ne glisse que si l'ancien surlignage est encore visible dans la zone défilante de la barre — sinon il apparaît directement sur le chapitre choisi, au lieu de traverser toute la liste. Les classes partagées"," ",e.jsx(s,{children:".nav-pill-fill"})," (remplissage) et"," ",e.jsx(s,{children:".nav-pill-accent"})," (soulignement) sont stylées sous"," ",e.jsx(s,{children:"html.theme-cleo"})," dans ",e.jsx(s,{children:"theme-cleo.css"})," : aplat Bleu Cléo (violet plat,"," ",e.jsx(s,{children:"#3021C3"}),"), rayon pleine pilule (9999 px), halo indigo et soulignement Bleu moyen. Le remplissage est volontairement plat et statique : seul l'anneau de brillance rotatif (",e.jsx(s,{children:".nav-shine-border"}),", barre de nav principale) tourne, arrondi pour épouser la pastille."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Dialogues de bornes"})," : les dialogues de bornes — Ajouter une borne (formulaire + enveloppe), Ajouter plusieurs bornes, et Modifier une borne — sont construits avec le vocabulaire Cléo plat (",e.jsx(s,{children:"co-card-inset"}),","," ",e.jsx(s,{children:"co-tile"}),", ",e.jsx(s,{children:"co-chip"}),","," ",e.jsx(s,{children:"co-field"}),", ",e.jsx(s,{children:"co-pill"}),")."]}),e.jsxs(i,{children:["Ajouter et Modifier partagent la même anatomie : en-têtes de section à pastille (",e.jsx(s,{children:"CleoSectionHeader"})," — pastille indigo + libellé en capitales), cartes plates en retrait, palette indigo, pastille de garantie verte, pied de page à bouton pilule, sans filet coloré ni icône à côté du titre."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Pages Organisations + Gestion des sites"})," : les deux pages partagent un même traitement — tuiles de statistiques à pastille (",e.jsx(s,{children:"CleoStatTile"}),"), surfaces plates"," ",e.jsx(s,{children:"co-card"}),", échelle de capacité monochrome indigo (remplaçant le dégradé arc-en-ciel), pastilles de transformateur aplaties, chiffres tabulaires ",e.jsx(s,{children:"co-nums"}),", badge kVA"," ",e.jsx(s,{children:"co-field"}),", états vides ",e.jsx(s,{children:"co-empty"})," + pastille d'icône, boutons pilule, bascule de vue indigo, et dialogues Créer / Modifier un site plats (sans filet coloré ni icône de titre)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Page Organisations — parité avec la Gestion des sites"})," : la page ",e.jsx(a,{children:"/organizations"})," reprend la coquille de la Gestion des sites — pleine largeur du gabarit AppLayout (l'ancien wrapper ",e.jsx(s,{children:"max-w-6xl"})," est supprimé), une seule carte externe ",e.jsx(s,{children:"ny-card"})," / ",e.jsx(s,{children:"co-card"})," qui contient le bandeau de KPI, la barre d'outils en carte encastrée (recherche, compteurs, bouton « Nouvelle organisation ») et le répertoire, plus le pied de page pleine largeur (",e.jsx(s,{children:"<Footer fullBleed />"}),"). Les tuiles de statistiques sont désormais un composant partagé"," ",e.jsx(s,{children:"StatTile"})," (",e.jsx(s,{children:"components/ui/stat-tile.tsx"}),") utilisé par les deux pages : une délégation directe à"," ",e.jsx(s,{children:"CleoStatTile"}),", où le ton ",e.jsx(s,{children:"amber"})," correspond à son état ",e.jsx(s,{children:"warn"}),". Le répertoire lui-même (groupes d'organisations + puces de sites) est inchangé."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Coquille des Paramètres (Sett
7ingsSectionShell)"})," : la page ",e.jsx(a,{children:"/configuration"})," peignait un ",e.jsx(s,{children:"<h2>"})," ","nu sur le fond de page puis laissait chaque section empiler ses propres cartes, souvent dans sa propre colonne"," ",e.jsx(s,{children:"mx-auto max-w-4xl"}),". Les 23 sections sont maintenant rendues dans une seule carte externe"," ",e.jsx(s,{children:"ny-card"})," / ",e.jsx(s,{children:"co-card"})," —"," ",e.jsx(s,{children:"components/configuration/settings-section-shell.tsx"})," — à la même largeur (",e.jsx(s,{children:"max-w-[1840px]"}),") et avec le même traitement que la carte principale de l'Entrepôt : en-tête"," ",e.jsx(s,{children:"co-card-head"})," portant la pastille d'icône, le titre de l'onglet, son compteur, une description (clés"," ",e.jsx(s,{children:"configuration.sectionDesc.<tabId>"}),", FR + EN) et un emplacement ",e.jsx(s,{children:"actions"}),". Le corps porte la classe"," ",e.jsx(s,{children:".settings-canvas"}),", dont les règles (dans"," ",e.jsx(s,{children:"index.css"})," pour l'apparence héritée et"," ",e.jsx(s,{children:"theme-cleo-extras.css"})," pour Cléo) déclassent toute"," ",e.jsx(s,{children:".ny-card"})," / ",e.jsx(s,{children:".co-card"})," imbriquée en panneau plat : plus d'anneau dégradé, plus d'ombre portée, plus de soulèvement au survol, et un ",e.jsx(s,{children:".co-card-inset"})," /"," ",e.jsx(s,{children:".co-tile"})," situé dans un panneau déclassé bascule sur la surface carte pour garder les trois niveaux lisibles. Le déclassement passe par un sélecteur porté par la coquille plutôt que par une réécriture des panneaux : montés ailleurs, ces mêmes composants peignent toujours leur carte complète. La coquille n'utilise volontairement pas ",e.jsx(s,{children:"overflow-hidden"})," (contrairement à l'Entrepôt) — un ancêtre au débordement rogné devient conteneur de défilement et neutralise ",e.jsx(s,{children:"position: sticky"}),", dont dépendent le rail et l'aperçu du Studio d'image de marque, les Menus et les Rôles et permissions. Trois autres pages réutilisent la même coquille : la console de plateforme (",e.jsx(a,{children:"/platform"}),"),"," ",e.jsx(a,{children:"/analytics"})," — dont les quatre chemins de rendu (onglet Site, Parcours utilisateur, squelette de chargement, erreur) répétaient chacun la sous-nav et la colonne, et posaient leurs cartes à même le fond ; le sélecteur de période est passé dans l'emplacement ",e.jsx(s,{children:"actions"})," de l'en-tête — et"," ",e.jsx(a,{children:"/user-management"}),", où le bouton « Add User » a rejoint l'en-tête à côté du nombre de comptes. Puis cinq autres :"," ",e.jsx(a,{children:"/rma"})," (un ",e.jsx(s,{children:"SettingsSectionShell"})," par onglet, en remplacement de trois en-têtes de carte écrits à la main),"," ",e.jsx(a,{children:"/support-tickets"})," (tuiles de statistiques et tableau réunis, boutons de page dans l'en-tête, le"," ",e.jsx(s,{children:"<Footer fullBleed />"})," restant ",e.jsx(n,{children:"hors"})," ","de la carte), ",e.jsx(a,{children:"/compliance"})," (qui n'avait aucune carte),"," ",e.jsx(a,{children:"/charging-sessions"})," et ",e.jsx(a,{children:"/power-management"})," — puis ",e.jsx(a,{children:"/site-management"})," et ",e.jsx(a,{children:"/organizations"})," ","(via ",e.jsx(s,{children:"OrganizationsSection"}),"), fermant la boucle."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Jetons de design des Paramètres (audit 2026-08-01)"})," : l'audit complet vit dans"," ",e.jsx(s,{children:"docs/design/2026-08-01-settings-design-audit.md"})," (50 constats, 35 corrigés). Les atomes récurrents sont désormais des classes de la couche composants : ",e.jsx(s,{children:".settings-cta"})," (le bouton d'action principal — une pastille indigo plate ; le dégradé de sa règle de base est toujours surchargé par"," ",e.jsx(s,{children:"html.theme-cleo .settings-cta"}),", car un dégradé brut restait à moitié converti, son arrêt cyan échappant à la palette),"," ",e.jsx(s,{children:".settings-microlabel"})," (étiquette majuscule 11 px / 0.14em), ",e.jsx(s,{children:".settings-note"})," /"," ",e.jsx(s,{children:".settings-note-warning"})," (bandeaux d'avis), un anneau"," ",e.jsx(s,{children:":focus-visible"})," pour tout élément interactif du"," ",e.jsx(s,{children:".settings-canvas"})," (indigo), et"," ",e.jsx(s,{children:'MotionConfig reducedMotion="user"'})," à la racine pour que Framer Motion respecte la préférence système. La coquille accepte aussi un ",e.jsx(s,{children:"eyebrow"})," — Configuration y passe la famille active de la sous-navigation. Un test de contrat (sett
7ings-shell-contract) interdit toute réapparition du dégradé brut dans le dépôt."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Suites de l'audit (2026-08-01)"})," : les trois leviers restants sont livrés. ",e.jsx(s,{children:"SettingsSearch"})," (palette"," ",e.jsx(s,{children:"cmdk"})," dans l'en-tête de la coquille, raccourcis « / » et Ctrl/Cmd+K, repli d'accents partagé, listes déjà élaguées par permission) ; ",e.jsx(s,{children:"SettingsDirtyProvider"})," /"," ",e.jsx(s,{children:"useSettingsDirty"})," (registre de brouillons par section — cinq sections s'y inscrivent, le changement d'onglet demande confirmation et ",e.jsx(s,{children:"beforeunload"})," est câblé) ; et"," ",e.jsx(s,{children:"SectionAnchorNav"})," (rail « Sur cette page » collant à xl+, scroll-spy sans IntersectionObserver pour que la dernière ancre puisse devenir active, cibles avec"," ",e.jsx(s,{children:"scroll-mt-[var(--settings-anchor-h)]"}),"). La liste des Rapports de maintenance rejoint la coquille."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Clôture de l'audit (ronde 2)"})," : les douze derniers constats sont traités — ressorts partagés"," ","(",e.jsx(s,{children:"lib/motion.ts"})," : ",e.jsx(s,{children:"SPRING_NAV"})," /"," ",e.jsx(s,{children:"SPRING_ICON"})," / ",e.jsx(s,{children:"SPRING_PANEL"})," /"," ",e.jsx(s,{children:"springEntrance"}),"), ",e.jsx(s,{children:"PanelHead"})," (les dix en-têtes de panneau uniformes de configuration.tsx),"," ",e.jsx(s,{children:"CopySectionLink"})," dans l'en-tête, création repliée pour les opérateurs SIM, barres d'outils aplaties, hauteurs h-9 et largeurs w-40/44/52 normalisées, boutons destructifs h-8, titres d'erreur contextuels, et suppression de l'entrée animée des groupes du Studio. Le journal complet vit dans le document d'audit, statut final 50/50."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Respiration au-dessus du pied de page"})," : les pages qui affichent le pied de page l'épinglent au bas du pli —"," ",e.jsx(s,{children:"mt-auto"})," dans une colonne flex, plus un"," ",e.jsx(s,{children:"-mb-8"})," qui annule le rembourrage bas du gabarit — ce qui collait le bandeau à la dernière carte de contenu."," ",e.jsx(s,{children:"footer.tsx"})," porte désormais l'écart lui-même :"," ",e.jsx(s,{children:"pt-8"})," au-dessus du séparateur dégradé. C'est un"," ",e.jsx(n,{children:"rembourrage"}),", jamais une marge — un"," ",e.jsx(s,{children:"mt-*"})," entrerait en conflit avec le ",e.jsx(s,{children:"mt-auto"})," ","qui plaque le bandeau au bas de la page (l'ordre des utilitaires déciderait du gagnant). L'écart reste transparent : il laisse voir le fond de page, et le bandeau touche toujours le bas du viewport. Les hôtes qui rembourrent déjà leur propre bord bas — le wrapper de contenu de ",e.jsx(a,{children:"/"})," (",e.jsx(s,{children:"py-6"}),") et le"," ",e.jsx(s,{children:"<main>"})," du Portail opérateur (",e.jsx(s,{children:"py-6"}),") — passent ",e.jsx(s,{children:"topGap={false}"})," plutôt que de payer deux fois."]})]}),e.jsxs(t,{id:"utilitaires-globales",title:"4. Classes utilitaires globales",children:[e.jsxs(i,{children:["Définies dans ",e.jsx(s,{children:"client/src/index.css"})," :"]}),e.jsx(h,{rows:[{key:e.jsx(s,{children:".ny-card"}),value:"Carte signature blanche bordée d'un dégradé bleu translucide (linear-gradient(135deg, rgba(59,130,246,0.5), …)). Survol = translateY(-1px) + drop-shadow accentuée. Utilisée sur certaines pages / cartes."},{key:e.jsx(s,{children:".nav-font"}),value:'Font family "DM Sans", "Inter", ui-sans-serif, system-ui, sans-serif. Appliquée à la barre de navigation, au menu profil et aux titres.'},{key:e.jsx(s,{children:".display-heading"}),value:"DM Sans font-weight: 700, letter-spacing: -0.025em."},{key:e.jsx(s,{children:".gradient-text"}),value:"Texte en dégradé bleu→indigo→violet (clip-text, transparent)."},{key:e.jsx(s,{children:".header-glass"}),value:"Header sticky avec backdrop-filter: blur(24px) saturate(1.8) et bordure inférieure dégradée."},{key:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:".glass-toast"}),", ",e.jsx(s,{children:".shadow-glass"})]}),value:"Ombres composées + backdrop-filter: blur(16px)."},{key:e.jsx(s,{children:".card-hover-lift"}),value:"Transform translateY(-2px) + shadow bleue au survol."},{key:e.jsx(s,{children:".flowing-water"}),value:"Animation 3 s : gradient horizontal qui défile (utilisé pour les effets shimmer)."},{key:e.jsx(s,{children:".logo-spin"}),value:"Rotation 8 s linear infinite (animation de chargement Polara)."},{key:e.jsx(s,{children:".animate-fade-in-up"}),value:"fadeInUp 0.6s ease-out forwards pour entrées de blocs."},{key:e.jsxs(s,{children:[".notification-glow-","{blue,red,amber,emerald}"]}),value:"Halos colorés pour bannières de notifications."},{key:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:".btn-3d"}),","," ",e.jsxs(s,{children:[".btn-","{white,blue,green,red,purple,dark-blue,light-blue}"]})]}),value:e.jsxs(e.Fragment,{children:["Système de boutons 3D avec lèvre intérieure (",e.jsx(s,{children:"inset 0 var(--btn-lip)"}),") + ombre portée colorée. Trois tailles : ",e.jsx(s,{children:"btn-sm"})," (48 px), default (56 px), ",e.jsx(s,{children:"btn-xs"})," (38 px). Variante carrée"," ",e.jsx(s,{children:"btn-icon"}),". Sous"," ",e.jsx(s,{children:"html.theme-cleo"})," — toujours présent —"," ",e.jsx(s,{children:"theme-cleo.css"})," aplatit chaque ",e.jsxs(s,{children:[".btn-","{couleur}"]})," : aplat de couleur unie, coins pleine pilule (9999 px) et ombre douce — sans dégradé, biseau ni lèvre intérieure. Les boutons contour (",e.jsx(s,{children:".btn-white"}),", ",e.jsx(s,{children:'variant="outline"'}),") adoptent en plus la teinte du bouton « secondary » (texte indigo Cléo, bordure indigo claire, remplissage lavande très pâle, ombre indigo douce) — le même style que le bouton « Enregistrer le brouillon » — afin d'être bien visibles au lieu de blanc-sur-blanc. La règle vit dans"," ",e.jsx(s,{children:"@layer components"})," (index.css) et non dans la couche non-layered de ",e.jsx(s,{children:"theme-cleo.css"})," : les surcharges de couleur par bouton (",e.jsx(s,{children:"text-*"}),","," ",e.jsx(s,{children:"border-*"}),", ",e.jsx(s,{children:"bg-*"}),") restent donc prioritaires, si bien que les boutons contour colorés (pastilles de statut, suppression rouge) conservent leur propre teinte."]})},{key:e.jsx(s,{children:".scrollbar-hide"}),value:"Masque les scrollbars (cross-browser)."},{key:e.jsx(s,{children:"min-h-[calc(100vh-11rem)]"}),value:"Tailwind arbitraire — hauteur minimum d'un conteneur de page (viewport moins ~176 px de header). Standard sur les cartes pleine page."}]})]}),e.jsxs(t,{id:"animations",title:"5. Animations — Framer Motion",children:[e.jsxs(i,{children:["Le projet importe Framer Motion en mode ",e.jsx(n,{children:"lazy"})," via"," ",e.jsx(s,{children:"<LazyMotion features={domAnimation}>"})," (App.tsx) pour réduire le bundle initial. Patterns courants :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Apparition de section"})," :",e.jsx(c,{language:"tsx",children:`<motion.div
8  initial={{ opacity: 0, y: 20 }}
9  animate={{ opacity: 1, y: 0 }}
10  transition={{ duration: 0.5, delay: 0.2 }}
11>`})]}),e.jsxs(r,{children:[e.jsx(n,{children:"Halo pulsant"})," (avatar du menu profil) :",e.jsx(c,{language:"tsx",children:`<motion.div animate={{
12  boxShadow: [
13    "0 0 0 0px rgba(59,130,246,0)",
14    "0 0 0 3px rgba(59,130,246,0.3)",
15    "0 0 0 0px rgba(59,130,246,0)",
16  ],
17}} transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }} />`})]}),e.jsxs(r,{children:[e.jsx(n,{children:"Rotation chevron"})," (",e.jsx(s,{children:"<motion.div animate={{ rotate: isOpen ? 180 : 0 }}>"})," ","avec ",e.jsx(s,{children:'type: "spring"'}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"Reorder.Group"})})," ","(Framer Motion) utilisé dans ",e.jsx(s,{children:"column-configurator.tsx"})," ","pour le drag-and-drop fluide des colonnes."]}),e.jsxs(r,{children:[e.jsx(n,{children:"AnimatedGridPattern"})," (",e.jsx(s,{children:"ui/animated-grid-pattern.tsx"}),") — fond animé de carrés bleus utilisé sur AppLayout (sauf ",e.jsx(a,{children:"/all-chargers"}),")."]})]}),e.jsxs(i,{children:["Animations CSS pures complémentaires :"," ",e.jsx(s,{children:"accordion-down"}),"/",e.jsx(s,{children:"accordion-up"})," (",e.jsx(s,{children:"tailwindcss-animate"}),"), ",e.jsx(s,{children:"marker-drop"})," (Google Maps markers), ",e.jsx(s,{children:"mobile-menu-slide-in"}),","," ",e.jsx(s,{children:"accent-pulse"}),", ",e.jsx(s,{children:"auth-aurora"}),"."]})]}),e.jsxs(t,{id:"loading-animations",title:"6. Loading animations — Polara, Custom, Standard",children:[e.jsxs(i,{children:["Composant pivot :"," ",e.jsx(s,{children:"client/src/components/ui/branded-spinner.tsx"}),". Le"," ",e.jsx(s,{children:"PageLoading"})," de route ne dessine plus la marque : c'est un squelette à la forme de la page depuis S4.4b (§14m)."]}),e.jsx(h,{rows:[{key:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"polara"})," (défaut)"]}),value:e.jsxs(e.Fragment,{children:[e.jsx(n,{children:"La marque par défaut du tenant"})," qui tourne (",e.jsx(s,{children:"logo-spin"})," 8 s) + halo ",e.jsx(s,{children:"animate-ping"})," ","bleu : ",e.jsx(s,{children:"polara-o.png"})," sur le tenant principal, la marque principale résolue du tenant sur un tenant secondaire, et ",e.jsx(s,{children:"null"})," (spinner"," ",e.jsx(s,{children:"<Loader2>"}),") quand celui-ci n'a téléversé aucun logo."]})},{key:e.jsx(s,{children:"custom"}),value:e.jsxs(e.Fragment,{children:["Image téléversée par l'admin (",e.jsx(s,{children:"branding.customLoadingAnimationUrl"}),"). Même animation."]})},{key:e.jsx(s,{children:"standard"}),value:e.jsxs(e.Fragment,{children:["Pas de logo, juste un ",e.jsx(s,{children:"<Loader2>"})," qui tourne. La barre de progression qui l'accompagnait a été retirée : figée à ",e.jsx(s,{children:"w-2/3"}),", elle pulsait sans jamais mesurer quoi que ce soit."]})}]}),e.jsxs(i,{children:["Le choix est lu via ",e.jsx(s,{children:"useBranding()"})," (hook qui interroge"," ",e.jsx(s,{children:"api.system.queries.getBrandingSettings"}),"). Cf. partie 3."]}),e.jsxs(d,{tone:"info",title:"Squelettes à la forme du contenu",children:[e.jsxs(i,{children:[e.jsx(s,{children:"components/ui/skeleton.tsx"})," exporte, en plus de"," ",e.jsx(s,{children:"Skeleton"})," (une barre seule),"," ",e.jsx(s,{children:"SkeletonBlock"}),", ",e.jsx(s,{children:"SkeletonText"}),","," ",e.jsx(s,{children:"SkeletonTable"}),", ",e.jsx(s,{children:"SkeletonCard"})," et"," ",e.jsx(s,{children:"SkeletonKpiRow"}),". Règle de composition :"," ",e.jsx(n,{children:"le balayage appartient au conteneur, pas aux pièces"})," ","— un seul dégradé ",e.jsx(s,{children:".skeleton-shimmer"})," traverse tout le bloc, et les barres internes restent en"," ",e.jsx(s,{children:"bg-muted"})," inerte (prop ",e.jsx(s,{children:"still"}),"). Sinon quarante petits rectangles clignotent en décalé."]}
17),e.jsxs(i,{children:[e.jsx(s,{children:"TabLoading"})," et ",e.jsx(s,{children:"SectionLoading"})," reposent désormais dessus et n'affichent plus de rouet : un squelette et un"," ",e.jsx(s,{children:"animate-spin"})," superposés disaient deux fois la même chose. Les rouets sont réservés aux boutons en attente, où ils accompagnent une action que l'utilisateur vient de déclencher. Chaque bloc porte ",e.jsx(s,{children:'role="status"'})," et"," ",e.jsx(s,{children:"aria-busy"}),", donc le lecteur d'écran annonce « chargement » une fois au lieu d'énumérer les barres décoratives."]})]}),e.jsxs(d,{tone:"success",title:"Consolidation BrandedSpinner (Branding v2)",children:["Toutes les surfaces de chargement honorent désormais"," ",e.jsx(s,{children:"loadingAnimationType"})," via le composant partagé"," ",e.jsx(s,{children:"client/src/components/ui/branded-spinner.tsx"})," : les huit copies inline de ",e.jsx(s,{children:"PolaraSpinner"})," (file-manager + dialogues), ",e.jsx(s,{children:"section-loading.tsx"}),","," ",e.jsx(s,{children:"tab-loading.tsx"})," (variante tableau) et le héros de"," ",e.jsx(s,{children:"home.tsx"})," sans site sélectionné. Exception voulue : le tableau de ",e.jsx(s,{children:"platform-console.tsx"})," garde le rendu ChargeOps/standard (surface plateforme, hors branding tenant). Le glyphe du pied de page (",e.jsx(s,{children:"footer.tsx"}),") suit aussi le branding — image du loader personnalisé quand"," ",e.jsx(s,{children:'loadingAnimationType === "custom"'}),", masqué en"," ",e.jsx(s,{children:"standard"}),"."]}),e.jsxs(d,{tone:"warning",title:'Le mode "polara" = « la marque par défaut de CE tenant »',children:[e.jsx(s,{children:"resolveLoaderImage"})," délègue désormais à"," ",e.jsx(s,{children:"client/src/lib/brand-glyph.ts::resolveBrandGlyph"})," et prend un axe tenant (",e.jsx(s,{children:"isMainTenant"}),","," ",e.jsx(s,{children:"primaryLogo"}),"). La ",e.jsx(n,{children:"valeur stockée"})," ",e.jsx(s,{children:'"polara"'})," est inchangée (aucune migration de données) ; c'est son ",e.jsx(n,{children:"interprétation"})," qui change : le glyphe Polara intégré n'est servi que sur le tenant principal, un tenant secondaire reçoit sa propre marque principale, et"," ",e.jsx(s,{children:"null"})," — donc le ",e.jsx(s,{children:"<Loader2>"})," générique — s'il n'a pas de logo. L'axe est optionnel et vaut « tenant principal » par défaut, si bien qu'un appelant non migré garde le comportement historique."]})]}),e.jsxs(t,{id:"toast",title:"7. Toast notifications",children:[e.jsxs(l,{children:[e.jsxs(r,{children:["Implémentation : ",e.jsx(s,{children:"ui/toast.tsx"})," +"," ",e.jsx(s,{children:"ui/toaster.tsx"})," (basé sur Radix"," ",e.jsx(s,{children:"@radix-ui/react-toast"})," + reducer custom inspiré de"," ",e.jsx(n,{children:"react-hot-toast"}),")."]}),e.jsxs(r,{children:["Hook : ",e.jsx(s,{children:"useToast()"})," dans"," ",e.jsx(s,{children:"client/src/hooks/use-toast.ts"}),"."]}),e.jsxs(r,{children:["Variantes : ",e.jsx(s,{children:"default"}),", ",e.jsx(s,{children:"destructive"}),"."]}),e.jsxs(r,{children:["Style : ",e.jsx(s,{children:"glass-toast"})," (blur + ombre composée)."]}),e.jsxs(r,{children:["Le ",e.jsx(s,{children:"<Toaster />"})," est monté une seule fois, dans"," ",e.jsx(s,{children:"App.tsx"}),"."]})]}),e.jsx(i,{children:"Usage type :"}),e.jsx(c,{language:"tsx",children:`const { toast } = useToast();
18toast({ title: t("common.success"), description: "Logo uploaded" });
19toast({ variant: "destructive", title: t("error"), description: error.message });`})]}),e.jsx(t,{id:"command-palette",title:"7a. Palette de commandes (⌘K)",children:e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/command-palette.tsx"})," — palette globale bâtie sur le wrapper ",e.jsx(s,{children:"cmdk"})," existant (",e.jsx(s,{children:"components/ui/command.tsx"}),") + Radix Dialog. Ouverte au clavier par ",e.jsx(n,{children:"⌘K"})," (macOS) / ",e.jsx(n,{children:"Ctrl+K"})," ","(",e.jsx(s,{children:"e.metaKey || e.ctrlKey"})," + touche ",e.jsx(s,{children:"k"}),"). Elle reçoit ses liens et ses actions du registre de navigation (",e.jsx(s,{children:"client/src/lib/nav/"}),") : l'app passe"," ",e.jsx(s,{children:"useNav().all"})," — toutes les pages auxquelles l'utilisateur a accès, pas seulement les pastilles de l'en-tête — et les actions de création ",e.jsx(s,{children:"useNav().createActions"})," (billet, message, rapport d'intervention ou de maintenance, scan d'une borne), filtrées par permission et par module ; le portail passe"," ",e.jsx(s,{children:"usePortalNav().topMenu"}),". Elle est montée à la fois dans"," ",e.jsx(s,{children:"app-layout.tsx"})," et ",e.jsx(s,{children:"portal/portal-layout.tsx"}),", agnostique de la coquille. Les deux coquilles y ajoutent l'action"," ",e.jsx(n,{children:"« Demander à ChargeOps AI »"}
19)," (",e.jsx(s,{children:"components/assistant/assistant-command.ts::useAssistantCommandActions"}),"), présente seulement quand ",e.jsx(s,{children:"assistant.availability.get"})," ","répond ",e.jsx(s,{children:"ready"})," — une entrée qui ouvrirait sur « demandez à votre administrateur » serait une impasse ; elle émet l'événement"," ",e.jsx(s,{children:"chargeops:assistant-open"})," qu'écoute la coquille de l'assistant (section 14i)."]})}),e.jsxs(t,{id:"calendrier",title:"7b. Calendrier et sélecteur de date",children:[e.jsxs(i,{children:[e.jsx(s,{children:"components/ui/calendar.tsx"})," enveloppe"," ",e.jsx(s,{children:"react-day-picker"})," v8 et fournit un"," ",e.jsx(s,{children:"Caption"})," maison (",e.jsx(s,{children:"DropdownCaption"}),") : listes déroulantes mois + année encadrées des flèches précédent/suivant. C'est le seul en-tête de calendrier de l'app — tout"," ",e.jsx(s,{children:"<DatePicker>"})," et tout ",e.jsx(s,{children:"<Calendar>"})," ","monté directement (centre de maintenance, filtres de sessions, plage de dates des analytics, gestionnaire de sauvegardes) en hérite."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"L'en-tête est une seule rangée flex"}),", jamais des flèches en ",e.jsx(s,{children:"absolute"}),". Les deux listes mesurent 200 px alors qu'une grille de 7 colonnes n'en laissait que 176 entre les flèches : chaque flèche se posait donc"," ",e.jsx(n,{children:"par-dessus"})," une liste (12 px de chevauchement de chaque côté, dans toutes les langues). En flux, la collision est impossible."]}),e.jsxs(r,{children:["La largeur naturelle de la rangée (272 px) dimensionne le bloc du mois ; les cellules de jour sont en ",e.jsx(s,{children:"flex-1"})," pour que les 7 colonnes couvrent exactement cette largeur. Les boutons de jour gardent leur carré de 36 px — la pastille du jour sélectionné reste ronde et le surlignage d'une plage reste continu."]}),e.jsxs(r,{children:[e.jsx(s,{children:"flex-wrap"})," sert de soupape : dans un conteneur plus étroit que la rangée (calendrier posé pleine largeur dans une carte sur très petit écran), les contrôles s'empilent au lieu de forcer le bloc du mois plus large que son parent."]}),e.jsxs(r,{children:["Les flèches utilisent"," ",e.jsx(s,{children:'buttonVariants({ variant: "outline", size: "icon-xs" })'})," ","— un carré déterministe de 32 px servi par"," ",e.jsx(s,{children:".btn-icon.btn-xs"}),"."]})]}),e.jsx(d,{tone:"warning",title:"Un Caption maison neutralise classNames.caption",children:e.jsxs(i,{children:["Dès qu'on passe ",e.jsx(s,{children:"components={{ Caption }}"}),", react-day-picker ne rend plus son propre caption :"," ",e.jsx(s,{children:"classNames.caption"}),", ",e.jsx(s,{children:"caption_label"})," et"," ",e.jsx(s,{children:"nav"})," ne sont plus lus par personne. Styler l'en-tête passe par le composant, pas par ces clés (elles ont été retirées de"," ",e.jsx(s,{children:"calendar.tsx"})," pour ne plus faire croire l'inverse)."]})}),e.jsx(d,{tone:"warning",title:"h-7 w-7 p-0 ne redimensionne pas un bouton du design system",children:e.jsxs(i,{children:["La taille des boutons vient des classes CSS"," ",e.jsx(s,{children:".btn-3d.btn-xs"})," (spécificité (0,2,0)), pas d'utilitaires Tailwind — un ",e.jsx(s,{children:"h-7 w-7 p-0"})," à (0,1,0) perd, et"," ",e.jsx(s,{children:"p-0"})," perd de toute façon contre le ",e.jsx(s,{children:"px-4"})," de la classe. Les anciennes flèches mesuraient donc 34 px avec 16 px de padding latéral au lieu des 28 px annoncés. Pour un bouton icône, prendre ",e.jsx(s,{children:'size="icon-xs"'})," plutôt que se battre avec la cascade."]})})]}),e.jsxs(t,{id:"liens-entites",title:"7c. Liens site et organisation",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/navigation/entity-links.tsx"})," — règle unique de l'application : partout où un ",e.jsx(n,{children:"nom de site"})," ","est écrit, il ouvre ",e.jsx(s,{children:"/sites/:id"})," ; partout où un"," ",e.jsx(n,{children:"nom d'organisation"})," est écrit, il ouvre"," ",e.jsx(s,{children:"/organizations/:id"}),". Trois composants la portent —"," ",e.jsx(s,{children:"SiteLink"}),", ",e.jsx(s,{children:"OrganizationLink"})," et"," ",e.jsx(s,{children:"OrganizationBadgeLink"})," (remplaçant direct de"," ",e.jsx(s,{children:"OrganizationBadge"}),")."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Montage portail."})," ",e.jsx(s,{children:"useInPortal()"})," lit"," ",e.jsx(s,{children:"useLocation()"})," : sous ",e.jsx(s,{children:"/portal/*"})," les deux noms redeviennent du texte simple, parce que"," ",e.jsx(s,{children:"ProtectedRoute"})," renverrait l'opérateur vers"," ",e.jsx(s,{children:"/portal"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Permission."})," La route"," ",e.jsx(s,{children:"/organizations/:orgId"})," exige"," ",e.jsx(s,{children:"viewOrganizationPage"})," ; sans ce droit le lien n'apparaît pas (il ne menait qu'à la redirection d'accès refusé). Les pages de site n'ont pas de garde équivalente."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Propagation du clic."})," Ces noms vivent presque toujours dans une ligne ou une carte elle-même cliquable : sans"," ",e.jsx(s,{children:"stopPropagation"}),", le gestionnaire de la ligne s'exécute après celui du lien et gagne — le clic sur le site ouvrait le rapport. Les composants l'appellent systématiquement."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Deux traitements."})," ",e.jsx(s,{children:'variant="text"'})," ","(défaut) souligne un nom écrit en toutes lettres au survol ;"," ",e.jsx(s,{children:'variant="wrapper"'})," pose un halo autour d'une pastille déjà stylée. Quand le lien est désactivé (portail, permission, id absent), ",e.jsx(s,{children:"OrganizationBadgeLink"})," rend la pastille nue, sans conteneur supplémentaire, pour ne pas déformer les mises en page flex environnantes."]})]}),e.jsx(d,{tone:"warning",title:"Une ancre ne s'imbrique pas dans une ancre",children:e.jsxs(i,{children:["Là où la carte entière est déjà un ",e.jsx(s,{children:"<Link>"})," — la ligne du temps de l'historique de la borne, panneaux « top sites » du tableau de bord opérationnel — le nom du site reste du texte : un ",e.jsx(s,{children:"<a>"}
19)," imbriqué est du HTML invalide. Les noms rendus à l'intérieur d'un contrôle de formulaire (listes déroulantes, comboboxes, cases à cocher), d'un assistant ou d'une boîte de dialogue restent également du texte — y naviguer abandonnerait la saisie en cours."]})})]}),e.jsxs(t,{id:"error-boundary",title:"8. Error boundary",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/error-boundary.tsx"})," — composant de classe React :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Trois niveaux : ",e.jsx(s,{children:"app"})," (page complète avec card centrée + icône ",e.jsx(s,{children:"AlertTriangle"}),"), ",e.jsx(s,{children:"page"})," (zone d'erreur dans le layout), ",e.jsx(s,{children:"component"})," (mini-erreur inline)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Détection chunks périmés"})," :"," ",e.jsx(s,{children:"isStaleChunkError()"})," reconnaît"," ",e.jsx(s,{children:"Failed to fetch dynamically imported module"})," /"," ",e.jsx(s,{children:"ChunkLoadError"}),". Le rechargement automatique"," ",e.jsx(s,{children:"window.location.reload()"})," n'a désormais lieu que lorsqu'un ",e.jsx(n,{children:"nouveau déploiement est réellement en ligne"})," ","— confirmé par ",e.jsx(s,{children:"isNewDeploymentLive()"})," (",e.jsx(s,{children:"client/src/lib/stale-chunk.ts"}),"), qui re-télécharge"," ",e.jsx(s,{children:"/index.html"})," (servi ",e.jsx(s,{children:"no-store"}),", avec une URL horodatée qui contourne le cache du service worker — sinon une coquille périmée en cache déclenchait une boucle de rechargement sur Safari) et compare la signature des modules servis (hash du"," ",e.jsx(s,{children:'<script type="module" src>'})," d'entrée +"," ",e.jsx(s,{children:'<link rel="modulepreload" href>'}),", via"," ",e.jsx(s,{children:"moduleSignatureFromHtml()"}),") à celle capturée au démarrage. Sur un nouveau build, le rechargement est encore réessayé jusqu'à 3 fois avec une courte temporisation (0,6 / 1,2 / 1,8 s) car un déploiement en cours de propagation peut faire échouer un premier essai ; le budget est suivi via"," ",e.jsx(s,{children:"sessionStorage"})," (",e.jsx(s,{children:"chargeops:stale-chunk-reloads"}),") dans une fenêtre de 60 s pour ne jamais boucler, et un discret écran « Mise à jour vers la dernière version… » reste affiché pendant la vérification. Sur un échec ",e.jsx(n,{children:"transitoire"})," du même build (le chunk existe toujours, son téléchargement a échoué : Safari qui abandonne un fetch de module concurrent, coupure réseau passagère),"," ",e.jsx(s,{children:"checkDeployment()"})," renvoie ",e.jsx(s,{children:'"same"'})," et la page se recharge ",e.jsx(n,{children:"une fois"})," après 1 s — au plus une fois par minute (",e.jsx(s,{children:"chargeops:transient-chunk-reload-at"}),"), jamais quand le serveur n'a pas répondu (",e.jsx(s,{children:'"unknown"'}),") et jamais sans ",e.jsx(s,{children:"sessionStorage"}),". Recharger est le seul vrai nouvel essai : ",e.jsx(s,{children:"React.lazy"})," garde en cache l'import rejeté, un remontage rejoue donc la même erreur. Pour la même raison, le bouton ",e.jsx(n,{children:"Retry"})," de la card recharge la page sur une erreur de chunk. En amont, ",e.jsx(s,{children:"sw.js"})," réessaie une fois un fetch ",e.jsx(s,{children:"/assets/*"})," rejeté (300 ms,"," ",e.jsx(s,{children:'cache: "reload"'}),") avant de le laisser remonter en"," ",e.jsx(s,{children:"net::ERR_FAILED"}),"."]}),e.jsxs(r,{children:["En dev, affiche ",e.jsx(s,{children:"error?.message"})," ; en prod, masqué."]}),e.jsxs(r,{children:["HOC ",e.jsx(s,{children:"withErrorBoundary(Component, level)"})," pour wrapper rapide."]}),e.jsxs(r,{children:["Dans ",e.jsx(s,{children:"App.tsx"}),", double wrapping :"," ",e.jsx(s,{children:'<ErrorBoundary level="app">'})," au sommet,"," ",e.jsx(s,{children:'<ErrorBoundary level="component">'})," autour du"," ",e.jsx(s,{children:"<Router>"}),"."]})]})]}),e.jsxs(t,{id:"pwa-banner",title:"9. PWA Install Banner",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/pwa-install-banner.tsx"})," — bannière flottante (",e.jsx(s,{children:"fixed bottom-3 left-3 right-3"}),") :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Ne s'affiche ",e.jsx(n,{children:"que sur mobile"})," (",e.jsx(s,{children:"useIsMobile()"}),", largeur seule), si"," ",e.jsx(s,{children:"canInstall"})," est vrai et l'app n'est pas déjà installée."]}),e.jsxs(r,{children:["Déclencheurs : événements ",e.jsx(s,{children:"beforeinstallprompt"})," et"," ",e.jsx(s,{children:"appinstalled"})," capturés par ",e.jsx(s,{children:"usePWAInstall()"})," ","(",e.jsx(s,{children:"client/src/hooks/use-pwa-install.tsx"}),")."]}),e.jsxs(r,{children:["Détection du mode installé : ",e.jsx(s,{children:"useIsStandalone()"})," ","(",e.jsx(s,{children:"client/src/hooks/use-is-standalone.ts"}),", S4.3) — la requête média ",e.jsx(s,{children:"(display-mode: standalone)"}),", ou"," ",e.jsx(s,{children:"navigator.standalone === true"})," sur iOS Safari, suivie en direct (événement ",e.jsx(s,{children:"change"}),"). Une seule définition partagée par la bannière et le balayage retour (9b)."]}),e.jsxs(r,{children:["Texte traduit (",e.jsx(s,{children:"pwaInstallTitle"}),","," ",e.jsx(s,{children:"pwaInstallDescription"}),", ",e.jsx(s,{children:"pwaInstallButton"}),","," ",e.jsx(s,{children:"pwaInstallDismiss"}),")."]}),e.jsxs(r,{children:["État de fermeture local (",e.jsx(s,{children:"dismissed"}),", non persisté — réapparaît au prochain chargement si l'app reste éligible)."]})]})]}),e.jsxs(t,{id:"app-installee",title:"9b. App installée : raccourcis, pastille et balayage retour (S4.3)",children:[e.jsx(i,{children:"Ce que l'app gagne une fois installée (CHARGEOPS-656). Rien de tout cela ne change le balisage desktop : la pastille ne rend rien, et le reste n'existe que sous 1024 px."}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Manifeste"})," (",e.jsx(s,{children:"client/public/manifest.json"}),") — ",e.jsx(s,{children:'"id": "/"'})," (identité stable de l'app installée, indépendante de ",e.jsx(s,{children:"start_url"}),") ; trois"," ",e.jsx(s,{children:"shortcuts"})," : ",e.jsx(s,{children:"/scan?open=1"})," (ouvre la feuille de scan, S4.1), ",e.jsx(s,{children:"/service-reports/new"})," et"," ",e.jsx(s,{children:"/my-day"}),", aux noms bilingues courts (« Scanner / Scan », « Rapport / Report », « Ma journée / My day ») puisqu'un manifeste ne se traduit pas à l'exécution, chacun avec une icône 96 px (",e.jsx(s,{children:"client/public/icons/shortcut-*-96.png"})," : les glyphes lucide du registre de navigation — ",e.jsx(s,{children:"ScanLine"}),","," ",e.jsx(s,{children:"FileText"}),", ",e.jsx(s,{children:"Sun"})," — en blanc sur le marine de la barre du haut) ; deux ",e.jsx(s,{children:"screenshots"})," ",e.jsx(s,{children:'form_factor: "narrow"'})," en 375×812 (",e.jsx(s,{children:"client/public/screenshots/phone-list.png"})," :"," ",e.jsx(s,{children:"/support"})," ; ",e.jsx(s,{children:"phone-detail.png"})," : une fiche de borne), capturées dans le harnais d'audit mobile (§21b)."," ",e.jsx(s,{children:"client/src/__tests__/pwa-manifest.test.ts"})," vérifie le JSON, l'ordre des raccourcis et que chaque PNG existe à la taille déclarée (lue dans son en-tête IHDR). Un raccourci hors du rôle (Ma journée pour un exploitant de site) tombe sur le"," ",e.jsx(s,{children:"blockForRoles"})," de sa route, qui renvoie à l'accueil."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pastille"})," — ",e.jsx(s,{children:"useAppBadge()"}
19)," (",e.jsx(s,{children:"client/src/components/shell/use-app-badge.ts"}),") appelle"," ",e.jsx(s,{children:"navigator.setAppBadge(total)"})," quand"," ",e.jsx(s,{children:"useInboxUnread().total"})," (cloche + messages, le nombre de l'onglet Réception) dépasse 0, et"," ",e.jsx(s,{children:"clearAppBadge()"})," à 0 ou au démontage. Il attend"," ",e.jsx(s,{children:"useInboxUnread().loaded"})," (le compteur de la cloche a répondu) : avant, 0 veut dire « pas encore connu » et effacerait pour rien la pastille laissée par la session précédente. Les promesses rejetées sont avalées ; sans l'API, rien. Monté une seule fois, pour tous les formats, par ",e.jsx(s,{children:"AppBadge"})," dans"," ",e.jsx(s,{children:"App.tsx"})," (à côté de ",e.jsx(s,{children:"NavPreloader"}),"), et seulement pour un utilisateur connecté : les lectures de non-lus sont des requêtes de locataire, et la déconnexion démonte le composant, donc efface la pastille. Le service worker de S5 met à jour la même pastille app fermée."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Balayage retour"})," —"," ",e.jsxs(s,{children:["useEdgeSwipeBack(","{ enabled, onBack }",")"]})," (",e.jsx(s,{children:"client/src/components/shell/use-edge-swipe-back.ts"}),"). Constantes : ",e.jsx(s,{children:"EDGE_PX = 20"}),","," ",e.jsx(s,{children:"COMMIT_RATIO = 0.3"}),","," ",e.jsx(s,{children:"FLICK_PX_PER_MS = 0.5"}),","," ",e.jsx(s,{children:"MAX_ANGLE_DEG = 30"}),". Un écouteur"," ",e.jsx(s,{children:"touchstart"})," en capture sur ",e.jsx(s,{children:"window"})," accepte un seul doigt posé à ≤ 20 px du bord gauche ; les écouteurs"," ",e.jsx(s,{children:"touchmove"})," (non passif, pour bloquer le défilement une fois l'axe horizontal fixé) / ",e.jsx(s,{children:"touchend"})," ne vivent que le temps du geste. L'axe se décide après 8 px (comme"," ",e.jsx(s,{children:"detail/tab-swipe.ts"}),") : plus de 30° d'inclinaison ou vers la gauche, le geste est abandonné. Au relâcher, il valide au-delà de 30 % de ",e.jsx(s,{children:"innerWidth"})," ou si la vitesse des 100 dernières ms dépasse 0,5 px/ms ; sinon ressort (200 ms,"," ",e.jsx(s,{children:"cubic-bezier(0.4, 0, 0.2, 1)"}),"). Ignoré : départ sur"," ",e.jsx(s,{children:"[data-no-edge-swipe]"}),", sur une ligne balayable (",e.jsx(s,{children:"[data-no-tab-swipe]"}),"), sur une carte (",e.jsx(s,{children:".gm-style"}),", Leaflet, MapLibre), dans un conteneur qui défile horizontalement, ou quand un"," ",e.jsx(s,{children:"[role=dialog][data-state=open]"})," existe. Pendant le geste, seul le conteneur de contenu marqué"," ",e.jsx(s,{children:"data-edge-swipe-page"})," suit le doigt (",e.jsx(s,{children:"translateX"})," + ombre à gauche ; le chrome fixe ne bouge pas) — posé par ",e.jsx(s,{children:"AppLayout"})," (le wrapper de contenu) et"," ",e.jsx(s,{children:"PortalLayout"})," (son ",e.jsx(s,{children:"<main>"}),") sous 1024 px seulement ; Home, dont la racine contient aussi le chrome, n'en a pas : le geste y valide sans animation. Sous"," ",e.jsx(s,{children:"prefers-reduced-motion"}),", aucune translation, seulement la décision au relâcher. ",e.jsx(s,{children:"edgeSwipeOwnsGesture()"})," est vrai pendant un geste accepté :"," ",e.jsx(s,{children:"DetailLayout"})," ne démarre alors pas son balayage d'onglets (sinon le même glissement ouvrirait aussi l'onglet précédent)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Montage"})," — ",e.jsx(s,{children:"PhoneChrome"})," et"," ",e.jsx(s,{children:"TabletShell"})," appellent"," ",e.jsx(s,{children:"useEdgeSwipeBack"})," avec"," ",e.jsx(s,{children:"enabled = useIsStandalone() && pageIntérieure && !splitActif"})," ","et le ",e.jsx(s,{children:"goBack"})," de ",e.jsx(s,{children:"useBackAction(isSplitActive)"})," ","(",e.jsx(s,{children:"client/src/components/shell/use-back-action.ts"}),") — la même action que le ← de ",e.jsx(s,{children:"TopBar"}),", qui l'utilise aussi : retour dans l'historique s'il y a un historique dans l'app, sinon le parent du fil d'Ariane. Dans un onglet de navigateur, rien n'est branché. Tests :"," ",e.jsx(s,{children:"shell/__tests__/use-edge-swipe-back.test.tsx"})," (geste),"," ",e.jsx(s,{children:"edge-swipe-mount.test.tsx"})," (câblage),"," ",e.jsx(s,{children:"use-app-badge.test.tsx"}),","," ",e.jsx(s,{children:"hooks/__tests__/use-is-standalone.test.tsx"}),"."]})]})]}),e.jsxs(t,{id:"column-configurator",title:"11. Configurateur de colonnes",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/column-configurator.tsx"})," (table"," ",e.jsx(s,{children:"all-chargers"}),") :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Liste ",e.jsx(s,{children:"AVAILABLE_COLUMNS"})," canonique (24 colonnes :"," ",e.jsx(s,{children:"siteName"}),", ",e.jsx(s,{children:"name"}),", ",e.jsx(s,{children:"brand"}),","," ",e.jsx(s,{children:"model"}),", ",e.jsx(s,{children:"connectorCount"}),","," ",e.jsx(s,{children:"maxPower"}),", ",e.jsx(s,{children:"currentPower"}),","," ",e.jsx(s,{children:"phase"}),", ",e.jsx(s,{children:"modelChargerClass"}),","," ",e.jsx(s,{children:"soldByPolara"}),", ",e.jsx(s,{children:"site.ocppDashboard"}),","," ",e.jsx(s,{children:"connectivity"}),", ",e.jsx(s,{children:"isActive"}),","," ",e.jsx(s,{children:"installationDate"}),", ",e.jsx(s,{children:"warrantyEnd"}),","," ",e.jsx(s,{children:"warrantyDuration"}),", ",e.jsx(s,{children:"warrantyValid"}),","," ",e.jsx(s,{children:"serialNumber"}),", ",e.jsx(s,{children:"pedestal"}),","," ",e.jsx(s,{children:"carrier"}),", ",e.jsx(s,{children:"simIccid"}),","," ",e.jsx(s,{children:"ocppUsername"}),", ",e.jsx(s,{children:"ocppPassword"}),","," ",e.jsx(s,{children:"actions"}),")."]}),e.jsxs(r,{children:["Lecture/écriture via"," ",e.jsx(s,{children:"api.system.queries.getColumnConfiguration"})," et"," ",e.jsx(s,{children:"api.system.mutations.setDefaultColumnConfiguration"})," ","(table Convex ",e.jsx(s,{children:"columnConfigurations"}),", indexée par"," ",e.jsx(s,{children:"pageId"})," et ",e.jsx(s,{children:"userId"}),")."]}),e.jsxs(r,{children:["Drag-and-drop fluide via ",e.jsx(s,{children:"<Reorder.Group>"})," ","(Framer Motion)."]}),e.jsxs(r,{children:["Toggle visibilité par ",e.jsx(s,{children:"<Checkbox>"}),"."]}),e.jsxs(r,{children:["Boutons ",e.jsx(n,{children:"Save"})," / ",e.jsx(n,{children:"Reset"})," + bandeau"," ",e.jsx(s,{children:"ShieldAlert"})," jaune avertissant que"," ",e.jsx(n,{children:"les changements s'appliquent à tous les utilisateurs"})," ","(les utilisateurs ayant personnalisé l'ordre conservent leur préférence individuelle)."]})]}),e.jsx(i,{children:"Schema Convex (extrait) :"}),e.jsx(c,{language:"ts",children:`const columnConfigurations = defineTable({
20  userId: v.optional(v.id("users")),  // null = défaut org
21  pageId: v.string(),                  // "all-chargers", etc.
22  columns: v.array(columnConfigValidator),
23  isDefault: v.boolean(),
24  createdAt: v.number(),
25  updatedAt: v.number(),
26}).index("by_userId_pageId", ["userId", "pageId"])
27  .index("by_pageId_isDefault", ["pageId", "isDefault"]);`})]}),e.jsxs(t,{id:"surface-messagerie",title:"11a. Messagerie interne (/messages)",children:[e.jsxs(i,{children:["La page ",e.jsx(s,{children:"/messages"})," (",e.jsx(s,{children:"client/src/pages/messages.tsx"}),") est une vue scindée : volet liste ",e.jsx(s,{children:"w-80 lg:w-96"})," à défilement propre + volet fil (paginé par curseur via ",e.jsx(s,{children:"usePaginatedQuery"}
27),", séparateurs de journée, marquage lu automatique à l'ouverture, à l'arrivée de messages et au retour d'onglet). Sur mobile les deux volets s'empilent — la liste sans sélection, le fil avec chevron retour — piloté par la route ",e.jsx(s,{children:"/messages/:conversationId"}),". Chaque volet est une carte appariée"," ",e.jsx(s,{children:'"co-card"'})," ; les bulles utilisent"," ",e.jsx(s,{children:"co-card-inset"}),". L'icône d'en-tête (",e.jsx(s,{children:"messages-header-icon.tsx"}),") siège à côté de la cloche sur l'en-tête marine et suit sa règle : neutres"," ",e.jsx(s,{children:"text-gray-600 dark:text-slate-300"}),", pastille rouge du même gabarit que la cloche, comptant les"," ",e.jsx(n,{children:"conversations"})," non lues. Elle est montée dans les deux en-têtes desktop qui portent la cloche —"," ",e.jsx(s,{children:"app-layout.tsx"}),", l'en-tête propre de l'accueil (",e.jsx(s,{children:"pages/home.tsx"}),", hors ",e.jsx(s,{children:"AppLayout"}),"). Sur téléphone et tablette, c'est l'onglet Boîte de réception de la coquille téléphone qui compte les conversations non lues (section 14). Le compositeur réutilise"," ",e.jsx(s,{children:"MentionTextarea"})," (module billets) tel quel et y ajoute le sélecteur de références (",e.jsx(s,{children:"reference-picker.tsx"}),", cmdk) : jetons ",e.jsx(s,{children:"[[ref:type:id]]"})," dans le texte, libellés figés côté serveur, puces ",e.jsx(s,{children:"reference-chip.tsx"})," menant aux pages canoniques (une borne pointe vers la page de son site)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Deux entités répondent à un nom qui n'est pas le leur"})," ","dans ",e.jsx(s,{children:"searchReferences"}),", parce que ce sont les noms qu'on a réellement sous la main. Une ",e.jsx(n,{children:"borne"})," se trouve par son ",e.jsx(s,{children:"serialNumber"})," autant que par son nom : sous-chaîne repliée sur le bassin récent, plus une sonde exacte sur l'index ",e.jsx(s,{children:"by_tenant_serialKey"})," (numéro rogné et en minuscules, ",e.jsx(s,{children:"shared/charger-serial.ts"})," — toute casse d'un numéro à casse mixte répond ; bornes actives seulement, jusqu'au plafond par type ; CHARGEOPS-513) qui la ramène même hors de la fenêtre de 200 lignes — les résultats exacts passent en tête, et le libellé de ligne devient"," ",e.jsx(s,{children:"nom · numéro de série · site"}),". Un"," ",e.jsx(n,{children:"site"})," se trouve par le nom de son"," ",e.jsx(n,{children:"organisation"})," : les organisations visibles (mêmes lignes que ",e.jsx(s,{children:"organizations.queries.list"}),", filtrées par portée) sont comparées à la requête, et les sites d'une organisation qui matche sont lus par"," ",e.jsx(s,{children:"by_organizationId"})," — pas par la fenêtre de récence, qui écarterait justement les sites anciens qu'on demande. L'ordre d'insertion fait l'ordre du résultat : les matches sur le nom du site restent devant. Le mur de portée ne bouge pas :"," ",e.jsx(s,{children:"filterSitesByScope"})," s'applique aussi aux sites arrivés par l'organisation."]}),e.jsxs(i,{children:["Les lignes de site portent donc leur organisation (",e.jsx(s,{children:"organizationId"})," / ",e.jsx(s,{children:"organizationName"})," /"," ",e.jsx(s,{children:"organizationColor"}),") et sont rendues par"," ",e.jsx(s,{children:"SiteCommandItems"})," —"," ",e.jsx(n,{children:"les rangées mêmes du sélecteur de site de l'en-tête"})," ",": en-tête d'organisation (pastille, compteur, filet teinté), tri alphabétique, repli sur liste plate sans organisation."," ",e.jsx(s,{children:"siteRowsFromHits"})," (exporté pour les tests) fait la conversion et exige un ",e.jsx(n,{children:"nom"})," d'organisation, pas seulement un id : une organisation supprimée laisserait sinon un en-tête vide — le site tombe dans « Non assigné ». Pas de"," ",e.jsx(s,{children:"onOrgSelect"})," ici : naviguer vers une page d'organisation en pleine rédaction jetterait le brouillon. Le plafond des sites passe à 30 ",e.jsx(n,{children:"uniquement"})," quand le sélecteur est filtré sur « Sites » ; sinon le plafond commun de 8 tient, sans quoi une grosse organisation repousserait bornes, cas et rapports hors de vue."]}),e.jsx(i,{children:"La v2 ajoute par-dessus :"}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Fil moderne"}
27)," : les messages consécutifs d'un même auteur (moins de 5 min) se groupent en un bloc, libellés « Aujourd'hui / Hier », séparateur « Nouveaux messages » au premier non-lu à l'ouverture, pastille flottante de retour en bas avec compteur de nouveaux messages ; les actions de message restent visibles au tactile."," ",e.jsx(s,{children:"getConversation"})," renvoie ",e.jsx(s,{children:"null"})," (et non une erreur) pour un non-membre → état « plus participant » dans l'UI."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Onglets et archive"})," : Toutes / Non lues / Archivées (archivage ",e.jsx(n,{children:"par membre"})," via"," ",e.jsx(s,{children:"conversationMembers.archivedAt"}),", désarchivage automatique à toute activité) + filtre client insensible aux accents sur la fenêtre chargée."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Recherche"})," : le champ du volet liste lance sur Entrée ",e.jsx(s,{children:"searchMessages"})," — premier"," ",e.jsx(s,{children:"searchIndex"})," Convex du dépôt (",e.jsx(s,{children:"search_text"})," ","sur ",e.jsx(s,{children:"messages.text"}),", filtré ",e.jsx(s,{children:"tenantId"}),") ; les résultats sont refiltrés en code par l'appartenance de l'appelant, surlignent l'extrait et ouvrent le fil au clic."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Brouillons"})," : localStorage"," ",e.jsx(s,{children:"messages.draft.<conversationId>"})," (hook"," ",e.jsx(s,{children:"use-local-storage-draft.ts"}),", TTL 14 jours, purge opportuniste des clés périmées) — le brouillon survit au changement de fil."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Présence"})," : hook de battement de cœur (~30 s, ~3 s pendant la saisie) vers"," ",e.jsx(s,{children:"convex/messages/presence.ts"})," ; la lecture considère « en ligne » sous 75 s et « en train d'écrire » sous 8 s (points verts sur les tête-à-tête, indicateur au-dessus du composeur, rangée « Vu par » servie par la requête dédiée"," ",e.jsx(s,{children:"getReadReceipts"}),", couleurs d'avatar dérivées par hachage FNV-1a)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pièces jointes"})," : jusqu'à 6 images par message (trombone, glisser-déposer, collage), recompression JPEG côté client (20 Mo max), vignettes en bulle + visionneuse."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Liens profonds"})," : la page lit"," ",e.jsx(s,{children:"?ref=type:id"})," / ",e.jsx(s,{children:"?new=1"})," (boutons « Discuter » du tableau de bord de site et du dialogue de borne — la v3 les retire des pages cas / rapports ; ⌘K « Nouveau message ») — la référence est revalidée côté serveur (",e.jsx(s,{children:"resolveReference"}),") avant d'être pré-attachée au composeur. La page vérifie aussi le module côté client (module désactivé → état d'accueil, pas d'erreur) et le titre de l'onglet du navigateur porte « (N) »."]})]}),e.jsx(i,{children:"La v3 ajoute :"}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Répondre / épingler / transférer / planifier"})," : « Répondre » pose une puce au-dessus du composeur (Échap annule) et la bulle (",e.jsx(s,{children:"message-bubble.tsx"}),") rend un bloc de citation dont le clic saute au message d'origine avec une pulsation de surbrillance ; ",e.jsx(s,{children:"pinned-strip.tsx"})," ","rend le bandeau des épingles au-dessus du fil (saut au clic) ;"," ",e.jsx(s,{children:"forward-dialog.tsx"})," + ",e.jsx(s,{children:"forward-handoff.ts"})," ","transportent texte et puces de référence vers le composeur prérempli de la conversation cible ; « Envoyer plus tard » (préréglages 1 h / demain 9 h / personnalisé) alimente une puce d'envois en attente avec annulation par entrée."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Envoi optimiste"})," : ",e.jsx(s,{children:"sendMessage"})," passe par ",e.jsx(s,{children:"insertAtTop"})," de ",e.jsx(s,{children:"convex/react"})," sur le cache paginé de ",e.jsx(s,{children:"listMessages"})," (écho local, état « envoi » discret, retrait en cas d'échec) ; les réactions par"," ",e.jsx(s,{children:"optimisticallyUpdateValueInPaginatedQuery"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Lectures conditionnées à la visibilité"})," : le marquage lu ne part que si"," ",e.jsx(s,{children:'document.visibilityState === "visible"'})," — un onglet d'arrière-plan ne marque plus les fils comme lus, ne tue plus les courriels-résumés et ne fabrique plus de « Vu par »."]}),e.jsxs(r,{children:[e.jsxs(n,{children:["Lien profond ",e.jsx(s,{children:"?messageId="})]})," : les résultats de recherche (",e.jsx(s,{children:"message-search.tsx"}),") ouvrent le fil directement sur le message trouvé (défilement + pulsation) — même mécanique de saut que les citations et les épingles."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Battement au repos"})," :"," ",e.jsx(s,{children:"use-messaging-presence.ts"})," cesse le battement après 5 min sans interaction (",e.jsx(s,{children:"IDLE_AFTER_MS"}),") et reprend instantanément à la première interaction ou au retour d'onglet ; les infobulles « Actif il y a X min » lisent le"," ",e.jsx(s,{children:"lastSeen"})," exposé par les requêtes de présence (fenêtre 24 h)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Panneau d'informations"})," :"," ",e.jsx(s,{children:"conversation-info-panel.tsx"})," — participants avec présence en direct, grille des images partagées (ouvre la visionneuse), puces de référence partagées de la conversation."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Boîte de réception"})," (",e.jsx(s,{children:"conversation-list.tsx"}),") : menu contextuel par ligne (lu / non lu, niveau de notification, archiver, quitter), pastille @ des mentions non lues, comptes sur les onglets, glissement-archivage au tactile."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Design"})," : bulle ",e.jsx(s,{children:"co-card-inset"})," à queue asymétrique, ses propres messages distingués par un anneau indigo intérieur, libellés de journée collants au défilement, micro-animations d'entrée et de réaction respectant"," ",e.jsx(s,{children:"prefers-reduced-motion"}),", pastilles de réaction groupées avec compte, points de présence à anneau pulsé, squelettes de chargement et états vides plus accueillants, composeur auto-extensible avec voile de dépôt et ↑ pour rouvrir son dernier message en édition."]})]})]}
27),e.jsxs(t,{id:"kpi-clic-destination",title:"11b. Cartes KPI — clic vers la page source",children:[e.jsxs(i,{children:["Chaque carte KPI ouvre la page d'où vient son chiffre, sur les trois surfaces : la rangée de l'accueil (",e.jsx(s,{children:"components/home/home-kpi-row.tsx"}),"), celle du Portail opérateur (",e.jsx(s,{children:"components/portal/portal-kpi-row.tsx"}),") et les tuiles de ",e.jsx(s,{children:"/operations-dashboard"})," (",e.jsx(s,{children:"components/operations/kpi-tile.tsx"}),", prop optionnelle"," ",e.jsx(s,{children:"kpiId"})," passée par tous les appels de"," ",e.jsx(s,{children:"KpiTile"})," ; les enveloppes ",e.jsx(s,{children:"<Link>"})," ","écrites à la main ont été retirées)."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Une seule carte des destinations"})," :"," ",e.jsx(s,{children:"shared/kpis.ts::KPI_DESTINATIONS"})," est un"," ",e.jsx(s,{children:"Record<KpiId, KpiDestination>"})," complet (",e.jsx(s,{children:"path"}),", ",e.jsx(s,{children:"search"}),","," ",e.jsx(s,{children:"fallbackPath"}),", ",e.jsx(s,{children:"portalPath"}),","," ",e.jsx(s,{children:"portalSearch"}),") — ajouter un KPI sans décider où il mène est une erreur de compilation, et une carte ne peut pas diverger de sa tuile jumelle."," ",e.jsx(s,{children:"search"})," pré-filtre la page d'arrivée là où elle lit déjà ses filtres dans l'URL (",e.jsx(s,{children:"/all-chargers?warranty=expired"}),","," ",e.jsx(s,{children:"/compliance?kpi=rsa_action|ntep_action"}),") ou consomme un paramètre à usage unique (",e.jsx(s,{children:"/support?view="}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Résolution"})," :"," ",e.jsxs(s,{children:["resolveKpiHref(id, ","{ surface, isRouteEnabled }",")"]})," ","rend une adresse ou ",e.jsx(s,{children:"null"}),". Surface"," ",e.jsx(s,{children:'"portal"'})," : seulement ",e.jsx(s,{children:"portalPath"})," (+"," ",e.jsx(s,{children:"portalSearch"}),", que seule la carte « Garanties expirées » porte : ",e.jsx(s,{children:"/portal/fleet?warranty=expired"}),", CHARGEOPS-354) — les autres cartes de parc n'ont pas de page dans le portail et restent inertes, et l'argent n'y entre jamais. Surface"," ",e.jsx(s,{children:'"app"'})," : ",e.jsx(s,{children:"path"})," (+ ",e.jsx(s,{children:"search"}),"), sinon ",e.jsx(s,{children:"fallbackPath"})," (« Sites avec GDP » →"," ",e.jsx(s,{children:"/power-management"}),", repli ",e.jsx(s,{children:"/clients"}),"), sinon ",e.jsx(s,{children:"null"}),". Le hook"," ",e.jsx(s,{children:'hooks/use-kpi-href.ts::useKpiHref("app" | "portal")'})," ","branche ",e.jsx(s,{children:"useModuleAccess().isRouteEnabled"})," et le drapeau de ",e.jsx(s,{children:"/power-management"})," : une carte dont le module est désactivé reste inerte plutôt que d'atterrir sur « module indisponible »."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Clic ou glissement"})," :"," ",e.jsx(s,{children:"SortableKpiGrid"})," (",e.jsx(s,{children:"kpi-row-kit.tsx"}),") reçoit un ",e.jsx(s,{children:"hrefFor"})," optionnel. Un clic simple sur la carte navigue ; c'est le seuil de 6 px du ",e.jsx(s,{children:"PointerSensor"})," de dnd-kit qui distingue le clic du glissement."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Garde contre la remontée des portails"})," : le menu de remplacement est rendu dans un portail Radix, mais React fait remonter ses clics le long de l'arbre de composants, pas du DOM. Le ",e.jsx(s,{children:"onClick"})," de la carte ignore donc toute cible que le DOM de la carte ne contient pas, ainsi que tout ce qui se trouve dans ",e.jsx(s,{children:"[data-kpi-no-nav]"})," (les contrôles de survol)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Chemin clavier"})," : ",e.jsx(s,{children:"Enter"}),"/",e.jsx(s,{children:"Space"})," sur la carte appartiennent au glisser-déposer clavier de dnd-kit. La pastille de survol à flèche (",e.jsx(s,{children:"ArrowUpRight"}),", vrai ",e.jsx(s,{children:"<Link>"}),","," ",e.jsx(s,{children:'data-testid="link-open-kpi-<id>"'}),") est donc le chemin du clavier, du clic du milieu et du Ctrl/⌘ + clic."]}),e.jsxs(r,{children:[e.jsxs(n,{children:["Paramètre ",e.jsx(s,{children:"?view="})," à usage unique"]})," :"," ",e.jsx(s,{children:"lib/support-view-param.ts::parseSupportView"})," accepte"," ",e.jsx(s,{children:"mine | attention | closed"}),". Les filtres de"," ",e.jsx(s,{children:"pages/support-tickets.tsx"})," sont de l'état local, pas de l'URL : la page consomme le paramètre une fois au montage, le rejoue par son ",e.jsx(s,{children:"applyView"})," (",e.jsx(s,{children:"mineOnly"})," / ",e.jsx(s,{children:"attentionOnly"})," / statuts du groupe fermé), puis le retire de l'URL, de sorte qu'un rafraîchissement ou un retour arrière ne l'applique pas par-dessus les choix de l'utilisateur. ",e.jsx(s,{children:"?new=1"})," ","(l'action de création « Nouveau billet » : + du téléphone, + du rail tablette, ⌘K) est retiré de la même façon, mais lu par"," ",e.jsx(s,{children:"useSearch()"})," de wouter plutôt qu'une fois au montage : il ouvre donc le dialogue même quand /support est déjà affiché et que seule la requête change ; le chemin"," ",e.jsx(s,{children:"?new=1&siteId="})," du premier montage attend toujours la vérification du site."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Tests"})," :"," ",e.jsx(s,{children:"shared/__tests__/kpi-destinations.test.ts"}),","," ",e.jsx(s,{children:"components/kpi/__tests__/kpi-row-kit.test.tsx"}),","," ",e.jsx(s,{children:"lib/__tests__/support-view-param.test.ts"}),"."]})]})]})]}),e.jsxs(o,{id:"partie-2",title:"Partie 2 — Mobile et responsive",children:[e.jsxs(t,{id:"strategie-responsive",title:"12. Stratég
27ie responsive",children:[e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Mobile-first"})," : Tailwind breakpoints standard (",e.jsx(s,{children:"sm: 640px"}),", ",e.jsx(s,{children:"md: 768px"}),","," ",e.jsx(s,{children:"lg: 1024px"}),", ",e.jsx(s,{children:"xl: 1280px"}),","," ",e.jsx(s,{children:"2xl: 1536px"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Uniquement basé sur la largeur"})," (CHARGEOPS-385) :"," ",e.jsx(s,{children:"useViewport()"})," renvoie"," ",e.jsx(s,{children:'"phone" | "tablet" | "desktop"'})," à partir de deux"," ",e.jsx(s,{children:"matchMedia"})," (",e.jsx(s,{children:"PHONE_MAX = 639"}),","," ",e.jsx(s,{children:"TABLET_MAX = 1023"}),") — un Chrome desktop redimensionné en dessous de ",e.jsx(s,{children:"1024 px"})," bascule lui aussi en layout mobile, il n'y a plus de vérification"," ",e.jsx(s,{children:"pointer: coarse"}),"."]}),e.jsxs(r,{children:["Le CSS contient également des règles"," ",e.jsx(s,{children:"@media (max-width: 768px)"})," pour la classe"," ",e.jsx(s,{children:".mobile-hide-nav"})," et ",e.jsx(s,{children:".mobile-compact"}),"."]})]}),e.jsx(u,{headers:["Breakpoint","Usage typique"],rows:[[e.jsx(s,{children:"sm: 640px"}),"Affiche labels longs, padding plus généreux"],[e.jsx(s,{children:"md: 768px"}),"Layout 2 colonnes pour formulaires"],[e.jsx(s,{children:"lg: 1024px"}),e.jsxs(e.Fragment,{children:[e.jsx(n,{children:"Frontière mobile/desktop"})," côté JS"]})],[e.jsx(s,{children:"xl: 1280px"}),"Layouts dashboard 3+ colonnes"],[e.jsx(s,{children:"2xl: 1536px"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"max-w-screen-2xl"})," pour le header desktop"]})]]})]}),e.jsxs(t,{id:"use-is-mobile",title:"13. Détection mobile — useViewport / useIsMobile",children:[e.jsxs(i,{children:["Fichier : ",e.jsx(s,{children:"client/src/hooks/use-mobile.tsx"}),"."]}),e.jsx(c,{language:"ts",children:'export function getViewport(): Viewport {\n  if (window.matchMedia(`(max-width: ${PHONE_MAX}px)`).matches) return "phone";\n  if (window.matchMedia(`(max-width: ${TABLET_MAX}px)`).matches) return "tablet";\n  return "desktop";\n}\n// useIsMobile() = useViewport() !== "desktop"\n// useIsPhoneViewport() = useViewport() === "phone"'}),e.jsxs(l,{children:[e.jsxs(r,{children:["Lecture ",e.jsx(n,{children:"synchrone"})," dès le premier rendu (",e.jsx(s,{children:"useState<Viewport>(getViewport)"}),") : contrairement à l'ancienne version, il n'y a pas de flash desktop avant qu'un effet ne détecte l'écran réel."]}),e.jsxs(r,{children:["Écoute ",e.jsx(s,{children:"change"})," sur les deux ",e.jsx(s,{children:"MediaQueryList"})," ","(",e.jsx(s,{children:"PHONE_MAX"}),", ",e.jsx(s,{children:"TABLET_MAX"}),") et nettoie au démontage."]}),e.jsxs(r,{children:[e.jsx(s,{children:"useIsMobile()"}),' reste la vérification "pas desktop" utilisée dans ',e.jsx(s,{children:"app-layout.tsx"}),","," ",e.jsx(s,{children:"pages/home.tsx"}),", ",e.jsx(s,{children:"pwa-install-banner.tsx"}),","," ",e.jsx(s,{children:"configuration.tsx"}),", etc."," ",e.jsx(s,{children:"useIsPhoneViewport()"})," est le repli plus étroit (",e.jsx(s,{children:'"phone"'})," seulement) pour les quelques écrans dont le layout mobile ne tient pas à la largeur d'une tablette (640-1023 px) — par exemple la grille de calendrier de maintenance, qui bascule en agenda uniquement sur téléphone."]})]})]}),e.jsxs(t,{id:"my-day-redirect",title:"Arrivée des techniciens sur Ma journée (CHARGEOPS-121)",children:[e.jsxs(i,{children:["La règle vit dans ",e.jsx(s,{children:"client/src/lib/my-day-preference.ts"})," ","(",e.jsx(s,{children:"landsOnMyDay(role)"})," : ",e.jsx(s,{children:'role === "technician"'})," ","et pas d'opt-out sur l'appareil) et s'applique à deux endroits, sur tous les appareils — plus de condition de largeur d'écran."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Connexion"})," — ",e.jsx(s,{children:"auth-page.tsx"})," envoie vers ",e.jsx(s,{children:"/my-day"})," au lieu de ",e.jsx(s,{children:"/"}),". Un scan en attente, le portail d'un opérateur de site et le retour vers « Synchronisation en attente » passent avant."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Ouverture de l'application sur /"})," —"," ",e.jsx(s,{children:"client/src/components/home-or-my-day.tsx"}),", monté par ",e.jsx(s,{children:"App.tsx"})," sur ",e.jsx(s,{children:"/"}),", rend"," ",e.jsx(s,{children:'<Redirect to="/my-day" replace />'})," (avec"," ",e.jsx(s,{children:"replace"})," — sans quoi le bouton Précédent du navigateur boucle sur la redirection) seulement si"," ",e.jsx(s,{children:"shouldRedirectColdOpenToMyDay(role)"})," est vrai : la page a démarré sur ",e.jsx(s,{children:"/"})," (",e.jsx(s,{children:"BOOT_PATH"}),", capturé au chargement du module) et l'arrivée n'a pas encore été décidée. Le premier rendu de ",e.jsx(s,{children:"/"})," et la connexion appellent ",e.jsx(s,{children:"markMyDayLandingDone()"})," : la pastille Tableau de bord et le logo mènent ensuite à ",e.jsx(s,{children:"Home"}),"."]}),e.jsxs(r,{children:["L'interrupteur « Ouvrir l'accueil à la place », affiché dans l'en-tête de"," ",e.jsx(s,{children:"/my-day"}),", écrit une clé"," ",e.jsx(s,{children:"chargeops:my-day:prefer-home"})," dans le"," ",e.jsx(s,{children:"localStorage"})," de l'appareil (",e.jsx(s,{children:"client/src/lib/my-day-preference.ts"}),") ; sa présence court-circuite la redirection. Lecture et écriture sont chacune enveloppées d'un ",e.jsx(s,{children:"try
27/catch"})," — un"," ",e.jsx(s,{children:"localStorage"})," indisponible (navigation privée, quota, politique du navigateur) dégrade silencieusement vers « pas d'opt-out » plutôt que de faire échouer le rendu."]}),e.jsx(r,{children:"Aucun autre rôle n'est jamais redirigé."})]})]}),e.jsxs(t,{id:"my-day-completed",title:"Ma journée — carte « Terminés aujourd'hui »",children:[e.jsxs(i,{children:["Requête : ",e.jsx(s,{children:"convex/myDay/queries.ts::getMyCompletedReports"})," ","(",e.jsx(s,{children:"tenantQuery"}),"), lue par ",e.jsx(s,{children:"CompletedSection"})," ","dans ",e.jsx(s,{children:"client/src/pages/my-day.tsx"})," sous son propre"," ",e.jsx(s,{children:"ErrorBoundary"}),", entre les brouillons et les cas."]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Renvoie"," ",e.jsx(s,{children:"{ serviceReports, maintenanceReports, cases }"})," ; chaque type de rapport vaut ",e.jsx(s,{children:"null"})," sans"," ",e.jsx(s,{children:"createServiceReport"})," /"," ",e.jsx(s,{children:"createMaintenanceReport"}),", et ",e.jsx(s,{children:"cases"})," sans"," ",e.jsx(s,{children:"viewTickets"})," (module désactivé compris), et la réponse entière vaut ",e.jsx(s,{children:"null"})," pour les rôles finance et site-operator. La carte n'est masquée que si les trois parties sont ",e.jsx(s,{children:"null"}),"."]}),e.jsxs(r,{children:["Deux index dédiés :"," ",e.jsx(s,{children:"serviceReports.by_tenant_technician_status_serviceDate"})," ","et ",e.jsx(s,{children:"maintenanceReports.by_tenant_author_status_visitDate"})," ","(",e.jsx(s,{children:"tenantId"}),", auteur, ",e.jsx(s,{children:"status"}),", date de travail). La lecture garde ",e.jsx(s,{children:'status === "final"'})," sur la fenêtre [aujourd'hui UTC − 1, aujourd'hui + 2[ — borne haute exclusive, car ",e.jsx(s,{children:"visitDate"})," peut porter un suffixe"," ",e.jsx(s,{children:"T…"})," — plafonnée à 50 lignes par type avec"," ",e.jsx(s,{children:"truncated"}),"."]}),e.jsxs(r,{children:["Le serveur ne connaît que sa date UTC : c'est le client qui ne garde que les lignes dont la ",e.jsx(s,{children:"date"})," est sa date locale, même raisonnement que ",e.jsx(s,{children:"getMyVisits"}),". « Terminé aujourd'hui » désigne la date de travail (",e.jsx(s,{children:"serviceDate"})," / ",e.jsx(s,{children:"visitDate"}),"), pas l'instant de la finalisation — ",e.jsx(s,{children:"serviceReports"})," n'en garde aucun horodatage."]}),e.jsxs(r,{children:["Rapports supprimés (",e.jsx(s,{children:"deletedAt"}),") exclus, sites hors de la portée d'organisation filtrés, comme pour"," ",e.jsx(s,{children:"getMyDrafts"}),". Tri par heure de début ; une ligne ouvre"," ",e.jsx(s,{children:"/service-reports/:id"})," ou"," ",e.jsx(s,{children:"/maintenance-reports/:id"}),". Le total alimente le « · N rapports terminés » du résumé de l'en-tête."]}),e.jsxs(r,{children:[e.jsx(s,{children:"cases"})," (CHARGEOPS-403) : les cas que l'appelant a"," ",e.jsx(n,{children:"créés"})," (",e.jsx(s,{children:"createdBy"}),", pas l'assigné — c'est ",e.jsx(s,{children:"getMyCases"}),"), lus par l'index"," ",e.jsx(s,{children:"supportTickets.by_tenant_createdBy_createdAt"})," ","(",e.jsx(s,{children:"tenantId"}),", ",e.jsx(s,{children:"createdBy"}),","," ",e.jsx(s,{children:"createdAt"}),") sur [minuit UTC d'aujourd'hui − 1, minuit UTC d'aujourd'hui + 2[, du plus récent au plus ancien, plafonnés à 50 avec ",e.jsx(s,{children:"truncated"}),". Cette fenêtre contient tous les minuits locaux (UTC−12 à UTC+14) : le client ne garde que les cas dont ",e.jsx(s,{children:"createdAt"})," tombe dans sa date locale. Cas supprimés (",e.jsx(s,{children:"deletedAt"}),") et fusionnés (",e.jsx(s,{children:"mergedIntoTicketId"}),") exclus, cas fermés conservés, sites hors portée filtrés. Chaque ligne renvoie numéro, titre, statut, site et ",e.jsx(s,{children:"createdAt"}),", ouvre"," ",e.jsx(s,{children:"/support/:id"})," et se place parmi les rapports à son heure locale de création (sur 24 h dans les deux langues) ; leur nombre alimente « · N cas créés » dans le résumé, séparément des rapports."]})]})]}),e.jsxs(t,{id:"header-mobile",title:"14. Coquille téléphone",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/shell/"})," (S1.3, CHARGEOPS-386) —"," ",e.jsx(s,{children:"phone-shell.tsx"}),", ",e.jsx(s,{children:"top-bar.tsx"}),","," ",e.jsx(s,{children:"tab-bar.tsx"}),", ",e.jsx(s,{children:"create-sheet.tsx"}),","," ",e.jsx(s,{children:"nav-menu.tsx"}),", ",e.jsx(s,{children:"pin-tab-dialog.tsx"}),","," ",e.jsx(s,{children:"use-keyboard-open.ts"}),","," ",e.jsx(s,{children:"use-shell-nav.ts"}),", plus"," ",e.jsx(s,{children:"client/src/hooks/use-pinned-tabs.ts"})," et deux fonctions pures dans ",e.jsx(s,{children:"client/src/lib/nav/"})," :"," ",e.jsx(s,{children:"tabs.ts"})," et ",e.jsx(s,{children:"back-target.ts"}),". Elle remplace l'ancien ",e.jsx(s,{children:"mobile-header.tsx"}),", supprimé."]}
27),e.jsxs(i,{children:[e.jsx(n,{children:"Quelle coquille s'affiche."})," ",e.jsx(s,{children:"AppLayout"})," et l'accueil (",e.jsx(s,{children:"/"}),","," ",e.jsx(s,{children:"/sites/:siteId"}),") rendent"," ",e.jsx(s,{children:'<PhoneChrome surface="app" />'})," (barre du haut, barre d'onglets, menu ☰) dès que"," ",e.jsx(s,{children:'useViewport() === "phone"'})," (",e.jsx(s,{children:"< 640 px"}),") ; la tablette (640–1023 px) reçoit ",e.jsx(s,{children:"TabletShell"})," et son rail de navigation à la place — voir 14b ci-dessous. C'est un"," ",e.jsx(n,{children:"voisin"})," de la page, pas une enveloppe : les deux mises en page gardent un seul arbre et n'échangent que le chrome (et le rembourrage ",e.jsx(s,{children:"PHONE_CONTENT_CLASS"})," du conteneur), si bien que franchir 1024 px — tourner un iPad — ne remonte jamais la page et ne perd pas un formulaire à moitié rempli. À partir de"," ",e.jsx(s,{children:"1024 px"}),", le header desktop ne change pas. L'accueil ne rend son propre header que sur desktop ; sur téléphone, son sélecteur de site passe en haut de la page dans une"," ",e.jsx(s,{children:"co-card"}),", et l'accueil monte sa propre palette de commandes. Le portail opérateur reçoit la même coquille — voir 14d."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Barre du haut"})," (",e.jsx(s,{children:"top-bar.tsx"}),") — marine (",e.jsx(s,{children:"bg-[var(--brand-header-bg)]"}),"), fixe, hauteur"," ",e.jsx(s,{children:"--chrome-h-mobile"})," (56 px + la zone sécurisée du haut). À gauche, ☰ sur une page de premier niveau ou ← sur une page intérieure :"," ",e.jsx(s,{children:"backTarget(location, language, hasInAppHistory)"})," ","renvoie ",e.jsx(s,{children:"null"})," quand ",e.jsx(s,{children:"buildCrumbs"})," ne trouve aucun ancêtre (et pour ",e.jsx(s,{children:"/sites/:siteId"}),", l'accueil avec un site choisi, via ",e.jsx(s,{children:"TOP_LEVEL_PATTERNS"}),"), ",e.jsx(s,{children:'{ kind: "history" }'})," quand l'entrée d'en dessous appartient à l'application — chaque entrée d'historique porte sa profondeur dans"," ",e.jsx(s,{children:"history.state"})," (0 pour l'entrée d'ouverture, +1 à chaque push, conservée au replace, relue au popstate, donc gardée au rechargement), et ",e.jsx(s,{children:"useInAppHistory()"})," vaut"," ",e.jsx(s,{children:"profondeur > 0"})," — sinon le href du dernier fil d'Ariane — un lien profond ne sort donc jamais de l'application. Au centre, le titre de ",e.jsx(s,{children:"useSetPageMeta"}),", qui est le"," ",e.jsx(n,{children:"seul"})," ",e.jsx(s,{children:"h1"})," de la page (tronqué ; l'attribut ",e.jsx(s,{children:"title"})," le montre en entier). À droite, ",e.jsx(s,{children:'<ConnectivityPill surface="navy" />'})," et un bouton de recherche qui appelle ",e.jsx(s,{children:"openCommandPalette()"})," ","(un événement window que la palette écoute, en plus de ⌘K)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Barre d'onglets"})," (",e.jsx(s,{children:"tab-bar.tsx"}),") — fixe en bas, hauteur ",e.jsx(s,{children:"--tabbar-h-visible"})," (64 px + la zone sécurisée du bas). Trois onglets de rôle issus de"," ",e.jsx(s,{children:"resolveTabs(tabRoleFor(ctx, surface), nav.all, topLevelPins(pinned, language))"})," ","où ",e.jsx(s,{children:"pinned"})," vient de"," ",e.jsx(s,{children:"usePinnedTabs(surface).pinned"})," (épinglage, section 14c), chacun sous son libellé court ",e.jsx(s,{children:"tabLabel"})," (",e.jsx(s,{children:"nav.tab.*"}),"). L'",e.jsx(s,{children:"aria-label"})," commence par ce libellé visible, ajoute le nom complet s'il diffère, puis le badge —"," ",e.jsx(s,{children:"tabAccessibleName(label, fullLabel, badge)"})," dans"," ",e.jsx(s,{children:"shell/tab-accessible-name.ts"}),", partagé avec le rail (« Réception, Boîte de réception (5) », « Entretien, Rapports de maintenance » ; WCAG 2.5.3 Étiquette dans le nom, pour que la commande vocale trouve l'onglet par ce qu'il affiche), puis la boîte de réception (",e.jsx(s,{children:"/inbox"}),","," ",e.jsx(s,{children:"/portal/inbox"})," dans le portail) avec un badge"," ",e.jsx(s,{children:"useInboxUnread().total"}),", affiché ",e.jsx(s,{children:"9+"})," ","au-delà de 9. Le ",e.jsx(n,{children:"+"})," surélevé se place entre les onglets 2 et 3 et n'est pas rendu quand l'utilisateur n'a aucune action de création. L'onglet actif est en"," ",e.jsx(s,{children:"text-primary dark:text-brand-200"})," avec"," ",e.jsx(s,{children:'aria-current="page"'}),". La coquille démonte la barre sur les pages intérieures et tant que le clavier virtuel est ouvert (",e.jsx(s,{children:"useKeyboardOpen()"}
27)," : hauteur du"," ",e.jsx(s,{children:"visualViewport"})," sous 75 % de la fenêtre). Montée, elle pose ",e.jsx(s,{children:"--tabbar-h"})," sur ",e.jsx(s,{children:"<html>"}),", contre lequel le conteneur de contenu de la mise en page (un simple ",e.jsx(s,{children:"div"}),", pas un ",e.jsx(s,{children:"main"})," : plusieurs pages ont le leur) se rembourre via ",e.jsx(s,{children:"PHONE_CONTENT_CLASS"})," ; les surcouches globales du bas (",e.jsx(s,{children:"aboveBottomBar()"})," : bannières stockage plein et installation, bouton de l'assistant ChargeOps AI) et les boutons d'action de Toutes les bornes se placent au-dessus ; la variable revient à ",e.jsx(s,{children:"0px"})," au démontage."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Feuille de création"})," (",e.jsx(s,{children:"create-sheet.tsx"}),") — un ",e.jsx(s,{children:"Sheet"})," du bas avec une poignée et une grille de deux colonnes de liens"," ",e.jsx(s,{children:"co-tile"})," construite à partir des"," ",e.jsx(s,{children:"createActions"})," du registre (déjà filtrées par permission et par module). Le + pivote de 45° en ✕ tant qu'elle est ouverte."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Menu ☰"})," (",e.jsx(s,{children:"nav-menu.tsx"}),") — un"," ",e.jsx(s,{children:"Sheet"})," à gauche (",e.jsx(s,{children:"w-[85vw] max-w-sm"}),") : la carte utilisateur (initiales, nom, ",e.jsx(s,{children:"roleLabelFor()"})," ","partagé avec ",e.jsx(s,{children:"profile-menu.tsx"}),", locataire), une recherche dans les ",e.jsx(s,{children:"menuSections"})," qui ignore accents et casse (",e.jsx(s,{children:"foldForSearch"}),") et retire les sections vides, toutes les sections (Accueil et Ma journée ouvrent Opérations via"," ",e.jsx(s,{children:"menuLead"}),", menu ☰ seulement ; titres en ",e.jsx(s,{children:"h3"})," : le titre de la feuille est le ",e.jsx(s,{children:"h2"})," du dialogue) avec leur pastille ADMIN / SUPER, et un pied avec FR / EN, le bouton de thème, le changement de mot de passe et la déconnexion (via"," ",e.jsx(s,{children:"useSignOutGuard"}),", plus « Se déconnecter de Cléo partout » quand l'IdP est configuré). Chaque entrée de premier niveau qui n'est pas un onglet fixe (l'onglet 1 ou la boîte de réception) porte un bouton ⋯ de 44 × 44 px (",e.jsx(s,{children:"button-pin-<href>"}),") et répond aussi à un appui long de 500 ms sur la ligne ; les deux ouvrent"," ",e.jsx(s,{children:"PinTabDialog"})," (section 14c). Une page intérieure — une entrée dont la page a un ancêtre dans le fil d'Ariane, comme Facturation des correctifs sous Rapports de service ou Échéances sous le Centre de maintenance (",e.jsx(s,{children:"isTopLevelPage(href, language)"})," faux, le même test que ",e.jsx(s,{children:"backTarget"}),") — n'a ni l'un ni l'autre : épinglée, elle cacherait la barre d'onglets dès qu'on touche son propre onglet."]})]}),e.jsx(u,{headers:["Rôle","Onglet 1","Onglet 2 (→ repli)","Onglet 3 (→ repli)"],rows:[["technician",e.jsx(s,{children:"/my-day → /"}),e.jsx(s,{children:"/all-chargers → /service-reports"}),e.jsx(s,{children:"/support → /maintenance-reports → /rma"})],["admin, user",e.jsx(s,{children:"/"}),e.jsx(s,{children:"/support → /rma → /service-reports"}),e.jsx(s,{children:"/all-chargers → /clients"})],["finance",e.jsx(s,{children:"/"}),e.jsx(s,{children:"/service-reports → /license-management → /rma"}),e.jsx(s,{children:"/clients → /all-chargers"})],["site-operator (portail)",e.jsx(s,{children:"/portal"}),e.jsx(s,{children:"/portal/cases → /portal/maintenance-reports"}),e.jsx(s,{children:"/portal/service-reports → /portal/maintenance-reports"})]]}),e.jsxs(i,{children:["Le premier candidat visible par l'utilisateur (permission, module, route) l'emporte ; un emplacement sans candidat est retiré et la barre se réespace. Le personnel plateforme reçoit les onglets de son rôle de locataire. Tests :"," ",e.jsx(s,{children:"lib/nav/__tests__/tabs.test.ts"}),","," ",e.jsx(s,{children:"lib/nav/__tests__/back-target.test.ts"}),","," ",e.jsx(s,{children:"components/shell/__tests__/phone-shell.test.tsx"}),","," ",e.jsx(s,{children:"components/shell/__tests__/pin-tabs.test.tsx"})," ;"," ",e.jsx(s,{children:"chrome-and-list-stats.test.ts"})," fixe la hauteur de la barre d'onglets sur le jeton."]})]}),e.jsxs(t,{id:"inbox-mobile",title:"14a. Boîte de réception unifiée (Inbox)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/pages/inbox.tsx"})," (S1.4, CHARGEOPS-387) — montée sur ",e.jsx(s,{children:"/inbox"})," et ",e.jsx(s,{children:"/portal/inbox"}),", réservée aux téléphones/tablettes (",e.jsx(s,{children:"useIsMobile()"})," ","faux → ",e.jsx(s,{children:"<Redirect>"})," vers ",e.jsx(s,{children:"/messages"})," ","(ou ",e.jsx(s,{children:"/notifications"})," si la messagerie est indisponible, ou ",e.jsx(s,{children:"/portal/notifications"})," côté portail) plutôt que de monter la coquille segmentée)."]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Réunit deux surfaces jusqu'ici séparées : la liste de la cloche et les conversations de ",e.jsx(s,{children:"/messages"}),". Sur téléphone, un ",e.jsx(s,{children:"co-card"})," porte un sélecteur à deux options (",e.jsx(s,{children:'role="tablist"'})," / ",e.jsx(s,{children:'role="tab"'})," /"," ",e.jsx(s,{children:"aria-selected"}),") « Notifications | Messages » — le segment Messages n'existe même pas quand"," ",e.jsx(s,{children:'!isSiteOperator && can.viewMessages && isModuleEnabled("messaging")'})," ","est faux (même garde que ",e.jsx(s,{children:"MessagesHeaderIcon"}),")."]}),e.jsxs(r,{children:[e.jsx(s,{children:"InboxList"})," (",e.jsx(s,{children:"client/src/components/notifications/inbox-list.tsx"}),") est le corps du popover de la cloche, extrait verbatim :"," ",e.jsx(s,{children:"<NotificationBell>"})," ne garde que le trigger, le badge et le ",e.jsx(s,{children:"<Popover>"}),", et rend"," ",e.jsx(s,{children:"<InboxList onNavigate={() => setOpen(false)} />"}),". ",e.jsx(s,{children:'variant="popover"'})," (défaut) reproduit exactement l'ancien rendu (boîte de défilement ",e.jsx(s,{children:"max-h-96"}),", lignes ",e.jsx(s,{children:"py-2.5"}),", titre ",e.jsx(s,{children:"text-sm"}),") ;"," ",e.jsx(s,{children:'variant="page"'})," retire le plafond de hauteur, agrandit les lignes (",e.jsx(s,{children:"py-3.5"}),", titre ",e.jsx(s,{children:"text-base"}),") et colle l'en-tête (",e.jsx(s,{children:"sticky top-0"}),"). Les fonctions pures ",e.jsx(s,{children:"itemTitle"}),"/",e.jsx(s,{children:"itemBody"})," vivent maintenant ici ; ",e.jsx(s,{children:"notification-bell.tsx"}
27)," les ré-exporte pour que son test existant reste inchangé. Barre d'outils (CHARGEOPS-627) : sur téléphone (",e.jsx(s,{children:"useIsPhoneViewport()"}),"), « Tout marquer lu » et « Tout supprimer » passent dans un menu ⋯ (",e.jsx(s,{children:"nav.moreActions"}),", Supprimer en"," ",e.jsx(s,{children:"co-text-negative"}),") — à côté du titre et de l'engrenage, elles tenaient sur trois lignes sous le"," ",e.jsx(s,{children:"sticky top-0"})," (~125 px de chrome collant à 375 px). Tablette et desktop gardent la rangée de boutons. « Tout supprimer » ouvre partout un ",e.jsx(s,{children:"AlertDialog"})," (",e.jsx(s,{children:"notificationsCenter.deleteAllTitle"})," /"," ",e.jsx(s,{children:"deleteAllConfirm"}),") : ",e.jsx(s,{children:"removeAll"})," ne part qu'à la confirmation, Annuler ne supprime rien. Tests :"," ",e.jsx(s,{children:"notifications/__tests__/inbox-list-toolbar.test.tsx"}),"."]}),e.jsxs(r,{children:[e.jsx(s,{children:"useInboxUnread()"})," (même fichier) — les compteurs de non-lus des deux segments :"," ",e.jsx(s,{children:"notifications = getUnreadCount().count"}),","," ",e.jsx(s,{children:"messages = getUnreadSummary().conversations"})," ","interrogé seulement sous la même garde que ci-dessus (sinon"," ",e.jsx(s,{children:'"skip"'})," → 0). Détermine le segment initial : Messages si des messages non lus existent et aucune notification, sinon Notifications — recalculé à chaque rendu (pas figé au montage) tant que l'utilisateur n'a pas cliqué un onglet."]}),e.jsxs(r,{children:["Panneau Messages : ",e.jsx(s,{children:"ConversationList"})," (",e.jsx(s,{children:"components/messages/conversation-list.tsx"}),"), les mêmes props que ",e.jsx(s,{children:"pages/messages.tsx"})," lui passe ; choisir une conversation navigue vers"," ",e.jsx(s,{children:"/messages/:id"}),"."]})]})]}),e.jsxs(t,{id:"tablet-rail-mobile",title:"14b. Rail de navigation tablette",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/shell/"})," (S1.5, CHARGEOPS-388) —"," ",e.jsx(s,{children:"tablet-shell.tsx"}),", ",e.jsx(s,{children:"nav-rail.tsx"})," et un hook extrait de la barre d'onglets téléphone,"," ",e.jsx(s,{children:"use-shell-tabs.ts"})," (",e.jsx(s,{children:"useShellTabs(surface)"})," ","→ ",e.jsx(s,{children:"{ tabs, inbox, createActions }"}),"), consommé à la fois par ",e.jsx(s,{children:"TabBar"})," et ",e.jsx(s,{children:"NavRail"})," pour que les deux chromes ne résolvent jamais la liste d'onglets de deux façons différentes."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Quelle coquille s'affiche."})," ",e.jsx(s,{children:"AppLayout"})," et l'accueil rendent"," ",e.jsx(s,{children:'<TabletShell surface="app" />'})," dès que"," ",e.jsx(s,{children:'useViewport() === "tablet"'})," (640–1023 px) au lieu de ",e.jsx(s,{children:"PhoneChrome"})," ; sous 640 px le téléphone garde la barre d'onglets, et à partir de ",e.jsx(s,{children:"1024 px"})," le header desktop ne change pas. Comme ",e.jsx(s,{children:"PhoneChrome"}),","," ",e.jsx(s,{children:"TabletShell"})," ne rend que du chrome — un"," ",e.jsx(n,{children:"voisin"})," de la page, jamais une enveloppe autour d'elle — si bien que les trois mises en page gardent un seul arbre et n'échangent que le chrome (et le rembourrage"," ",e.jsx(s,{children:"TABLET_CONTENT_CLASS"})," du conteneur) ; franchir un palier ne remonte jamais la page et ne perd pas un formulaire à moitié rempli. Ce rembourrage vaut"," ",e.jsx(s,{children:"pl-[calc(72px+var(--safe-left))] pr-[var(--safe-right)]"})," ","— la largeur du rail écrite avec le même calc plutôt que"," ",e.jsx(s,{children:"var(--rail-w)"}),", qui n'est posé qu'une fois l'effet de mise en page du rail exécuté (aucun flash sous le rail) ; la barre du haut porte aussi"," ",e.jsx(s,{children:"pr-[var(--safe-right)]"})," sur téléphone et tablette. Il n'y a pas de barre d'onglets sur tablette, donc ",e.jsx(s,{children:"--tabbar-h"})," reste à ",e.jsx(s,{children:"0px"}),"."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Rail de navigation"}
27)," (",e.jsx(s,{children:"nav-rail.tsx"}),") — fixe à gauche, ",e.jsx(s,{children:"w-[calc(72px+var(--safe-left))]"})," ","avec ",e.jsx(s,{children:"pl-[var(--safe-left)]"})," (border-box : la colonne d'icônes garde 72 px et dégage l'encoche d'un iPhone à l'horizontale), pleine hauteur, marine (",e.jsx(s,{children:"bg-[var(--brand-header-bg)]"}),"). Les mêmes trois onglets de rôle et le même emplacement Inbox que la barre d'onglets téléphone (",e.jsx(s,{children:"useShellTabs"}),"), chacun un lien de 56 px de haut (",e.jsx(s,{children:"h-14"}),") avec l'icône et un libellé ",e.jsx(s,{children:"text-2xs"})," (10 px) dessous ; l'actif est"," ",e.jsx(s,{children:"bg-white/15 text-white"})," avec"," ",e.jsx(s,{children:'aria-current="page"'}),", les autres"," ",e.jsx(s,{children:"text-white/70"}),". Sous eux, le même"," ",e.jsx(n,{children:"+"})," surélevé (ouvrant ",e.jsx(s,{children:"CreateSheet"}),", ici centré comme une carte flottante via un"," ",e.jsx(s,{children:"className"})," supplémentaire plutôt que la feuille pleine largeur du téléphone) quand l'utilisateur a une action de création, dans une zone"," ",e.jsx(s,{children:"flex-1 min-h-0 overflow-y-auto"})," qui se compacte sous"," ",e.jsx(s,{children:"max-height:480px"})," (un téléphone en paysage reçoit toujours le rail à ",e.jsx(s,{children:"≥ 640px"})," de large) ; son propre ☰ est un frère ",e.jsx(s,{children:"shrink-0"})," placé APRÈS cette zone plutôt qu'un enfant ",e.jsx(s,{children:"mt-auto"})," à l'intérieur, ouvrant le même ",e.jsx(s,{children:"NavMenu"})," que la coquille téléphone — il ne peut donc jamais sortir de l'écran par défilement. Tant qu'il est monté, le rail publie aussi son empreinte (",e.jsx(s,{children:"calc(72px + var(--safe-left))"}),") sous"," ",e.jsx(s,{children:"--rail-w"})," sur ",e.jsx(s,{children:"<html>"})," (0px le reste du temps, même schéma que ",e.jsx(s,{children:"--tabbar-h"}),") : une barre fixe pleine largeur ailleurs (la barre de sauvegarde de l'assistant de maintenance, la barre d'actions groupées des billets, la bannière d'installation PWA, la pastille repliée de la bannière stockage plein) la lit pour commencer ou se centrer après le rail plutôt que de peindre sur ses icônes et son ☰. Sur téléphone, la barre d'actions groupées des billets monte aussi de la hauteur de la barre d'onglets et de la place que publie le bouton de l'assistant (",e.jsx(s,{children:"bottom-[calc(var(--tabbar-h)+1.5rem+var(--co-assistant-launcher-h,0px))]"}),") : /support garde la barre d'onglets montée pendant une sélection."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Barre du haut décalée."})," ",e.jsx(s,{children:"TopBar"})," a gagné deux props : ",e.jsx(s,{children:"inset"})," échange son"," ",e.jsx(s,{children:"inset-x-0"})," pour ",e.jsx(s,{children:"left-[var(--rail-w)] right-0"})," ","— l'empreinte réelle du rail, pas un ",e.jsx(s,{children:"72px"})," ","littéral, pour ne jamais dériver de la largeur du rail lui-même — et ",e.jsxs(s,{children:["showMenuButton=","{false}"]})," cache son propre ☰ sur une page de premier niveau (celui du rail porte ce rôle sur tablette) — le ← sur une page intérieure ne change pas, puisque le rail lui-même ne se démonte jamais."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pied de page pleine largeur à côté du rail"})," —"," ",e.jsx(s,{children:"<Footer fullBleed />"})," sort de sa page avec"," ",e.jsx(s,{children:"w-screen left-1/2 -translate-x-1/2"}),", centré sur son parent. Sur tablette ce parent est décalé à droite du rail : une bande de 100vw débordait d'un demi-rail (36 px) à droite et était rognée. Il y vaut"," ",e.jsx(s,{children:"sm:max-lg:w-[calc(100vw-var(--rail-w)-var(--safe-right))]"})," ","— la largeur de la zone de contenu (la page est décalée du rail à gauche et de ",e.jsx(s,{children:"--safe-right"})," à droite), qui, centrée sur le même parent, va exactement du rail au bord droit, ou à l'encoche en paysage (CHARGEOPS-607)."," ",e.jsx(s,{children:"--rail-w"})," plutôt que le calc littéral, puisqu'il vaut 0px partout où aucun rail n'est monté. Téléphone et desktop gardent ",e.jsx(s,{children:"w-screen"}),"."]})]}),e.jsxs(i,{children:["Tests :"," ",e.jsx(s,{children:"components/shell/__tests__/tablet-shell.test.tsx"})," ","(nombre d'onglets du rail, pas de barre d'onglets en bas, le ← de la barre du haut sur une page intérieure, un seul"," ",e.jsx(s,{children:"h1"}),") ; la propriété d'arbre unique est étendue dans ",e.jsx(s,{children:"phone-shell.test.tsx"})," (téléphone → desktop → tablette garde un formulaire à moitié rempli)."]})]}),e.jsxs(t,{id:"pin-tabs-mobile",title:"14c. Épingler une page à la barre d'onglets",children:[e.jsxs(i,{children:["Spec §5.7, CHARGEOPS-390. Backend :"," ",e.jsx(s,{children:"convex/nav/preferences.ts"})," (S1.7a) — table"," ",e.jsx(s,{children:"userNavPreferences"}
27)," (une ligne par utilisateur,"," ",e.jsx(s,{children:"PER_USER_TABLES"}),"), ",e.jsx(s,{children:"getMine"}),","," ",e.jsxs(s,{children:["setPinnedTabs(","{ pinnedTabs }",")"]})," (au plus 2 entrées, aucun doublon, chacune commençant par"," ",e.jsx(s,{children:"/"}),") et ",e.jsx(s,{children:"resetMine"}),". Le serveur ne vérifie pas l'accès à la page : une épingle obsolète ou non autorisée est filtrée côté client (ci-dessous) et ne peut donc jamais afficher une page interdite — les routes restent protégées par"," ",e.jsx(s,{children:"ProtectedRoute"})," comme aujourd'hui. Il ne sait pas non plus quelles pages sont intérieures : ",e.jsx(s,{children:"useShellTabs"})," ","et ",e.jsx(s,{children:"usePinnedTabs"})," passent tous deux le tableau stocké par ",e.jsx(s,{children:"topLevelPins(pinned, language)"})," (",e.jsx(s,{children:"lib/nav/tabs.ts"}),"), qui vide sur place une épingle de page intérieure — son emplacement retombe sur le défaut du rôle et l'autre épingle garde le sien — pour qu'une épingle enregistrée avant que le menu ☰ cesse de proposer les pages intérieures ne cache jamais la barre d'onglets."]}),e.jsxs(i,{children:["Client : ",e.jsx(s,{children:"client/src/hooks/use-pinned-tabs.ts"})," —"," ",e.jsx(s,{children:"usePinnedTabs(surface)"})," combine"," ",e.jsx(s,{children:"useQuery(api.nav.preferences.getMine)"})," et deux"," ",e.jsx(s,{children:"useMutation(...).withOptimisticUpdate(...)"})," (",e.jsx(s,{children:"setPinnedTabs"}),", ",e.jsx(s,{children:"resetMine"}),", les deux patchant directement le cache de ",e.jsx(s,{children:"getMine"}),", donc la barre d'onglets change avant le retour du serveur). Il expose ",e.jsx(s,{children:"pinned"})," (le tableau du serveur),"," ",e.jsx(s,{children:"currentTabs"})," (ce que les onglets 2 et 3 affichent réellement en ce moment, via la nouvelle"," ",e.jsx(s,{children:"resolveTabSlots"})," de ",e.jsx(s,{children:"lib/nav/tabs.ts"})," — la même règle que ",e.jsx(s,{children:"resolveTabs"}),", mais qui garde chaque emplacement adressable par son nom même quand il est retiré) et"," ",e.jsx(s,{children:"pin(slot, href)"})," : ",e.jsx(s,{children:"0"})," → onglet 2,"," ",e.jsx(s,{children:"1"})," → onglet 3, résolu contre ",e.jsx(s,{children:"currentTabs"})," ","(jamais le tableau brut, potentiellement obsolète, du serveur) : si ",e.jsx(s,{children:"href"})," occupe déjà l'emplacement visé, ne rien envoyer (silencieux, pas de toast) ; s'il occupe l'",e.jsx(n,{children:"autre"})," emplacement, échanger les deux au lieu de laisser un dé-doublonnage naïf faire disparaître l'un des deux ; sinon, remplacer l'emplacement visé et garder l'autre tel quel."]}),e.jsxs(i,{children:[e.jsx(s,{children:"PinTabDialog"})," (",e.jsx(s,{children:"client/src/components/shell/pin-tab-dialog.tsx"}),") — un"," ",e.jsx(s,{children:"Sheet"})," du bas ouvert par le bouton ⋯ / l'appui long du menu ☰ (section 14) : deux boutons « Remplacer"," ","{libellé court de l'onglet 2 / 3 actuel}"," » et un bouton texte « Revenir aux onglets par défaut ». Un succès affiche un toast (",e.jsx(s,{children:"nav.pin.done"}),") et referme la feuille ; une erreur affiche ",e.jsx(s,{children:"common.error"})," +"," ",e.jsx(s,{children:"getErrorMessage(e)"})," en ",e.jsx(s,{children:"destructive"}),"."]}),e.jsxs(i,{children:["Tests : ",e.jsx(s,{children:"components/shell/__tests__/pin-tabs.test.tsx"})," ","(le ⋯ ouvre la feuille, « Remplacer Support » appelle"," ",e.jsx(s,{children:"setPinnedTabs"})," avec"," ",e.jsx(s,{children:'["/rma", "/all-chargers"]'}),", la barre reflète la nouvelle épingle une fois la requête simulée mise à jour,"," ",e.jsx(s,{children:"resetMine"})," côté « Revenir aux onglets par défaut », les onglets fixes et les pages intérieures n'ont ni ⋯ ni appui long, et une épingle de page intérieure déjà enregistrée retombe sur le défaut du rôle, dans la barre comme dans les libellés « Remplacer … » de la feuille)."]})]}),e.jsxs(t,{id:"portal-shell-mobile",title:"14d. Portail opérateur sur téléphone et tablette",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/portal/portal-layout.tsx"})," (S1.6, CHARGEOPS-389) — sous 1024 px, ",e.jsx(s,{children:"PortalLayout"})," rend la même coquille que ",e.jsx(s,{children:"AppLayout"})," :"," ",e.jsx(s,{children:'<PhoneChrome surface="portal" />'})," sous 640 px,"," ",e.jsx(s,{children:'<TabletShell surface="portal" />'})," de 640 à 1023 px. Même règle d'arbre unique : le chrome et l'en-tête desktop sont des alternatives dans des emplacements fixes autour du même ",e.jsx(s,{children:"<main>"}),", et seules les classes de la racine changent (",e.jsx(s,{children:"PHONE_CONTENT_CLASS"})," /"," ",e.jsx(s,{children:"TABLET_CONTENT_CLASS"})," ajoutées sous 1024 px) ; franchir un palier ne remonte donc jamais la page. À partir de 1024 px, l'en-tête marine, les pastilles et le filtre de site ne changent pas."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Onglets."})," ",e.jsx(s,{children:'useShellTabs("portal")'})," →"," ",e.jsx(s,{children:'tabRoleFor(ctx, "portal")'})," ="," ",e.jsx(s,{children:"siteOperator"})," : ",e.jsx(s,{children:"/portal"})," ·"," ",e.jsx(s,{children:"/portal/cases"})," (repli"," ",e.jsx(s,{children:"/portal/maintenance-reports"}),") · + ·"," ",e.jsx(s,{children:"/portal/service-reports"})," · Boîte de réception →"," ",e.jsx(s,{children:"/portal/inbox"}),". Le badge ne compte que les notifications : ",e.jsx(s,{children:"canMessage"})," est toujours faux pour un exploitant, et la page Inbox du portail n'a pas de segment Messages."]}),e.jsxs(r,{children:[e.jsx(n,{children:"+."})," ",e.jsx(s,{children:"PORTAL_CREATE_ACTIONS"})," — Nouveau cas (",e.jsx(s,{children:"/portal/cases?new=1"}),", gate"," ",e.jsx(s,{children:"can.viewTickets"}),") et Scanner une borne (",e.jsx(s,{children:"/scan"}),"). ",e.jsx(s,{children:"pages/portal/cases.tsx"})," consomme"," ",e.jsx(s,{children:"?new=1"})," via ",e.jsx(s,{children:"useSearch()"})," : il ouvre"," ",e.jsx(s,{children:"CreateTicketDialog"})," puis retire le paramètre avec"," ",e.jsx(s,{children:"replace"})," — aussi quand l'exploitant est déjà sur la liste, où seule la requête change (même route, pas de remontage)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Menu ☰."})," ",e.jsx(s,{children:"usePortalNav().menuSections"})," — la section du portail (Mon équipe seulement pour un Org-Admin, ",e.jsx(s,{children:"isOrgAdmin"}),") et Compte (",e.jsx(s,{children:"/portal/notifications"}),")."," ",e.jsx(s,{children:"NavMenu"})," a gagné une prop ",e.jsx(s,{children:"top"}),", transmise par la prop ",e.jsx(s,{children:"menuTop"})," de ",e.jsx(s,{children:"PhoneChrome"})," /"," ",e.jsx(s,{children:"TabletShell"}),", rendue sous la carte utilisateur et au-dessus de la recherche. Le portail y place son"," ",e.jsx(s,{children:"SiteCombobox"})," (libellé ",e.jsx(s,{children:"portal.siteFilter"})," ","relié par ",e.jsx(s,{children:"aria-labelledby"}),", popover à la largeur du déclencheur) branché sur le même magasin"," ",e.jsx(s,{children:"usePortalSiteFilter"})," que celui de l'en-tête desktop : la réclamation par utilisateur et le nettoyage d'un site périmé restent dans ",e.jsx(s,{children:"PortalLayout"}),", rien n'est dupliqué. La recherche du menu filtre les pages, jamais le filtre de site."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pages de premier niveau."})," ",e.jsx(s,{children:"buildCrumbs"})," donne l'accueil du portail comme fil à toute page ",e.jsx(s,{children:"/portal/<section>"})," ;"," ",e.jsx(s,{children:"backTarget"}
27)," les traite pourtant comme des pages de premier niveau (",e.jsx(s,{children:"TOP_LEVEL_PATTERNS"}),", comme"," ",e.jsx(s,{children:"/sites/:siteId"}),"), si bien que ☰ et les onglets s'y affichent. ← n'apparaît qu'en dessous :"," ",e.jsx(s,{children:"/portal/cases/:id"})," revient à"," ",e.jsx(s,{children:"/portal/cases"})," sans historique dans l'app."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Titre."})," Chaque page de liste du portail appelle"," ",e.jsx(s,{children:"useSetPageMeta"})," (le libellé de son entrée de navigation, ",e.jsx(s,{children:"portal.title"})," sur la vue d'ensemble) pour alimenter le ",e.jsx(s,{children:"<h1>"})," de la barre du haut ; les pages de détail partagées le faisaient déjà, et l'en-tête desktop garde son propre ",e.jsx(s,{children:"<h1>"}),"."]}),e.jsxs(r,{children:["Le confinement de ",e.jsx(s,{children:"protected-route.tsx"})," ne change pas. Balayage : aucune page sous ",e.jsx(s,{children:"/portal/*"})," ni les pages de détail partagées (rapport d'intervention, rapport d'entretien, cas, historique de borne) n'ont de barre fixe en bas ou pleine largeur à décaler — seule la visionneuse photo, un calque plein écran ",e.jsx(s,{children:"inset-0"}),"."]})]}),e.jsxs(i,{children:["Tests :"," ",e.jsx(s,{children:"components/portal/__tests__/portal-mobile-shell.test.tsx"})," ","(onglets, actions +, filtre de site et Mon équipe dans ☰, ← vers la liste, badge sans messages, un seul ",e.jsx(s,{children:"h1"}),", desktop inchangé, pas de remontage d'un palier à l'autre),"," ",e.jsx(s,{children:"pages/portal/__tests__/cases-new-param.test.tsx"})," et"," ",e.jsx(s,{children:"lib/nav/__tests__/back-target.test.ts"}),"."]})]}),e.jsxs(t,{id:"dialogs-adaptatifs",title:"14e. Fenêtres adaptatives",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/dialog.tsx"})," et"," ",e.jsx(s,{children:"alert-dialog.tsx"})," (S2.2, CHARGEOPS-507) résolvent un"," ",e.jsx(n,{children:"mode de présentation"})," une seule fois à l'ouverture et le gardent jusqu'à la fermeture, pour qu'une rotation du téléphone ne remplace jamais l'arbre d'une fenêtre ouverte sous son propre piège à focus — le résolveur pur est ",e.jsx(s,{children:"client/src/components/ui/dialog-mode.ts"})," ","et sa fonction"," ",e.jsx(s,{children:"resolveDialogMode(viewport, mobileProp, className, isAlertDialog)"})," :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(s,{children:'viewport !== "phone"'})," → ",e.jsx(s,{children:'"modal"'})," : chemin Radix + Framer Motion d'aujourd'hui, inchangé au caractère près."]}),e.jsxs(r,{children:["Sur téléphone, une ",e.jsx(s,{children:"AlertDialog"})," résout toujours vers"," ",e.jsx(s,{children:'"action"'})," ; la prop explicite"," ",e.jsx(s,{children:'mobile="sheet" | "fullscreen"'})," d'un"," ",e.jsx(s,{children:"Dialog"})," prime sur l'inférence par className ; sinon une className ",e.jsx(s,{children:"max-w-2xl"})," ou plus large donne"," ",e.jsx(s,{children:'"fullscreen"'}),", sinon ",e.jsx(s,{children:'"sheet"'}),"."]})]}),e.jsxs(i,{children:["Sur téléphone, les deux passent par ",e.jsx(s,{children:"Drawer.Root"}),"/",e.jsx(s,{children:"Content"})," de ",e.jsx(s,{children:"vaul"})," plutôt que par la fenêtre centrée de Radix : une ",e.jsx(s,{children:'"sheet"'})," est une feuille du bas qu'on peut glisser (grandit jusqu'à"," ",e.jsx(s,{children:"92dvh"}),", puis le corps défile) ; un panneau"," ",e.jsx(s,{children:'"fullscreen"'})," n'a pas de glissement, avec une barre du haut collante (✕ · titre · le premier enfant du pied de page, reflété comme action principale via un marqueur"," ",e.jsx(s,{children:"data-dialog-primary-action"})," posé par"," ",e.jsx(s,{children:"DialogFooter"}),") ; le mode ",e.jsx(s,{children:'"action"'})," ","d'une ",e.jsx(s,{children:"AlertDialog"})," est une feuille du bas avec le balisage Annuler/Action d'aujourd'hui inchangé (son"," ",e.jsx(s,{children:"flex-col-reverse"})," empile déjà l'action au-dessus d'Annuler sous 640 px — rien à réécrire là). Cette feuille porte ",e.jsx(s,{children:'style={{ pointerEvents: "auto" }}'})," ","(CHARGEOPS-627) : le ",e.jsx(s,{children:"Drawer.Content"})," de vaul est une couche Radix imbriquée DANS ",e.jsx(s,{children:"AlertDialogPrimitive.Content"}),", les deux s'inscrivent dans le même commit, l'enfant d'abord, et Radix donnait donc à la feuille"," ",e.jsx(s,{children:"pointer-events: none"})," — un toucher sur Annuler ou l'action tombait sur le voile, et la feuille (non refermable par geste) restait ouverte. Radix étale"," ",e.jsx(s,{children:"props.style"})," après sa propre valeur, donc celle-ci gagne. Test : ",e.jsx(s,{children:"ui/__tests__/alert-dialog-phone-taps.test.tsx"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Confirmation d'abandon"})," (spec §6.3) : un"," ",e.jsx(s,{children:"Dialog"})," suit l'état « modifié » à partir des événements ",e.jsx(s,{children:"input"}),"/",e.jsx(s,{children:"change"})," qui remontent, via une ref-callback (pas un effet indexé sur l'objet ref — le ",e.jsx(s,{children:"Portal"})," de Radix monte un rendu en retard, à dessein, pour la sécurité SSR, et un effet qui lit"," ",e.jsx(s,{children:"ref.current"})," à ce premier rendu encore vide n'a jamais de seconde chance de s'attacher). Glisser, toucher le fond, ✕ et Échap sur une feuille ou un plein écran modifié ouvrent une petite feuille d'action « Voulez-vous abandonner vos modifications ? » (",e.jsx(s,{children:"Continuer la modification"})," /"," ",e.jsx(s,{children:"Abandonner"}),", les deux via ",e.jsx(s,{children:"t()"}),") ; fermer par les propres boutons de la fenêtre (l'appelant qui met sa prop contrôlée ",e.jsx(s,{children:"open"})," à faux, par exemple après un enregistrement réussi) ne demande jamais rien."," ",e.jsxs(s,{children:["confirmDiscard=","{false}"]})," permet de s'en exclure. Le geste de glissement est désactivé d'emblée tant que c'est modifié (",e.jsx(s,{children:"data-vaul-no-drag"})," sur tout le contenu de la feuille) plutôt que de laisser un relâchement de glissement se terminer puis d'essayer de l'annuler — vaul n'a rien pour remettre la transformation à sa place une fois le relâchement déjà déclenché."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Sélecteurs imbriqués"})," (spec §6.4) :"," ",e.jsx(s,{children:"SelectContent"})," et ",e.jsx(s,{children:"PopoverContent"})," portent"," ",e.jsx(s,{children:"data-vaul-no-drag"}),". Un toucher à l'intérieur d'un portail Radix remonte quand même comme un événement synthétique React à travers ce qui a rendu le propriétaire du portail — même si le nœud DOM lui-même vit dans un autre sous-arbre — donc sans l'attribut, ouvrir un"," ",e.jsx(s,{children:"Select"})," à l'intérieur d'une feuille sur téléphone s'enregistrerait comme le début d'un glissement pour fermer. ",e.jsx(s,{children:"vaul"})," vérifie exactement cet attribut via ",e.jsx(s,{children:"hasAttribute"}),"/",e.jsx(s,{children:"closest"})," avant de démarrer un glissement."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Fond relevé"})," (CHARGEOPS-379) :"," ",e.jsx(s,{children:"AlertDialogContent"})," accepte"," ",e.jsx(s,{children:"overlayClassName"}),", appliqué au fond dans les deux formes (fenêtre centrée et feuille). Il sert à la rare alerte qui doit s'empiler au-dessus d'une autre couche fixe — la confirmation de suppression d'une conversation, au-dessus du panneau de l'assistant (",e.jsx(s,{children:"z-[65]"}),") : son fond monte avec elle au lieu de rester à ",e.jsx(s,{children:"z-50"})," sous le panneau."]}),e.jsxs(i,{children:["Tests : ",e.jsx(s,{children:"ui/__tests__/dialog-mode.test.ts"})," (la table du résolveur) et ",e.jsx(s,{children:"ui/__tests__/dialog-modes.test.tsx"})," ","(structure feuille / plein écran / action, la confirmation d'abandon avec et sans champ modifié, l'action principale reflétée dans la barre du haut en plein écran, un"," ",e.jsx(s,{children:"Select"})," imbriqué qui s'ouvre et choisit à l'intérieur d'une feuille, et un instantané de classes prouvant que la fenêtre modale desktop est inchangée)."]})]}),e.jsxs(t,{id:"listes-adaptatives",title:"14f. Listes et filtres adaptatifs",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/lists/responsive-list.tsx"})," et"," ",e.jsx(s,{children:"filter-bar.tsx"})," (S2.3, CHARGEOPS-508) sont le rendu téléphone de la table d'une page : sur tablette et desktop,"," ",e.jsx(s,{children:"ResponsiveList"})," rend sa prop ",e.jsx(s,{children:"desktop"})," (la table existante) sans y toucher ; sur téléphone, elle rend des fiches ",e.jsx(s,{children:".co-card"})," en une colonne (≥ 64 px,"," ",e.jsx(s,{children:"@tanstack/react-virtual"})," au-delà de 60 lignes), avec balayage (pointeurs tactiles seulement, jusqu'à 2 actions par côté, chaque action aussi dans un menu ⋯) et sélection en masse par appui long. Là où l'appui long sélectionne, la fiche porte ",e.jsx(s,{children:"select-none [-webkit-touch-callout:none]"})," et annule ",e.jsx(s,{children:"contextmenu"})," (la paire de"," ",e.jsx(s,{children:"nav-menu.tsx"}),") : le maintien n'ouvre jamais aussi le menu de lien du navigateur ; une liste sans sélection par appui long le garde. Chaque nouvel appui efface le drapeau « appui long déclenché » : le toucher suivant un appui long tactile (qui ne finit par aucun clic) bascule la fiche, et le clic qui suit un appui long à la souris reste avalé ; la minuterie de 500 ms est effacée au démontage (CHARGEOPS-604)."," ",e.jsx(s,{children:"sectionFor"})," groupe les fiches sous un en-tête collant (utilisé par le pilote Rapports de service pour ses bandes de mois) — désactive la virtualisation quand il est fourni, groupage et fenêtrage ne se composent pas dans cette version."]}),e.jsxs(i,{children:[e.jsx(s,{children:"FilterBar"})," fait le même partage : sur tablette/desktop"," ",e.jsx(s,{children:"children"})," (le panneau de filtres existant de la page) rend inchangé ; sur téléphone elle affiche sa propre recherche, une rangée de 3 à 5 puces rapides définies par la page, et une puce « Filtres (n) » qui ouvre une feuille (S2.2) contenant"," ",e.jsx(s,{children:"children"})," avec Appliquer/Réinitialiser — la page garde son état de filtre existant, sans état intermédiaire séparé. La feuille passe ",e.jsxs(s,{children:["confirmDiscard=","{false}"]})," à la fenêtre partagée : Appliquer/Réinitialiser possèdent déjà « est-ce que quelque chose a changé ? » pour un panneau de filtres, donc la confirmation d'abandon de la fenêtre (§14e) n'ajoute jamais une seconde demande « Voulez-vous abandonner vos modifications ? » par-dessus. Une prop optionnelle"," ",e.jsx(s,{children:"sheetChildren"})," permet à une page de fournir une version de son panneau de filtres sans le champ de recherche pour le corps de la feuille, pour que la recherche de la barre compacte au-dessus ne soit jamais dupliquée — les deux pilotes l'utilisent. Une prop ",e.jsx(s,{children:"compact?: boolean"})," force la présentation téléphone même hors d'un vrai viewport téléphone, pour le volet liste de 320 px de la séparation liste/détail sur tablette (§14h) : les deux pilotes passent"," ",e.jsxs(s,{children:["compact=","{forcePhoneCards}"]}),"."]}),e.jsxs(i,{children:["Tirer vers le bas en haut de la liste au-delà de 72 px (",e.jsx(s,{children:"usePullToResync"}),","," ",e.jsx(s,{children:"client/src/hooks/use-pull-to-resync.ts"}),") force une reconnexion Convex et vide la file hors ligne, puis affiche « à jour » ou « hors ligne — synchronisera au retour ». La liste porte ",e.jsx(s,{children:"overscroll-behavior-y: contain"}),". « En haut » se lit sur le vrai défileur (CHARGEOPS-614) :"," ",e.jsx(s,{children:"window.scrollY"})," sur téléphone, le"," ",e.jsx(s,{children:"scrollTop"})," du volet liste dans la séparation tablette. Avant, le hook lisait la div de la liste, toujours à 0 : glisser vers le bas pour remonter une page déjà défilée armait la resynchronisation et son toast."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Fiches mesurées, un seul « Sélectionner » (CHARGEOPS-610)."})," ","Au-delà de 60 lignes, chaque ligne virtualisée est mesurée : son enveloppe porte ",e.jsx(s,{children:"data-index"})," et"," ",e.jsxs(s,{children:["ref=","{virtualizer.measureElement}"]}),", et"," ",e.jsx(s,{children:"estimateSize"})," (88 px depuis CHARGEOPS-614, une fiche avec une ligne ",e.jsx(s,{children:"meta"}),", le cas courant ; 72 px avant) n'est plus que l'estimation d'une ligne pas encore mesurée. Avant, les lignes étaient posées à 72 × index : une fiche avec une ligne ",e.jsx(s,{children:"meta"})," (~88 px) chevauchait la suivante d'environ 15 px (70 fiches, 69 chevauchements mesurés dans le banc d'essai à 375 px). Les mesures sont rangées par clé de ligne (",e.jsx(s,{children:"getItemKey"}),"), pour qu'un filtre ou un tri garde la hauteur de chaque fiche, et"," ",e.jsx(s,{children:"paddingStart"}),"/",e.jsx(s,{children:"paddingEnd"})," reproduisent le"," ",e.jsx(s,{children:"p-3"})," de la liste non virtualisée."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Fenêtrage réel (CHARGEOPS-614)."})," Le virtualiseur surveillait la div ",e.jsx(s,{children:"h-full overflow-y-auto"})," de la liste, qui ne défile jamais : sur téléphone, c'est le document qui défile ; dans la séparation tablette, le volet liste. La div prenait la hauteur de son contenu et toutes les lignes restaient dans la plage (200 fiches montées sur 200 dans le banc d'essai). ",e.jsx(s,{children:"resolveListScroller"})," (",e.jsx(s,{children:"client/src/components/lists/list-scroller.ts"}),") trouve le vrai défileur : la div de la liste si un parent borne sa hauteur (la vue liste des cartes), sinon l'ancêtre"," ",e.jsx(s,{children:"[data-list-scroll-container]"})," (",e.jsx(s,{children:"LIST_SCROLL_CONTAINER_ATTR"}),") le plus proche (les deux volets de ",e.jsx(s,{children:"ListDetailLayout"}),"), sinon la fenêtre. « Bornée » se lit sur la hauteur du contenu dans le flux (",e.jsx(s,{children:"inFlowContentHeight"})," : la somme des"," ",e.jsx(s,{children:"offsetHeight"}
27)," des enfants non positionnés ; le corps virtuel a une hauteur explicite), jamais sur"," ",e.jsx(s,{children:"scrollHeight"}),". Celui-ci compte aussi les lignes en"," ",e.jsx(s,{children:"translateY"}),", et une ligne montée dans le même commit est encore posée à son estimation : une fiche de billet (~95 px) dépassait sous le corps virtuel, la liste se croyait bornée, montait toutes ses lignes et le tirer lisait un"," ",e.jsx(s,{children:"scrollTop"})," à 0 jusqu'au prochain changement du nombre de lignes (effacer un filtre en bas d'une liste de 60 fiches au plus, une ligne ajoutée en direct). Sur téléphone,"," ",e.jsx(s,{children:"useWindowVirtualizer"})," ; dans un volet,"," ",e.jsx(s,{children:"useVirtualizer"})," sur ce volet, avec"," ",e.jsx(s,{children:"initialOffset"})," à son ",e.jsx(s,{children:"scrollTop"})," pour ne pas le ramener en haut. ",e.jsx(s,{children:"scrollMargin"})," est la distance de la liste au haut du contenu défilant (filtres et indicateurs au-dessus), remesurée à chaque rendu, au redimensionnement et quand ce contenu change de taille ; chaque ligne la retranche de"," ",e.jsx(s,{children:"vi.start"}),". Dans le banc d'essai, 200 fiches : 5 à 18 montées à 375 px, 9 à 20 dans le volet à 900 px, aucun chevauchement (",e.jsx(s,{children:"VIRTUAL_OVERSCAN"})," = 4 lignes de part et d'autre). Les hauteurs mesurées sont aussi gardées au niveau du module (2 000 au plus), par liste et par clé de ligne : une liste remontée au retour d'une fiche pose ses lignes à leur vraie hauteur, et un décalage restauré retombe sur la même fiche. Le nom d'une liste est la route sur laquelle elle s'est montée (figé au montage : le volet liste le garde pendant ",e.jsx(s,{children:"/support"})," ⇄ ",e.jsx(s,{children:"/support/:id"}),"), plus sa prop facultative ",e.jsx(s,{children:"listKey"})," — le même identifiant (borne, client, ",e.jsx(s,{children:"ncmRouterId"}),") figure dans plusieurs listes aux fiches de hauteurs différentes ;"," ",e.jsx(s,{children:"resetRowHeightMemory()"})," la vide pour les tests. La ligne qui a le focus clavier reste montée (un"," ",e.jsx(s,{children:"rangeExtractor"})," fusionné avec"," ",e.jsx(s,{children:"defaultRangeExtractor"}),", suivi par clé de ligne et trié pour garder l'ordre de tabulation) : défilée hors de la plage, elle se démontait et le focus retombait sur"," ",e.jsx(s,{children:"<body>"}),". Le corps virtuel est un"," ",e.jsx(s,{children:'role="list"'})," et chaque ligne un"," ",e.jsx(s,{children:'role="listitem"'})," avec"," ",e.jsx(s,{children:"aria-setsize"})," (le vrai total) et"," ",e.jsx(s,{children:"aria-posinset"}),". Les listes groupées (",e.jsx(s,{children:"sectionFor"}),") ne sont toujours pas virtualisées. C'est pourquoi ",e.jsx(n,{children:"Mon parc"})," du portail (",e.jsx(s,{children:"pages/portal/fleet.tsx"}),") et, depuis CHARGEOPS-663, ses"," ",e.jsx(n,{children:"Billets de support"})," (",e.jsx(s,{children:"pages/portal/cases.tsx"}),", volet de la vue scindée compris) ne rangent leurs fiches sous un en-tête par site que jusqu'à"," ",e.jsx(s,{children:"PHONE_SECTION_LIMIT"})," (300,"," ",e.jsx(s,{children:"lib/portal-site-groups.ts"}),", qui porte aussi"," ",e.jsx(s,{children:"groupBySite"}),", l'ordre des sections partagé avec le tableau desktop) : au-delà, la liste reste plate pour rester fenêtrée et chaque fiche redonne son site dans ses faits."]}),e.jsxs(i,{children:[e.jsx(n,{children:"À respecter, et limites connues."})," Tout conteneur défilant qui contient un ",e.jsx(s,{children:"ResponsiveList"})," doit porter"," ",e.jsx(s,{children:"LIST_SCROLL_CONTAINER_ATTR"})," (comme les deux volets de"," ",e.jsx(s,{children:"ListDetailLayout"}),") : sans lui, la liste se rabat sur la fenêtre, fenêtre ses lignes contre un défilement qui n'est pas le sien et lit « en haut » sur ",e.jsx(s,{children:"window.scrollY"}),". Limites connues : la recherche dans la page du navigateur (Ctrl+F, « Rechercher dans la page » sur iOS) ne trouve pas les lignes démontées ; et seul le focus DOM retient une ligne — une ligne atteinte par le curseur virtuel d'un lecteur d'écran sans recevoir le focus se démonte dè
27s qu'elle sort de la plage, comme toute autre."]}),e.jsxs(i,{children:[e.jsx(s,{children:"SwipeAction.menuOnly"})," place une action dans le ⋯ seulement — jamais dans le tiroir de balayage ni dans le calcul du geste. Deux usages : une action destructive qui reste derrière un toucher explicite (Supprimer), ou une entrée non destructive qui ne mérite pas une place de balayage (« Détails », « Sélectionner »). Là où"," ",e.jsx(s,{children:"selection.onLongPress"})," est défini,"," ",e.jsx(s,{children:"ResponsiveList"})," ajoute elle-même, après les actions de la page, une entrée ⋯ ",e.jsx(s,{children:"menuOnly"})," « Sélectionner {titre} » (clé partagée ",e.jsx(s,{children:"list.selectRow"}),") qui appelle le même ",e.jsx(s,{children:"onLongPress"})," : l'appui long est invisible et inatteignable au clavier, c'est la même porte d'entrée. Elle disparaît en mode sélection, où un toucher bascule déjà la fiche. Une page n'ajoute donc plus sa propre action « Sélectionner » (Mon parc a perdu la sienne ; les Billets de support l'ont gagnée)."]}),e.jsx(i,{children:"La puce « Filtres (n) » ne compte que les contrôles de la feuille : la recherche a son propre champ toujours visible au-dessus et n'est jamais dans la feuille, donc elle ne compte pas — un tri qui vit dans la feuille, lui, compte. Clients, Contacts, Gestion des sites, Centre de maintenance, Rapports de service et Billets de support comptaient la recherche ; les Billets de support gardent ce compte pour « Effacer les filtres (n) », qui efface aussi la recherche."}),e.jsxs(i,{children:[e.jsx(n,{children:"Un tri dans la feuille (CHARGEOPS-607)."})," Un tableau qui trie par ses en-têtes de colonne perd ce tri une fois en fiches. Les deux listes de rapports d'entretien (",e.jsx(a,{children:"/maintenance-reports"})," et"," ",e.jsx(a,{children:"/portal/maintenance-reports"}),") le rendent dans la feuille : ",e.jsx(s,{children:"ReportsSortSheet"})," (",e.jsx(s,{children:"client/src/components/maintenance-reports/list/reports-sort-sheet.tsx"}),") suit le panneau de filtres dans ",e.jsx(s,{children:"sheetChildren"})," et écrit le même ",e.jsx(s,{children:"SortState"})," que les en-têtes. Un tri autre que celui par défaut compte pour un dans ",e.jsx(s,{children:"activeCount"}),", et Réinitialiser le remet. Le détail est dans la page technique « 06 — Maintenance et Licences Cleo »."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Billets de support"})," (",e.jsx(s,{children:"support-tickets.tsx"}),") — la fiche affiche"," ",e.jsx(s,{children:"#n · sujet"}),", le statut, la priorité, borne/site et l'heure de mise à jour ; balayer propose « Me l'assigner » et « Changer le statut » (qui active la sélection en masse avec cette seule ligne — le sélecteur de statut déjà construit, ",e.jsx(s,{children:"TicketBulkBar"}),", s'en charge, sans doublon)."," ",e.jsx(s,{children:"TICKET_STATUS_TONE"})," (",e.jsx(s,{children:"ticket-badges.tsx"}),") est la seule source de vérité du ton, partagée avec"," ",e.jsx(s,{children:"TicketStatusBadge"})," desktop."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Rapports de service"})," (",e.jsx(s,{children:"service-reports.tsx"}),") — la fiche affiche le numéro, technicien·ne, site, date et statut ; balayer propose « Ouvrir le PDF » et « Partager » — les deux ouvrent la page de détail avec un paramètre de requête (",e.jsx(s,{children:"?download=1"})," /"," ",e.jsx(s,{children:"?share=1"}),", même convention) plutôt que de reconstruire le rendu hors-écran de PDF de marque que la page de détail possède déjà ; les bandes de mois existantes deviennent les en-têtes de section collants via"," ",e.jsx(s,{children:"sectionFor"}),"."]})]}),e.jsxs(i,{children:["Tests :"," ",e.jsx(s,{children:"components/lists/__tests__/responsive-list.test.tsx"})," ","et ",e.jsx(s,{children:"filter-bar.test.tsx"})," (changement de fenêtre, champs de fiche, balayage et le repli ⋯ qui appellent le même gestionnaire, le seuil de tirer déclenche une resynchronisation, virtualisation au-delà de 60), et par pilote"," ",e.jsx(s,{children:"pages/__tests__/support-tickets-phone-cards.test.tsx"})," ","/ ",e.jsx(s,{children:"service-reports-phone-cards.test.tsx"})," (champs de fiche, actions de balayage sans geste de glissement, décompte de la puce « Filtres (n) » de FilterBar)."]})]}),e.jsxs(t,{id:"mise-en-page-detail",title:"14g. Mise en page de détail (DetailLayout)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/detail/detail-layout.tsx"})," (S2.4, CHARGEOPS-509) est le rendu téléphone/tablette d'une page de détail : un en-tête (« hero »), une rangée d'onglets collante et balayable (≥ 2 sections), une barre d'actions fixe en bas (jusqu'à 3 actions) et le repli ⋯ de la barre du haut 
27(S1) pour le reste — sur desktop (≥ 1024 px), ",e.jsx(s,{children:"DetailLayout"})," ","rend sa prop ",e.jsx(s,{children:"desktop"})," sans y toucher, même convention que ",e.jsx(s,{children:"ResponsiveList"}),". Le titre du hero n'est PAS un ",e.jsx(s,{children:"<h1>"})," : la mise en page possède le seul"," ",e.jsx(s,{children:"<h1>"})," de la page (celui de la barre du haut, via"," ",e.jsx(s,{children:"usePageMeta"}),") — un ",e.jsx(s,{children:"IntersectionObserver"})," ","bascule un état ",e.jsx(s,{children:"heroVisible"})," quand le hero sort de l'écran, et le titre « traverse » visuellement vers la barre du haut à ce moment (le hero garde le champ vide tant qu'il est visible)."]}),e.jsxs(i,{children:["Le balayage d'onglet (",e.jsx(s,{children:"tab-swipe.ts"}),") verrouille l'axe du geste une seule fois par geste, au premier dépassement de la zone morte (8 px), à 30° de l'horizontale — un glissement qui commence horizontal et dérive vertical (ou l'inverse) ne change pas d'axe sous le doigt. Un onglet qui héberge une ",e.jsx(s,{children:"ResponsiveList"})," dont les fiches ont leur propre glissement de révélation (S2.3) ne doit jamais faire démarrer les deux gestes à la fois :"," ",e.jsx(s,{children:"data-no-tab-swipe"})," sur la surface de glissement de la fiche (",e.jsx(s,{children:"SwipeRow"}),") fait que"," ",e.jsx(s,{children:"startsInNoSwipeZone"})," (via ",e.jsx(s,{children:"closest()"}),") ignore ce toucher pour le geste d'onglet. Un"," ",e.jsx(s,{children:"?tab="})," dans l'URL survit à un rechargement ; un identifiant absent ou invalide retombe sur le premier onglet plutôt que de rendre un panneau vide sans onglet actif. Une boîte de dialogue Radix/vaul (ou un popover ",e.jsx(s,{children:"Select"}),") ouverte depuis un onglet rend son contenu via un portail monté directement sous ",e.jsx(s,{children:"document.body"})," — hors du DOM de"," ",e.jsx(s,{children:"detail-content"})," — mais les événements synthétiques de React remontent quand même à travers l'arbre React, donc un geste à l'intérieur de ce portail atteignait aussi ces gestionnaires. Le gestionnaire de début de toucher abandonne maintenant quand ",e.jsx(s,{children:"!e.currentTarget.contains(e.target)"}),", avant même de commencer à suivre le geste — plus général qu'un attribut d'exclusion posé sur un seul consommateur de portail, puisqu'il couvre tous les portails."]}),e.jsx(i,{children:"Deux premiers pilotes (S2.4b) — desktop inchangé sur les deux :"}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Détail d'un billet de support"})," (",e.jsx(s,{children:"support-ticket-detail.tsx"}),") — onglets Conversation (description, photos de défauts, note de résolution, cas fabricant, pièces jointes, analyse de journal, commentaires), Détails (fiche du cas, visites, cas liés, copilote IA, satisfaction) et Activité ; actions Répondre (bascule l'onglet Conversation et met le focus dans le composeur via une ref exposée par ",e.jsx(s,{children:"TicketCommentsCard"}),"), Changer le statut et Assigner. Le statut et l'assignation n'ont pas de second contrôle : ",e.jsx(s,{children:"TicketStatusControl"})," et"," ",e.jsx(s,{children:"TicketAssigneeControl"})," (extraits de"," ",e.jsx(s,{children:"ticket-header-card.tsx"}),", toujours utilisés par"," ",e.jsx(s,{children:"TicketHeaderCard"})," lui-même) sont montés une seconde fois dans une petite ",e.jsx(s,{children:"Dialog"})," possédée par la page, ouverte par le bouton de la barre d'actions — le même composant, jamais une deuxième interface. La suppression reste dans le repli ⋯. Les pastilles d'origine (défaut, RMA liés, soumissions de réparation) s'affichent dans le héros à côté du statut (une rangée ",e.jsx(s,{children:"flex flex-wrap gap-1.5"}),") : la rangée de pastilles de l'en-tête est dans son bloc d'identité ",e.jsx(s,{children:"hidden lg:block"}),", donc la page ne les passe à ",e.jsx(s,{children:"originBadges"})," que sur ordinateur — un seul jeu à chaque largeur, aucun dans le Portail opérateur. Le reste de ce bloc (priorité, catégorie, pastille SLA, pilule de niveau de service, puis site · organisation · auteur) vient de"," ",e.jsx(s,{children:"ticket-identity.tsx"})," (",e.jsx(s,{children:"TicketIdentityBadges"}),","," ",e.jsx(s,{children:"TicketIdentityMeta"}),"), que l'en-tête et le héros rendent tous les deux — même calcul SLA, mêmes règles du portail (pas de pilule de niveau ; ",e.jsx(s,{children:"SiteLink"})," retombe en texte). Le crayon « Changer le site » de l'en-tête n'existe que sur ordinateur : l'action rejoint donc Supprimer dans le menu ⋯ et ne monte ",e.jsx(s,{children:"TicketChangeSiteDialog"})," que pendant qu'elle est ouverte (CHARGEOPS-572)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Détail d'un rapport de service"})," (",e.jsx(s,{children:"service-report-detail.tsx"}),") — onglets Rapport (fiche du rapport, temps de main-d'œuvre, facturation, bornes, travail sous garantie étendue, description, résumé de pied de page), Photos et Signatures (accusé de réception, tampons GPS) ; actions Ouvrir le PDF, Partager et Modifier. Honore ",e.jsx(s,{children:"?download=1"})," / ",e.jsx(s,{children:"?share=1"})," (les mêmes paramètres que le balayage de la liste, S2.3) au premier chargement du rapport, en réutilisant les gestionnaires existants de la page (la boîte de dialogue de langue d'export ; la boîte de dialogue de partage), puis retire le paramètre par un remplacement d'URL — une ref garde le déclenchement à usage unique, pour qu'un rechargement ou un retour ne le refasse pas."]})]}),e.jsxs(i,{children:["Piège partagé par les deux pilotes : ",e.jsx(s,{children:"ServiceReportsShell"})," ","(et l'appel direct à ",e.jsx(s,{children:"useSetPageMeta"})," sur la page des billets) fixe un titre statique — sur téléphone, la page passe une paire vide plutôt que le titre générique, pour que son effet (qui ne se déclenche qu'au montage, ses dépendances restant stables) s'accorde avec la valeur initiale de"," ",e.jsx(s,{children:"DetailLayout"})," (hero visible ⇒ titre vide) au lieu de l'écraser avant le premier défilement."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Réserve de la barre d'actions"})," (CHARGEOPS-609) : la barre fixe publie sa hauteur dans"," ",e.jsx(s,{children:"--co-bottom-bar-h"})," (",e.jsx(s,{children:"useBottomBarInset"}),"), et ce sont les coquilles qui la réservent, pas la page :"," ",e.jsx(s,{children:"PHONE_CONTENT_CLASS"})," se rembourre de"," ",e.jsx(s,{children:"calc(var(--tabbar-h)+var(--co-bottom-bar-h,0px))"})," et"," ",e.jsx(s,{children:"TABLET_CONTENT_CLASS"})," de"," ",e.jsx(s,{children:"var(--co-bottom-bar-h,0px)"}),". Ce conteneur est le seul élément sous tous les pieds de page (le ",e.jsx(s,{children:"Footer"})," du billet ou de la fiche de borne, celui de"," ",e.jsx(s,{children:"PortalLayout"}),") ; un rembourrage posé sur"," ",e.jsx(s,{children:"DetailLayout"})," laissait le pied de page, rendu après lui, sous la barre (5 pages de détail sur 7). La barre se place à"," ",e.jsx(s,{children:"bottom-[var(--tabbar-h)]"})," : toutes les routes de détail sont des pages intérieures (barre d'onglets masquée, 0 px) et la tablette n'a pas de barre d'onglets, mais si les deux coexistaient, la barre s'empilerait au-dessus et la somme resterait exacte. Les autres barres fixes (formulaire de rapport de service, assistant d'entretien, mise en service) publient la même variable et profitent de la même réserve : aucune page n'ajoute plus son propre"," ",e.jsx(s,{children:"pb-[var(--co-bottom-bar-h)]"}),". En mode"," ",e.jsx(s,{children:"embedded"})," (séparation tablette), la barre est collante, ne publie rien et rien n'est réservé."]}),e.jsxs(i,{children:["Une barre qui publie sa hauteur se place juste au-dessus de la barre d'onglets ou de la zone sécurisée, et n'ajoute jamais ",e.jsx(s,{children:"--co-assistant-launcher-h"})," à son"," ",e.jsx(s,{children:"bottom"})," : le bouton de l'assistant ChargeOps AI (§14i) flotte déjà au-dessus de la hauteur publiée, donc ce terme faisait passer la barre sous le bouton (sur Enregistrer) et laissait 68 px de page cachés. Seules les barres qui ne publient rien (la barre d'actions groupées des billets, les boutons de Toutes les bornes) l'ajoutent. La barre de mise en service flotte 0,75 rem plus haut : elle publie aussi cet écart (",e.jsx(s,{children:'useBottomBarInset("0.75rem")'}),"). Si deux barres sont montées en même temps, la variable porte la plus haute et ne s'efface qu'au démontage de la dernière."]}),e.jsxs(i,{children:[e.jsx(s,{children:'DetailAction.tone: "destructive"'}
27)," peint l'entrée du menu ⋯ en ",e.jsx(s,{children:"co-text-negative"}),", comme le ⋯ de ",e.jsx(s,{children:"ResponsiveList"})," (Supprimer du billet, du rapport de service et du rapport d'entretien, Supprimer le brouillon du bon de commande) ; l'entrée ouvre toujours la confirmation existante. Les faits du héros sont clés par position, et une page omet un fait manquant (",e.jsx(s,{children:".filter(Boolean)"}),") plutôt que d'afficher « — ». Méta de page : une page au-dessus de"," ",e.jsx(s,{children:"DetailLayout"})," ne cède (",e.jsx(s,{children:"null"}),") que lorsque"," ",e.jsx(s,{children:"DetailLayout"})," est réellement rendu (",e.jsx(s,{children:"isMobile && (embedded || données chargées)"}),") : pendant le chargement et sur « introuvable », elle pose son propre titre, sinon la barre du haut restait vide (billet, garantie prolongée, rapport d'entretien — même règle que le bon de commande, l'organisation et ",e.jsx(s,{children:"PendingPageMeta"})," de la fiche de borne). En mode ",e.jsx(s,{children:"embedded"})," elle cède toujours : la page liste possède le titre et"," ",e.jsx(s,{children:"fullWidth"}),"."]}),e.jsxs(i,{children:["Tests : ",e.jsx(s,{children:"detail/__tests__/detail-layout.test.tsx"})," et"," ",e.jsx(s,{children:"tab-swipe.test.ts"})," (verrouillage d'axe, repli sur le premier onglet, ",e.jsx(s,{children:"?tab="})," qui survit), et par pilote"," ",e.jsx(s,{children:"service-report-detail-links.test.tsx"}),"/",e.jsx(s,{children:"-signoff.test.tsx"}),"/",e.jsx(s,{children:"-billing.test.tsx"})," (le corps desktop enveloppé, un cas viewport téléphone : 3 onglets,"," ",e.jsx(s,{children:"?tab=photos"})," ouvre sur Photos, Ouvrir le PDF est l'action principale, un seul ",e.jsx(s,{children:"<h1>"}),")."]})]}),e.jsxs(t,{id:"separation-liste-detail-tablette",title:"14h. Séparation liste/détail sur tablette (ListDetailLayout)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/detail/list-detail-layout.tsx"})," ","(S2.5, CHARGEOPS-510) — sur tablette (640-1023 px) ET au moins 760 px de large, les deux pilotes (billets de support, rapports de service) affichent leur liste et leur détail côte à côte au lieu de deux pages séparées : un volet gauche de 320 px (les fiches téléphone de ",e.jsx(s,{children:"ResponsiveList"}),", qui défile indépendamment) et un volet droit (le détail, ou l'état vide"," ",e.jsx(s,{children:"« Sélectionnez un… »"})," tant qu'aucune fiche n'est ouverte)."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Une seule route, aucun remontage"})," —"," ",e.jsx(s,{children:"App.tsx"})," remplace les deux routes (",e.jsx(s,{children:"/support"})," + ",e.jsx(s,{children:"/support/:ticketId"}),","," ",e.jsx(s,{children:"/service-reports"})," +"," ",e.jsx(s,{children:"/service-reports/:reportId"}),") par UNE route à paramètre optionnel (",e.jsx(s,{children:"/support/:ticketId?"}),","," ","wouter matche avec ou sans le segment final) rendue par un petit wrapper (",e.jsx(s,{children:"SupportSplitRoute"}),"/",e.jsx(s,{children:"ServiceReportSplitRoute"}),"). Toucher une fiche ne fait que changer l'URL — la même instance de la page liste reste montée, donc son défilement et ses filtres survivent."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Repli tablette étroite"})," — sous 760 px (portrait sur un iPad mini), le wrapper rend directement la page liste OU la page détail (jamais les deux), exactement comme le flux téléphone : changer d'id remonte alors le composant, comme avant."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pas de ← dans le volet détail"})," —"," ",e.jsx(s,{children:"backTarget"})," (",e.jsx(s,{children:"lib/nav/back-target.ts"}),") prend un 4e paramètre ",e.jsx(s,{children:"isSplitActive"})," (par défaut"," ",e.jsx(s,{children:"false"}),", donc tous les appelants d'avant S2.5 sont inchangés) : à ",e.jsx(s,{children:"true"})," il court-circuite tout de suite à ",e.jsx(s,{children:"null"}),", la séparation EST la navigation."," ",e.jsx(s,{children:"PhoneChrome"}),"/",e.jsx(s,{children:"TabletShell"})," (S1) calculent"," ",e.jsx(s,{children:"isSplitActive"})," de la même façon (",e.jsx(s,{children:"useSplitViewport() && isSplitRouteLocation(location)"}),") et le passent à ",e.jsx(s,{children:"TopBar"}),", qui seul appelle"," ",e.jsx(s,{children:"backTarget"})," — ",e.jsx(s,{children:"isSplitRouteLocation"})," et"," ",e.jsx(s,{children:"SPLIT_PAIRS"})," (les sept paires réparties) vivent dans"," ",e.jsx(s,{children:"back-target.ts"}),", une seule source de vérité pour App.tsx, les wrappers de route et les deux chromes."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Paires du portail (S3 V5, CHARGEOPS-602)"})," —"," ",e.jsx(s,{children:"PortalCasesSplitRoute"})," associe la liste des billets du portail à la page PARTAGÉE"," ",e.jsx(s,{children:"support-ticket-detail.tsx"})," sous une seule route"," ",e.jsx(s,{children:"/portal/cases/:ticketId?"}),", déclarée après le passeport de borne à 4 segments ; le détail continue de réécrire ses liens sous ",e.jsx(s,{children:"/portal"})," grâce à son second"," ",e.jsx(s,{children:"useRoute"}),". ",e.jsx(s,{children:"PortalLayout"})," n'a pas de crochet ",e.jsx(s,{children:"fullWidth"})," : il interroge l'URL."," ",e.jsx(s,{children:"isSplitPairLocation(location)"})," (l'une ou l'autre moitié d'une paire, racine de liste ou détail) 
27sur une tablette d'au moins 760 px retire le rembourrage du"," ",e.jsx(s,{children:"<main>"})," et le pied de page, qui sinon s'empileraient sur les volets en 100dvh et feraient défiler la page une seconde fois. Une future paire du portail n'a qu'à ajouter sa clé à ",e.jsx(s,{children:"SPLIT_PAIRS"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Rapports du portail (CHARGEOPS-603)"})," — c'est ce qu'ont fait ",e.jsx(s,{children:"PortalServiceReportSplitRoute"})," et"," ",e.jsx(s,{children:"PortalMaintenanceReportSplitRoute"})," : les motifs"," ",e.jsx(s,{children:"/portal/service-reports/:reportId"})," et"," ",e.jsx(s,{children:"/portal/maintenance-reports/:reportId"}),", une route optionnelle chacune dans App.tsx, et les pages PARTAGÉES"," ",e.jsx(s,{children:"service-report-detail.tsx"})," et"," ",e.jsx(s,{children:"maintenance-reports/detail.tsx"})," en ",e.jsx(s,{children:"embedded"}),", qui gardent leur détection du portail par leur second"," ",e.jsx(s,{children:"useRoute"}),". Aucun changement à"," ",e.jsx(s,{children:"PortalLayout"})," ni à ",e.jsx(s,{children:"isSplitPairLocation"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La liste reste vraiment montée (CHARGEOPS-607)"})," ","— une seule route ne suffisait pas : ",e.jsx(s,{children:"AppLayout"})," ","donnait à son ",e.jsx(s,{children:"ErrorBoundary"})," de page l'emplacement comme clé, et ",e.jsx(s,{children:"PortalLayout"})," ",e.jsx(s,{children:"`${location}::${siteFilter}`"})," ; passer de"," ",e.jsx(s,{children:"/x"})," à ",e.jsx(s,{children:"/x/:id"})," changeait donc la clé et React remontait toute la vue partagée — la liste perdait son défilement, sa recherche, son tri et ses filtres à chaque toucher, dans les sept paires. Les deux mises en page utilisent désormais"," ",e.jsx(s,{children:"pageBoundaryKey(location, splitViewport)"})," ","(",e.jsx(s,{children:"back-target.ts"}),") : sur une tablette d'au moins 760 px, sur l'une ou l'autre moitié d'une paire, c'est la racine de liste de la paire (",e.jsx(s,{children:"splitListRoot"}),", tirée de"," ",e.jsx(s,{children:"SPLIT_PAIRS"}),") ; partout ailleurs l'emplacement, comme avant — téléphone, tablette étroite et desktop ne changent pas, et quitter la paire réinitialise toujours une page plantée. ",e.jsx(s,{children:"ListDetailLayout"})," ","enveloppe le volet détail dans son propre"," ",e.jsx(s,{children:'ErrorBoundary level="page"'}),", avec l'emplacement comme clé : un détail planté affiche la carte d'erreur dans son volet, la liste reste utilisable à côté, la sélection suivante le réinitialise, et chaque sélection monte toujours un détail neuf. Dans cette frontière, un"," ",e.jsx(s,{children:"<Suspense>"})," local affiche un squelette"," ",e.jsx(s,{children:"SectionLoading"})," à la taille du volet pendant que le morceau d'une page de détail paresseuse se charge : la première sélection ne remonte plus jusqu'au"," ",e.jsx(s,{children:"PageLoading"})," de route d'App.tsx, qui faisait clignoter toute la mise en page ; la frontière est"," ",e.jsx(s,{children:"contained"}),", donc un rechargement de morceau périmé affiche son état « mise à jour » dans le volet plutôt qu'en"," ",e.jsx(s,{children:"min-h-screen"}),". Limite acceptée : si le volet LISTE plante, la clé de la frontière de page (la racine de liste de la paire) ne change pas quand on passe d'un élément à l'autre de la même paire, donc sa carte d'erreur reste jusqu'à ce que tu touches ",e.jsx(n,{children:"Try Again"})," ou quittes la paire."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Volets positionnés"})," — les deux volets sont"," ",e.jsx(s,{children:"relative"}
27),". Chacun est son propre conteneur de défilement : un descendant en position absolue (le compte"," ",e.jsx(s,{children:"sr-only"})," d'une bande de groupe, une ancre) prend le volet comme bloc conteneur plutôt que la page, où il tombait sous la ligne de flottaison et faisait défiler la page une seconde fois. Le contournement ",e.jsx(s,{children:"relative"})," de la liste d'entretien du portail (#630) disparaît."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Sous-chemins littéraux"})," — la forme"," ",e.jsx(s,{children:"/racine/:param"})," d'une paire attrape aussi les pages sœurs que App.tsx déclare avant la route optionnelle."," ",e.jsx(s,{children:"SPLIT_LITERAL_SUBPATHS"})," les énumère (",e.jsx(s,{children:"/support/outages"}),","," ",e.jsx(s,{children:"/service-reports/new"})," et ",e.jsx(s,{children:"/billing"}),","," ",e.jsx(s,{children:"/maintenance-reports/new"})," et"," ",e.jsx(s,{children:"/settings"}),") : aucune n'est un détail ni une paire, donc chacune garde son ← et ne cache jamais la barre d'onglets sur une tablette large (",e.jsx(s,{children:"/service-reports/new"})," et ",e.jsx(s,{children:"/billing"})," le faisaient jusqu'à CHARGEOPS-607)."," ",e.jsx(s,{children:"back-target.test.ts"})," lit App.tsx et échoue sur une route littérale sous une racine de paire que la liste oublie."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Une vérification, une liste (CHARGEOPS-627)"})," — le test de la séparation s'écrit une seule fois,"," ",e.jsx(s,{children:"useSplitViewport()"})," (",e.jsx(s,{children:"hooks/use-split-viewport.ts"})," : une tablette d'au moins ",e.jsx(s,{children:"SPLIT_MIN_WIDTH"}),", 760 px, construite sur"," ",e.jsx(s,{children:"useViewport"})," et ",e.jsx(s,{children:"useMinWidth"})," pour qu'un test qui simule l'un ou l'autre la pilote encore). Les wrappers de route, la clé de frontière de page d'",e.jsx(s,{children:"AppLayout"})," et de ",e.jsx(s,{children:"PortalLayout"}),", le retrait de ",e.jsx(s,{children:"PortalLayout"})," et les deux chromes la lisent ; il y en avait dix copies, et une qui dérive, c'est ainsi que la liste se remontait (#636). Les paires aussi s'écrivent une seule fois, dans la table à clés"," ",e.jsx(s,{children:"SPLIT_PAIRS"})," (",e.jsx(s,{children:"list"}),", ",e.jsx(s,{children:"param"}),","," ",e.jsx(s,{children:"detail"}),", ",e.jsx(s,{children:"route"}),") :"," ",e.jsx(s,{children:"SPLIT_ROUTE_PATTERNS"})," et les racines de liste en dérivent, et les sept wrappers sortent d'une seule fabrique"," ",e.jsx(s,{children:"makeSplitRoute"})," qui y lit son motif"," ",e.jsx(s,{children:"useRoute"}),". App.tsx garde ses routes en littéraux (plusieurs gardes lisent ",e.jsx(s,{children:'path="…"'}),"), et"," ",e.jsx(s,{children:"detail/__tests__/split-one-source.test.tsx"})," exige que chacune égale la ",e.jsx(s,{children:"route"})," de sa paire, interdit un chemin littéral dans ",e.jsx(s,{children:"split-routes.tsx"})," et tout autre"," ",e.jsx(s,{children:"useMinWidth(760)"})," dans ",e.jsx(s,{children:"client/src"}),". Une nouvelle paire : une clé dans ",e.jsx(s,{children:"SPLIT_PAIRS"}),", un appel à ",e.jsx(s,{children:"makeSplitRoute"}),", la route dans App.tsx, et son wrapper dans la table ",e.jsx(s,{children:"wrapperPair"})," de"," ",e.jsx(s,{children:"split-one-source.test.tsx"})," (le test exige que ses clés soient exactement les wrappers routés dans App.tsx)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pas de pied de page dans le volet liste (CHARGEOPS-627)"})," — la vue partagée ne porte aucun chrome de page : le détail saute son pied de page en"," ",e.jsx(s,{children:"embedded"}),", ",e.jsx(s,{children:"PortalLayout"})," retire le sien pour les paires du portail, et la liste des billets saute désormais son ",e.jsx(s,{children:"<Footer fullBleed />"})," quand"," ",e.jsx(s,{children:"forcePhoneCards"})," est vrai. Ce pied de page fait"," ",e.jsx(s,{children:"w-screen"})," moins le rail (748 px à 820) : monté dans le volet de 320 px, il faisait défiler la liste de côté. C'était la seule des sept pages liste à rendre le sien. Les billets et les rapports d'entretien du bureau montent aussi ",e.jsx(s,{children:"SettingsSectionShell"}),", dont l'en-tête se met côte à côte passé ",e.jsx(s,{children:"sm"})," ; sa rangée d'actions (540 px pour les billets) ne tient dans le volet que parce que #644 (CHARGEOPS-612) laisse cet en-tête passer à la ligne sous ",e.jsx(s,{children:"lg"})," (",e.jsx(s,{children:"max-lg:flex-wrap"}),", un bloc titre en ",e.jsx(s,{children:"sm:max-lg:basis-64"}),", des actions en"," ",e.jsx(s,{children:"max-lg:min-w-0 max-lg:shrink"}),"). La vue partagée n'a pas besoin d'un en-tête à elle. Dans le harnais à 820 px, les sept volets liste ont"," ",e.jsx(s,{children:"scrollWidth"})," = ",e.jsx(s,{children:"clientWidth"})," (319)."," ",e.jsx(s,{children:"pages/__tests__/split-list-footer.test.tsx"})," monte la vraie page avec et sans ",e.jsx(s,{children:"forcePhoneCards"}),", vérifie que l'en-tête porte les mêmes classes dans les deux cas, et exige qu'aucun ",e.jsx(s,{children:"<Footer"})," des sept pages ne soit rendu sans la garde ",e.jsx(s,{children:"!forcePhoneCards"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le volet gauche force les fiches"})," — 320 px est trop étroit pour le tableau desktop, donc les deux pages liste reçoivent un nouveau prop"," ",e.jsx(s,{children:"forcePhoneCards?: boolean"})," et passent"," ",e.jsx(s,{children:"desktop={forcePhoneCards ? null : <Table>...</Table>}"})," ","à ",e.jsx(s,{children:"ResponsiveList"}),", qui traite désormais un"," ",e.jsx(s,{children:"desktop"})," à ",e.jsx(s,{children:"null"})," comme « toujours rendre les fiches », même hors téléphone. Le prop"," ",e.jsx(s,{children:"splitSelectedId?: string | null"})," marque la fiche actuellement ouverte : ",e.jsx(s,{children:"ResponsiveListCard"})," a un nouveau champ ",e.jsx(s,{children:"current?: boolean"})," (indépendant de la sélection groupée) qui ajoute l'anneau de surbrillance et"," ",e.jsx(s,{children:'aria-current="true"'}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le volet droit n'a pas son propre chrome"})," —"," ","les deux pages détail reçoivent un prop"," ",e.jsx(s,{children:"embedded?: boolean"})," : la page des billets saute son propre ",e.jsx(s,{children:"<Footer fullBleed />"})," (et l'annulation de marge qui va avec) ; la page des rapports, qui n'a pas de pied de page, laisse plutôt son corps 
27normalement centré (",e.jsx(s,{children:"max-w-[1200px]"}),") remplir toute la largeur du volet. La suppression du ← elle-même passe entièrement par"," ",e.jsx(s,{children:"backTarget"})," (ci-dessus), pas par ce prop."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La barre de sélection groupée suit la même convention"})," — la classe par défaut de"," ",e.jsx(s,{children:"TicketBulkBar"}),","," ",e.jsx(s,{children:"fixed left-[var(--rail-w)] right-0 … mx-auto w-fit"}),", atteint toute la largeur à droite du rail, chevauchant aussi le volet de détail de la séparation, pas seulement le volet liste de 320 px qu'elle trie. Une prop"," ",e.jsx(s,{children:"embedded?: boolean"}),", passée en"," ",e.jsxs(s,{children:["embedded=","{forcePhoneCards}"]})," depuis"," ",e.jsx(s,{children:"support-tickets.tsx"}),", la rend plutôt"," ",e.jsx(s,{children:"sticky bottom-0"})," à l'intérieur du conteneur de défilement propre au volet liste — la même idée de cantonnement au volet que la prop ",e.jsx(s,{children:"embedded"})," du volet détail ci-dessus. Les rapports de service n'ont pas de barre de sélection groupée. Le chemin non-embedded est inchangé."]})]}),e.jsxs(i,{children:["Tests : ",e.jsx(s,{children:"detail/__tests__/list-detail-layout.test.tsx"})," ","(les deux volets, une sélection qui ne réinitialise ni le défilement ni la recherche de la liste, l'état vide, le repli sous 760 px, desktop inchangé — via un petit",e.jsx(s,{children:"FakeSplitRoute"})," local qui reproduit le motif réel sans importer ",e.jsx(s,{children:"App.tsx"}),", dont ",e.jsx(s,{children:"lib/convex.ts"})," ","construit un vrai ",e.jsx(s,{children:"ConvexReactClient"})," au chargement du module), et dans ",e.jsx(s,{children:"back-target.test.ts"})," /"," ",e.jsx(s,{children:"responsive-list.test.tsx"})," / ",e.jsx(s,{children:"tablet-shell.test.tsx"})," ","pour ",e.jsx(s,{children:"isSplitActive"}),", ",e.jsx(s,{children:"current"})," et"," ",e.jsx(s,{children:"desktop=null"})," individuellement."]})]}),e.jsxs(t,{id:"assistant-surcouches",title:"14i. Surcouches de l'assistant ChargeOps AI",children:[e.jsxs(i,{children:["L'assistant (CHARGEOPS-379, ",e.jsx(s,{children:"client/src/components/assistant/"}),") ajoute deux couches fixes présentes sur toutes les pages, dans le coin en bas à droite que d'autres utilisaient déjà. Les deux sont rendues par ",e.jsx(s,{children:"createPortal"})," dans ",e.jsx(s,{children:"document.body"})," (un"," ",e.jsx(s,{children:"fixed"})," sous un ancêtre transformé ou filtré se place contre cet ancêtre). Guide fonctionnel :"," ",e.jsx(a,{children:"Assistant ChargeOps AI (interne)"}),", §11."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Le bouton"})," (",e.jsx(s,{children:"assistant-launcher.tsx"}),") — rond de 52 px, ",e.jsx(s,{children:"right-4"}),", ",e.jsx(s,{children:"z-[65]"})," : au-dessus du chrome de page (",e.jsx(s,{children:"z-50"}),") et de la fenêtre d'appel (",e.jsx(s,{children:"z-[60]"}),"), sous la sonnerie d'appel entrant et les menus de l'appel (",e.jsx(s,{children:"z-[70]"})," et plus) et sous les toasts. Son ",e.jsx(s,{children:"bottom"})," vient de"," ",e.jsx(s,{children:"aboveBottomBar()"})," (",e.jsx(s,{children:"hooks/use-bottom-bar-inset.ts"}),") : au-dessus de la barre d'actions fixe d'une page, de la barre d'onglets, de la zone sécurisée, et de la bannière d'installation PWA tant qu'elle est affichée. Monté, il publie la place qu'il prend dans"," ",e.jsx(s,{children:"--co-assistant-launcher-h"})," sur ",e.jsx(s,{children:"<html>"})," ","(52 px + 16 px d'écart, plus la place de la bannière d'installation quand elle l'a poussé) ; la variable revient à rien au démontage."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Qui lit cette variable"})," — la fenêtre d'appel réduite (",e.jsx(s,{children:"calls/call-window.tsx"})," :"," ",e.jsx(s,{children:'MINIMIZED_BOTTOM = aboveBottomBar("1rem", "var(--co-assistant-launcher-h, 0px)")'}),"), le bandeau « appareil plein » (§58), les boutons d'action flottants de Toutes les bornes sur téléphone (",e.jsx(s,{children:"bottom-[calc(var(--tabbar-h)+1rem+var(--co-assistant-launcher-h,0px))]"}),") et la barre d'actions groupées des billets (§14b). Chacun prend ",e.jsx(s,{children:"0px"})," quand l'assistant n'est pas monté."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le panneau"})," (",e.jsx(s,{children:"assistant-panel.tsx"}),") — à partir de ",e.jsx(s,{children:"sm"}),", une ",e.jsx(s,{children:"co-card is-floating"})," ","NON modale (",e.jsx(s,{children:'role="dialog" aria-modal="false"'}),", sans piège à focus) de 400 px, 720 px élargie, au-dessus du bouton ; sa hauteur se calcule depuis sa vraie position (",e.jsx(s,{children:"PANEL_BOTTOM"}),"). Ouverte, elle publie sa largeur plus un écart dans ",e.jsx(s,{children:"--co-assistant-panel-w"}),", et la fenêtre d'appel réduite se place à sa gauche (",e.jsx(s,{children:"MINIMIZED_RIGHT = calc(1rem + var(--co-assistant-panel-w, 0px))"}),"). Sous ",e.jsx(s,{children:"sm"}),", une ",e.jsx(s,{children:"co-card is-sheet"})," plein écran, modale, focus tenu à l'intérieur. Titre en"," ",e.jsx(s,{children:"h2"}),", ce qui est dedans (carte de proposition, historique) à partir de ",e.jsx(s,{children:"h3"}),"."]}),e.jsxs(r,{children:[e.jsxs(n,{children:[e.jsx(s,{children:".co-card.is-floating"})," /"," ",e.jsx(s,{children:".is-sheet"})]})," (",e.jsx(s,{children:"styles/theme-cleo-extras.css"}),") — la carte flottante garde une ombre relevée au repos ET au survol (une classe"," ",e.jsx(s,{children:"shadow-*"})," ne l'emporterait pas sur l'ombre de"," ",e.jsx(s,{children:".co-card"}),"), teintée par le triplet"," ",e.jsx(s,{children:"rgb(var(--brand-600) / a)"})," (",e.jsx(s,{children:"--sky-400"})," en sombre), et doit rester APRÈS les règles ",e.jsx(s,{children:"is-static"})," ; la feuille n'a ni rayon, ni filet, ni ombre. Le bandeau « appareil plein » porte aussi ",e.jsx(s,{children:"is-floating"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La règle des fenêtres de page"})," — le bouton et le panneau sont au-dessus d'une fenêtre modale de page (",e.jsx(s,{children:"z-50"}),"). Tant qu'une est ouverte (",e.jsx(s,{children:"useModalLayerOpen(OWN_LAYER_SELECTOR)"}),", qui surveille le ",e.jsx(s,{children:"pointer-events: none"})," que Radix pose sur"," ",e.jsx(s,{children:"<body>"}),"), le bouton se cache (monté, pour garder sa place publiée et y rendre le focus), le panneau descend à"," ",e.jsx(s,{children:"z-40"})," sous le fond de la fenêtre sans être démonté (une question à moitié tapée survit) et ",e.jsx(s,{children:"Ctrl+J"})," est laissé au navigateur. Les couches PROPRES à l'assistant (ses"," ",e.jsx(s,{children:"Select"}),", la confirmation de suppression, en"," ",e.jsx(s,{children:"z-[70]"}),") verrouillent ",e.jsx(s,{children:"<body>"})," de la même façon : elles portent ",e.jsx(s,{children:"data-assistant-layer"}),", et l'argument ",e.jsx(s,{children:"ignore"})," de"," ",e.jsx(s,{children:"useModalLayerOpen"})," les écarte, sinon choisir une valeur dans le panneau le ferait s'effacer devant lui-même."]}
27),e.jsx(r,{children:"Le bouton se cache aussi pendant qu'une fenêtre d'appel est ouverte et non réduite : il couvrirait ses commandes."})]})]}),e.jsxs(t,{id:"onglets-telephone",title:"14j. Onglets sur téléphone (S3.0b)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/tabs.tsx"})," (S3.0b, CHARGEOPS-568, spec S3 §4.1) est le composant Radix"," ",e.jsx(s,{children:"Tabs"})," partagé par toute l'application — pas seulement ",e.jsx(s,{children:"DetailLayout"})," (§14g). Sous 640 px,"," ",e.jsx(s,{children:"TabsList"})," et ",e.jsx(s,{children:"TabsTrigger"})," prennent la présentation de rangée d'onglets de S2 (les chaînes partagées de ",e.jsx(s,{children:"client/src/components/detail/tab-row-classes.ts"})," ","servent seulement de référence visuelle, pas d'import — pas de changement d'API) : c'est un pur changement de CSS via des classes ",e.jsx(s,{children:"max-sm:"})," Tailwind — aucune branche JS de viewport, aucun remontage, et rien ne change à partir de 640 px."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"La rangée"})," (",e.jsx(s,{children:"TabsList"}),") devient"," ",e.jsx(s,{children:"flex"})," pleine largeur, alignée au début, avec un défilement horizontal (",e.jsx(s,{children:"overflow-x-auto snap-x snap-mandatory"}),") et sa propre barre de défilement masquée (",e.jsx(s,{children:"[scrollbar-width:none]"}),"). Le fond et l'arrondi du bouton-groupe desktop disparaissent au profit d'un simple filet inférieur (",e.jsx(s,{children:"border-b"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Chaque onglet"})," (",e.jsx(s,{children:"TabsTrigger"}),") a une hauteur minimale de 44 px (",e.jsx(s,{children:"min-h-11"}),"), ne rétrécit pas (",e.jsx(s,{children:"shrink-0"}),") et s'aligne sur la grille de défilement (",e.jsx(s,{children:"snap-start"}),") — sa largeur vient de son contenu, pas d'un partage égal de la rangée. L'onglet actif porte un filet inférieur de 2 px de la couleur de marque (",e.jsx(s,{children:"data-[state=active]:border-primary"}),","," ",e.jsx(s,{children:"dark:data-[state=active]:border-brand-300"}),") au lieu du fond/ombre desktop."]}),e.jsxs(r,{children:[e.jsx(n,{children:"L'onglet actif se scrolle dans la vue."})," Un"," ",e.jsx(s,{children:"useLayoutEffect"})," pose un"," ",e.jsx(s,{children:"MutationObserver"})," sur l'attribut"," ",e.jsx(s,{children:"data-state"})," du bouton (même idée que"," ",e.jsx(s,{children:"useModalLayerOpen"}),", §9 : surveiller un seul attribut plutôt que de deviner quand React refait le rendu). Quand Radix bascule l'attribut à"," ",e.jsx(s,{children:'"active"'})," ET que ",e.jsx(s,{children:'matchMedia("(max-width: 639px)")'})," correspond, le bouton appelle lui-même"," ",e.jsxs(s,{children:["scrollIntoView(",'{ block: "nearest", inline: "nearest" }',")"]}),". Rien ne se produit sur desktop/tablette, où tous les onglets tiennent déjà dans la rangée. Le clavier reste celui de Radix (flèches, ",e.jsx(s,{children:"Home"}),"/",e.jsx(s,{children:"End"}),") — cet effet ne fait qu'accompagner visuellement une activation déjà décidée par Radix, qu'elle vienne d'un clic, d'une touche ou de l'activation automatique au focus."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Un site qui redéfinit son propre style d'onglet"})," ","peut entrer en conflit avec la rangée téléphone : un"," ",e.jsx(s,{children:"flex-1"})," sur ",e.jsx(s,{children:"TabsTrigger"})," (qui étire chaque onglet pour remplir la rangée) empêche tout débordement, donc tout défilement — deux appelants existants l'avaient (",e.jsx(s,{children:"pages/home.tsx"}),"'s"," ",e.jsx(s,{children:"HOME_TAB_TRIGGER_CLASS"})," et les trois onglets de"," ",e.jsx(s,{children:"charger-management/charger-edit-dialog.tsx"}),"), et portent maintenant un ",e.jsx(s,{children:"max-sm:flex-none"})," qui annule l'étirement seulement sous 640 px ; desktop/tablette gardent ",e.jsx(s,{children:"flex-1"})," intact. Un futur appelant avec un"," ",e.jsx(s,{children:"grid grid-cols-N"})," n'a rien à faire : la classe"," ",e.jsx(s,{children:"max-sm:flex"})," de ",e.jsx(s,{children:"TabsList"})," l'emporte déjà sous 640 px (les variantes d'écran Tailwind s'appliquent toujours après les classes sans variante, quel que soit l'ordre dans l'attribut"," ",e.jsx(s,{children:"class"}),")."]})]}),e.jsxs(i,{children:["Tests :"," ",e.jsx(s,{children:"components/ui/__tests__/tabs-phone.test.tsx"})," — l'onglet qui devient actif appelle"," ",e.jsx(s,{children:"scrollIntoView"})," quand la requête média téléphone correspond, ne l'appelle pas sinon, et une assertion littérale prouve que la partie non-",e.jsx(s,{children:"max-sm:"})," des chaînes de classes de ",e.jsx(s,{children:"TabsList"})," et"," ",e.jsx(s,{children:"TabsTrigger"})," est inchangée (desktop/tablette pixel pour pixel)."]})]}),e.jsxs(t,{id:"tableaux-telephone",title:"14k. Tableaux téléphone (Table phone)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/table.tsx"})," (S3.0a, CHARGEOPS-567, spec §4.1) — sous 640 px, chaque ligne de corps d'un ",e.jsx(s,{children:"<Table>"})," devient une carte"," ",e.jsx(s,{children:"co-card"})," avec ses champs nommés, sans branche JS de viewport : c'est du CSS pur sous"," ",e.jsx(s,{children:"@media (max-width: 639px)"})," dans"," ",e.jsx(s,{children:"theme-cleo-extras.css"}),", et rien ne change au-dessus de 640 px."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Deux modes"}),", via"," ",e.jsx(s,{children:'<Table phone="stack" | "scroll">'})," —"," ",e.jsx(s,{children:'"stack"'})," par défaut, qui rend"," ",e.jsx(s,{children:'data-phone="stack"'})," sur le"," ",e.jsx(s,{children:"<table>"})," ; l'opposé,"," ",e.jsx(s,{children:'"scroll"'}),", garde la grille et la fait défiler latéralement, sa première colonne restant"," ",e.jsx(s,{children:"sticky left-0"})," avec le fond de carte."]}),e.jsxs(r,{children:[e.jsx(n,{children:"L'étiquetage"})," (",e.jsx(s,{children:"table-phone-labels.ts"}),","," ",e.jsx(s,{children:"labelTableForPhone(table)"}),", pur DOM et idempotent) — lit le texte de la ligne d'en-tête (tenant compte de"," ",e.jsx(s,{children:"colSpan"}),") et pose ",e.jsx(s,{children:"data-label"})," sur chaque"," ",e.jsx(s,{children:"<td>"}),", plus"," ",e.jsx(s,{children:'data-cell="select" | "actions" | "title"'})," ","sur les cellules détectées automatiquement : une cellule dont le seul contenu interactif est une case à cocher devient"," ",e.jsx(s,{children:"select"}),", une cellule sans texte propre dont le seul contenu est un bouton, un lien ou un"," ",e.jsx(s,{children:"[role=menuitem]"})," devient ",e.jsx(s,{children:"actions"}),", et la première cellule qui n'est ni l'une ni l'autre devient ",e.jsx(s,{children:"title"}),". Un ",e.jsx(s,{children:"data-cell"})," déjà posé n'est jamais écrasé. Un effet dans ",e.jsx(s,{children:"Table"})," ne s'exécute que lorsque"," ",e.jsx(s,{children:'matchMedia("(max-width: 639px)")'})," ","correspond ; un ",e.jsx(s,{children:"MutationObserver"})," sur le corps du tableau réétiquette les lignes ajoutées ensuite, mais n'est construit et connecté que pendant que le média correspond, et déconnecté dès qu'il cesse de correspondre (jamais d'observateur au repos sur ordinateur ou tablette)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Dérogations explicites"}),", prioritaires sur la détection automatique :"," ",e.jsx(s,{children:'<TableCell data-cell="title|meta|actions|select|hide">'})," ","et ",e.jsx(s,{children:'<TableHead data-label="…">'})," pour un en-tête réduit à une icône."]}),e.jsxs(r,{children:["Sous 640 px, ",e.jsx(s,{children:"<thead>"})," reste dans l'arbre d'accessibilité (les noms de colonnes restent lus au lecteur d'écran) mais est masqué visuellement par la technique ",e.jsx(s,{children:"sr-only"})," (pas ",e.jsx(s,{children:"display: none"}),"). Une cellule ",e.jsx(s,{children:'data-cell="hide"'})," ou vide est masquée ; le pied de tableau (",e.jsx(s,{children:"<tfoot>"}
27),") reste un simple bloc, sans habillage carte."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Rôles d'accessibilité"})," ","(",e.jsx(s,{children:"applyPhoneRoles"})," / ",e.jsx(s,{children:"clearPhoneRoles"}),", correctif 1) — passer ",e.jsx(s,{children:"<thead>"}),"/",e.jsx(s,{children:"<tbody>"}),"/",e.jsx(s,{children:"<tr>"}),"/",e.jsx(s,{children:"<td>"})," en ",e.jsx(s,{children:"display: block"})," leur retire leurs rôles ARIA implicites. Tant que le média correspond, la même passe pose"," ",e.jsx(s,{children:'role="table"'})," sur ",e.jsx(s,{children:"<table>"}),","," ",e.jsx(s,{children:'role="rowgroup"'})," sur"," ",e.jsx(s,{children:"<thead>"}),"/",e.jsx(s,{children:"<tbody>"}),","," ",e.jsx(s,{children:'role="row"'})," sur ",e.jsx(s,{children:"<tr>"}),","," ",e.jsx(s,{children:'role="columnheader"'})," sur"," ",e.jsx(s,{children:"<th>"})," et ",e.jsx(s,{children:'role="cell"'})," sur"," ",e.jsx(s,{children:"<td>"})," — seulement là où l'auteur n'a pas déjà posé un rôle, chacun marqué"," ",e.jsx(s,{children:"data-phone-role"})," pour que"," ",e.jsx(s,{children:"clearPhoneRoles"})," retire exactement ce qu'elle a ajouté (et rien d'autre) dès que le média cesse de correspondre. Jamais sur ",e.jsx(s,{children:'phone="scroll"'}),", qui garde ses rôles natifs puisqu'il n'est jamais mis en ",e.jsx(s,{children:"display: block"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Dégradé de défilement"})," — en mode"," ",e.jsx(s,{children:'"scroll"'}),", un ",e.jsx(s,{children:"mask-image"})," sur le conteneur (pas sur le ",e.jsx(s,{children:"<table>"})," lui-même, pour que la première colonne ",e.jsx(s,{children:"sticky"})," reste pleinement opaque) estompe son bord droit pour indiquer qu'il reste du contenu à faire défiler."]}),e.jsx(r,{children:"Une ligne qui navigue au clic garde son gestionnaire : toute la carte est la cible, et elle fait au moins 44 px de haut."})]})]}),e.jsxs(t,{id:"sous-navs-telephone",title:"14l. Sous-navigations de section et titre de carte sur téléphone",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/section-sub-nav.tsx"})," rend la sous-navigation du support (Cas, Pannes), du centre de maintenance, de l'entrepôt, des RMA et des véhicules. Sous 1024 px (",e.jsx(s,{children:"useIsMobile()"}),"), elle rend la même rangée d'onglets collante que ",e.jsx(s,{children:"DetailLayout"})," ","(§14g) — les chaînes de"," ",e.jsx(s,{children:"client/src/components/detail/tab-row-classes.ts"})," ","importées par les deux — et rien autour (CHARGEOPS-633) : un seul conteneur ",e.jsx(s,{children:"sticky z-40 top-[var(--chrome-h-mobile)]"})," ","(",e.jsx(s,{children:'data-testid="<préfixe>-phone-row"'}),"), puis le"," ",e.jsx(s,{children:"<nav>"}),". Le fond blanc (",e.jsx(s,{children:"bg-background"}),", marine en sombre), le filet inférieur (",e.jsx(s,{children:"border-border"}),", le jeton Cléo : 10 % d'encre en clair, 10 % de blanc en sombre ; un ",e.jsx(s,{children:"border-b"})," nu prenait le gris-200 par défaut de Tailwind, une ligne pâle sur le fond marine), le retrait de 8 px et le défilement horizontal sont ceux de la rangée ; les onglets gardent leurs 44 px (",e.jsx(s,{children:"min-h-11"}),") et leur soulignement de 2 px. La rangée va d'un bord à l'autre de la zone de contenu : 0 à la largeur de l'écran sur téléphone, du bord du rail au bord droit sur tablette."]}),e.jsxs(i,{children:["Avant, la rangée restait dans la bande de bureau"," ",e.jsx(s,{children:".brand-page-subnav"})," (fond lavande, filets en haut et en bas) et dans la gouttière de page (",e.jsx(s,{children:"max-w-[var(--page-max)] mx-auto px-4 sm:px-6 lg:px-8"}),") : la rangée blanche flottait en retrait, avec des bandes lavande sur les côtés, au-dessus et au-dessous. À partir de 1024 px rien ne change : la bande, la gouttière et la barre à pastille sont les mêmes, balisage compris. Épinglé par"," ",e.jsx(s,{children:"components/__tests__/section-sub-nav-phone-row.test.tsx"})," ","(pas de bande ni de gouttière, classes de ",e.jsx(s,{children:"DetailLayout"})," ","sous 1024 px ; squelette de bureau figé au-dessus)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Titre de carte répété."})," Sous 1024 px, la barre du haut de la coquille téléphone et tablette affiche déjà le titre de la page (",e.jsx(s,{children:"shell/top-bar.tsx"})," lit"," ",e.jsx(s,{children:"usePageMeta()"}),"). ",e.jsx(s,{children:"SettingsSectionShell"})," ","compare donc son ",e.jsx(s,{children:"title"})," (quand c'est une chaîne) à celui de la méta de page, lu par ",e.jsx(s,{children:"useCurrentPageTitle()"})," ","(",e.jsx(s,{children:"hooks/page-meta-context.ts"}),") — indéfini hors d'un"," ",e.jsx(s,{children:"PageMetaProvider"}),", et alors rien ne change. Le contexte vit dans ce module à part parce qu'une quarantaine de tests remplacent ",e.jsx(s,{children:"use-page-meta.tsx"})," par un"," ",e.jsx(s,{children:"vi.mock"})," qui n'expose que"," ",e.jsx(s,{children:"useSetPageMeta"})," : la coquille ne l'importe donc pas. S'ils sont égaux, le texte du titre prend ",e.jsx(s,{children:"max-lg:sr-only"})," ","(le ",e.jsx(s,{children:"<h2>"})," reste dans le plan de la page, souvent comme seul titre de section), et la description, la pastille d'icône, le compteur et les actions restent : la description dit ce que la barre du haut ne dit pas. Sans icône, sans compteur, sans surtitre et sans description, c'est toute la rangée du titre qui passe en"," ",e.jsx(s,{children:"max-lg:sr-only"}),", pour ne pas laisser une rangée vide et son espacement au-dessus des actions. Automatique plutôt qu'une prop : une carte titrée autrement que sa page (les sections de la configuration, les onglets des RMA) garde son titre partout. Toutes les classes ajoutées sont en"," ",e.jsx(s,{children:"max-lg:"}),", donc rien ne change sur ordinateur. Le centre de maintenance, dont la tête de carte n'est pas un"," ",e.jsx(s,{children:"SettingsSectionShell"}),", pose le même"," ",e.jsx(s,{children:"max-lg:sr-only"})," sur son bloc de titre. Épinglé par"," ",e.jsx(s,{children:"configuration/__tests__/settings-section-shell-page-title.test.tsx"}),", monté sous le vrai ",e.jsx(s,{children:"PageMetaProvider"}),"."]})]}),e.jsxs(t,{id:"squelettes-haptique",title:"14m. Squelettes et retour haptique (S4.4b)",children:[e.jsxs(i,{children:[e.jsx(n,{children:"Chargement de route en squelette (C
27HARGEOPS-658)."})," ",e.jsx(s,{children:"PageLoading"})," (",e.jsx(s,{children:"client/src/components/ui/page-loading.tsx"}),"), le repli"," ",e.jsx(s,{children:"<Suspense>"})," des routes paresseuses d'App.tsx, le chargeur du transfert de connexion et l'atterrissage du scan, est un squelette qui a la forme d'une page, construit avec ",e.jsx(s,{children:"ui/skeleton.tsx"})," : une barre de titre, puis une"," ",e.jsx(s,{children:"co-card"})," de quatre lignes (une pastille ronde, deux lignes de texte, une étiquette). Il remplace le logo de marque qui tournait et le ",e.jsx(s,{children:"Loader2"}),", sur tous les formats, y compris l'ordinateur : un squelette réserve la forme de la page, donc rien ne saute quand le morceau arrive. C'est un seul ",e.jsx(s,{children:"SkeletonBlock"}),", donc un seul"," ",e.jsx(s,{children:'role="status"'})," avec"," ",e.jsx(s,{children:"aria-busy"})," ; son ",e.jsx(s,{children:"aria-label"})," est"," ",e.jsx(s,{children:'t("pageLoading.label")'})," (« Chargement de la page »), ou le ",e.jsx(s,{children:"message"})," de l'appelant, affiché aussi sous la carte. Il ne dessine aucun logo, donc la prop"," ",e.jsx(s,{children:"showLogo"})," a disparu : rien de propre au client ne peut s'afficher avant la connexion. Le reflet est porté par le conteneur et s'arrête sous"," ",e.jsx(s,{children:"prefers-reduced-motion"}),". Les chargements de la documentation (la vérification de l'audience et le morceau d'un chapitre) sont aussi des blocs"," ",e.jsx(s,{children:"SkeletonText"}),". Épinglé par"," ",e.jsx(s,{children:"ui/__tests__/page-loading.test.tsx"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Roues des pages."})," Un balayage des"," ",e.jsx(s,{children:"Loader2"})," de ",e.jsx(s,{children:"client/src/pages"})," a trouvé les chargements de page et de section déjà sur"," ",e.jsx(s,{children:"SectionLoading"})," ou des squelettes, sauf les deux de la documentation. Ce qui reste est voulu : une roue dans un bouton, à côté d'un contrôle, un statut « enregistrement… », une ligne « charger plus » ou une écriture en cours (les pages de désabonnement), où un squelette annoncerait du contenu alors qu'il s'agit d'une action."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Retour haptique."})," ",e.jsx(s,{children:"haptic(kind)"})," (",e.jsx(s,{children:"client/src/lib/haptics.ts"}),") associe"," ",e.jsx(s,{children:'"tap" | "success" | "warning" | "error"'})," ","à des motifs ",e.jsx(s,{children:"navigator.vibrate"})," : 10 ms,"," ",e.jsx(s,{children:"[12, 40, 12]"}),", 30 ms et ",e.jsx(s,{children:"[40, 60, 40]"}),". Il ne fait rien sans l'API Vibration (iOS, la plupart des navigateurs d'ordinateur) ni sous"," ",e.jsx(s,{children:"prefers-reduced-motion: reduce"}),", et il avale un appel refusé. Usages : ",e.jsx(s,{children:"tap"})," quand une action de balayage de ResponsiveList est entièrement révélée (il remplace l'appel direct ",e.jsx(s,{children:"navigator.vibrate?.(10)"}),") et quand tu relâches un tirer-pour-resynchroniser armé ; ",e.jsx(s,{children:"success"})," quand un rapport de service est enregistré (brouillon, sur l'appareil ou final), qu'un rapport de maintenance est finalisé et qu'une mise en service est terminée ; ",e.jsx(s,{children:"warning"})," ","dans l'",e.jsx(s,{children:"AlertDialogAction"})," partagé quand"," ",e.jsx(s,{children:'variant="destructive"'})," (avant le"," ",e.jsx(s,{children:"onClick"})," de l'appelant) ; ",e.jsx(s,{children:"error"})," dans"," ",e.jsx(s,{children:"FormMessage"})," quand un champ devient invalide, au plus une fois par 400 ms, pour qu'une soumission avec cinq champs invalides ne vibre qu'une fois. Épinglé par"," ",e.jsx(s,{children:"lib/__tests__/haptics.test.ts"})," et"," ",e.jsx(s,{children:"ui/__tests__/haptic-wiring.test.tsx"}),"."]})]}),e.jsxs(t,{id:"transitions-page-telephone",title:"14n. Transitions de page et barre du haut qui se réduit (S4.4a)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/lib/nav/view-transition.ts"})," (CHARGEOPS-657). Sur téléphone et tablette, un changement de route passe par"," ",e.jsx(s,{children:"document.startViewTransition()"})," quand le navigateur l'offre : sur ",e.jsx(n,{children:"téléphone"}),", un court glissement (vers l'avant depuis la droite, en arrière depuis la gauche : 16 % de translation plus un fondu, 200 ms, la courbe de décélération ",e.jsx(s,{children:"cubic-bezier(0.22, 1, 0.36, 1)"})," ","des entrées de KPI) ; sur ",e.jsx(n,{children:"tablette"}),", un fondu enchaîné de 180 ms (",e.jsx(s,{children:"cubic-bezier(0.4, 0, 0.2, 1)"}),"). Le sens est posé sur ",e.jsx(s,{children:"<html data-nav-dir>"})," (avec"," ",e.jsx(s,{children:"data-nav-viewport"}),") le temps de la transition, et"," ",e.jsx(s,{children:"index.css"})," choisit l'animation d'après ces deux attributs."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Un seul point de branchement pour les liens."})," ",e.jsx(s,{children:"App.tsx"})," enveloppe les routes dans"," ",e.jsx(s,{children:"<Router hook={useTransitionLocation}>"})," de wouter : chaque ",e.jsx(s,{children:"<Link>"})," et chaque ",e.jsx(s,{children:"navigate()"})," ","passent par ",e.jsx(s,{children:"transitionNavigate"}),". Le"," ",e.jsx(s,{children:"onClick"})," propre d'un lien s'exécute toujours avant et peut toujours annuler par"," ",e.jsx(s,{children:"preventDefault()"})," (l'appui long du menu ☰, une rangée de liste après un balayage) — c'est pourquoi ce n'est pas un intercepteur de clics global, qui aurait navigué avant eux et transformé en navigation interne des liens qui doivent atteindre le serveur. Un ",e.jsx(s,{children:"replace"})," (une redirection) et un changement de la seule chaîne de requête (un filtre) ne s'animent jamais."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le ←."})," Le gestionnaire de retour unique de la coquille (",e.jsx(s,{children:"goBack"})," de ",e.jsx(s,{children:"useBackAction"})," dans"," ",e.jsx(s,{children:"shell/use-back-action.ts"}),", utilisé par la barre du haut et le balayage depuis le bord) appelle"," ",e.jsx(s,{children:"navigateBack(back, navigate)"})," : sens"," ",e.jsx(s,{children:'"back"'}),", que ce soit ",e.jsx(s,{children:"history.back()"})," ","(la fonction de mise à jour attend le ",e.jsx(s,{children:"popstate"}),", 400 ms au plus) ou le lien vers l'ancêtre. Tout ce qui veut dire « retour » (le balayage depuis le bord de l'app installée) appelle ce même gestionnaire, donc glisse lui aussi depuis la gauche."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Les onglets."})," Les ",e.jsx(s,{children:"<Link>"})," de la barre d'onglets portent ",e.jsx(s,{children:'data-nav-dir="none"'})," ","(wouter passe les props du lien comme options de navigation) : passer d'une section à l'autre n'est pas aller « plus loin », donc rien ne glisse sur téléphone — c'est l'exception voulue. Une tablette fait son fondu quel que soit le sens, donc le rail n'a pas besoin de marqueur."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Quand c'est sauté"})," (",e.jsx(s,{children:"transitionsEnabled"}),") : sur ordinateur (aucune coquille montée —"," ",e.jsx(s,{children:"useTransitionShell"}),", appelé par la barre du haut, publie le viewport et remet « desktop » au démontage), sous"," ",e.jsx(s,{children:"prefers-reduced-motion"}),", dans la séparation liste/détail de la tablette (même paire des deux côtés : la liste reste, seul le volet change) et sans l'API. Sauté veut dire ",e.jsx(s,{children:"navigate(to)"})," direct, rien d'autre."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Le chrome reste en place."}
27)," La barre du haut (",e.jsx(s,{children:'data-nav-chrome="top"'}),") et la barre d'onglets (",e.jsx(s,{children:'data-nav-chrome="tabs"'}),") reçoivent"," ",e.jsx(s,{children:"view-transition-name: chrome-top"})," /"," ",e.jsx(s,{children:"chrome-tabs"})," seulement sous"," ",e.jsx(s,{children:"html[data-nav-dir]"}),", c'est-à-dire pendant une transition de navigation : le dévoilement circulaire du bouton de thème n'est pas touché. Le ",e.jsx(s,{children:"z-index"})," des règles du thème sur ",e.jsx(s,{children:"::view-transition-new(root)"})," ","est remis à ",e.jsx(s,{children:"auto"})," pendant ces transitions, sinon la nouvelle page passerait par-dessus la barre."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"Barre du haut qui se réduit (téléphone seulement)."})," ",e.jsx(s,{children:'useCompactHeader(24, viewport === "phone")'})," : un seul écouteur de défilement passif, lu une fois par"," ",e.jsx(s,{children:"requestAnimationFrame"}),". Au-delà de 24 px vers le bas, la barre passe en compact ; elle revient au premier défilement vers le haut de plus de 4 px, ou en haut de page. Le compact ne fait que des ",e.jsx(s,{children:"transform"})," : le fond marine est un calque à part (",e.jsx(s,{children:"<span aria-hidden>"}),") qui remonte de 12 px (",e.jsx(s,{children:"-translate-y-3"})," : 56 → 44 px peints), la rangée remonte de 6 px avec lui et le titre passe à ",e.jsx(s,{children:"scale-[0.85]"})," (",e.jsx(s,{children:"origin-left"}),"). La boîte du ",e.jsx(s,{children:"<header>"})," et ",e.jsx(s,{children:"--chrome-h-mobile"})," ","gardent leur pleine valeur : les rangées collantes (sous-navs, rangées d'onglets) ne sautent jamais, et les 12 px libérés laissent voir la page. En compact, le"," ",e.jsx(s,{children:"<header>"})," passe en"," ",e.jsx(s,{children:"pointer-events-none"})," (la rangée reste cliquable) pour ne pas avaler les touchers sur cette bande. Chaque classe de transition porte ",e.jsx(s,{children:"motion-reduce:transition-none"})," : sous mouvement réduit, le changement est instantané. La passation du titre héros de ",e.jsx(s,{children:"DetailLayout"})," ne change pas : c'est toujours ",e.jsx(s,{children:"meta.title"}),"."]}),e.jsxs(i,{children:["Épinglé par ",e.jsx(s,{children:"lib/nav/__tests__/view-transition.test.tsx"})," ","(cas de ",e.jsx(s,{children:"transitionsEnabled"}),", enveloppe ou navigation directe, sens sur ",e.jsx(s,{children:"data-nav-dir"}),", onglets, séparation, pas de transition imbriquée, bascule du compact,"," ",e.jsx(s,{children:"--chrome-h-mobile"})," intact) et par les tests de coquille (",e.jsx(s,{children:"phone-shell.test.tsx"})," : liens d'onglets marqués, compact par transform seulement ;"," ",e.jsx(s,{children:"tablet-shell.test.tsx"})," : la barre de tablette ne se réduit jamais)."]})]}),e.jsxs(t,{id:"site-info-mobile",title:"15. Site info mobile",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/mobile-site-info.tsx"})," — carte d'info site optimisée pour mobile :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Contenu structuré en ",e.jsx(n,{children:"rangées uniformes"})," séparées par des filets (",e.jsx(s,{children:"divide-y"}),") : chaque rangée = pastille d'icône (",e.jsx(s,{children:".co-chip"}),") + micro-étiquette majuscule + valeur + bouton d'action 36 × 36 px à droite."]}),e.jsxs(r,{children:["Rangées : adresse (action ",e.jsx(s,{children:"Navigation"})," → Google Maps via ",e.jsx(s,{children:"window.open(...maps...)"}),"), téléphone (action"," ",e.jsx(s,{children:"PhoneOutgoing"})," → ",e.jsx(s,{children:"openTeamsCall()"})," qui lance Microsoft Teams), organisation (badge éditable inline), contacts (monogramme d'initiales, nom/rôle, actions courriel + appel). Logo de plateforme OCPP (OCPP/ChargeLab/Cleo/InControl/Synop) dans l'en-tête."]}),e.jsxs(r,{children:["Édition de l'organisation : ",e.jsx(s,{children:"OrganizationSelect"})," + boutons check/cross —"," ",e.jsx(s,{children:"useMutation(api.sites.mutations.update)"}),"."]}),e.jsxs(r,{children:["Apparition en cascade des rangées via"," ",e.jsx(s,{children:"tailwindcss-animate"})," (",e.jsx(s,{children:"animate-in"})," +"," ",e.jsx(s,{children:"animationDelay"})," par index,"," ",e.jsx(s,{children:"motion-reduce:animate-none"}),")."]})]})]}),e.jsxs(t,{id:"site-selector-mobile",title:"16. Site selector mobile",children:[e.jsxs(i,{children:["Sous 1024 px, l'accueil rend"," ",e.jsx(s,{children:"client/src/components/home/mobile-site-picker.tsx"})," ","(CHARGEOPS-634) : le même ",e.jsx(s,{children:"Command"})," cmdk que l'en-tête desktop (recherche + ",e.jsx(s,{children:"SiteCommandItems"}),"), ouvert dans une feuille (",e.jsx(s,{children:'Dialog mobile="sheet"'}),","," ",e.jsxs(s,{children:["confirmDiscard=","{false}"]})," — une recherche n'est pas une saisie à protéger) sur téléphone, et dans un"," ",e.jsx(s,{children:"Popover"})," sous son déclencheur sur tablette. Rangées de 44 px (",e.jsx(s,{children:"[&_[cmdk-item]]:min-h-11"}),") et champ en 16 px sur téléphone (iOS zoome sur un champ plus petit). Focus à l'ouverture : la tablette place le curseur dans la recherche (comportement Radix, comme le desktop) ; le téléphone donne le focus à la liste, pas au champ — le clavier couvrirait la moitié de la feuille, et on choisit surtout en faisant défiler. Le bouton « Changer de site » du bandeau collant ouvre le même sélecteur (état ",e.jsx(s,{children:"open"})," contrôlé par"," ",e.jsx(s,{children:"home.tsx"}),")."]}),e.jsxs(i,{children:[e.jsxs(n,{children:[e.jsx(s,{children:"orgOverviewRows"})," : les organisations au doigt et au clavier."]})," ","Avec ",e.jsx(s,{children:"onOrgSelect"}),", cette option de"," ",e.jsx(s,{children:"SiteCommandItems"})," ajoute sous chaque en-tête d'organisation une vraie rangée ",e.jsx(s,{children:"CommandItem"})," ","« → Vue d'ensemble de l'organisation » (",e.jsx(s,{children:'data-testid="org-overview-row-<orgId>"'}),") qui appelle ",e.jsx(s,{children:"onOrgSelect"}),". Elle reste dans les résultats tant que la recherche touche le nom de l'organisation (sa valeur et ses ",e.jsx(s,{children:"keywords"})," portent ce nom, donc le filtre de cmdk suit notre score), et elle se classe ",e.jsx(n,{children:"avec son groupe"})," ",": l'organisation est notée comme un site qui porterait son nom (",e.jsx(s,{children:'siteMatchScore(org.n
27ame, "", q)'}),"), ce score fait remonter le groupe, et la rangée se place après les seuls sites qui correspondent mieux que lui. Seul le sélecteur mobile de l'accueil la passe : le popover de l'en-tête desktop n'en a pas et ne change pas."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Hauteur, défilement et droits."})," La feuille du téléphone a une hauteur fixe (",e.jsx(s,{children:"h-[85dvh]"})," sur"," ",e.jsx(s,{children:"DialogContent"}),", que ",e.jsx(s,{children:"stripHeightScrollClasses"})," ","laisse passer puisqu'il ne retire que les ",e.jsx(s,{children:"max-h-*"}),") : le titre et le ",e.jsx(s,{children:"Command"})," sont dans une colonne flex"," ",e.jsx(s,{children:"h-full min-h-0"}),", le ",e.jsx(s,{children:"Command"})," prend"," ",e.jsx(s,{children:"flex-1 min-h-0"})," et la liste ",e.jsx(s,{children:"flex-1 max-h-[none]"})," ","(la valeur arbitraire, parce que twMerge ne dédoublonne pas"," ",e.jsx(s,{children:"max-h-none"})," avec le ",e.jsx(s,{children:"max-h-[300px]"})," de"," ",e.jsx(s,{children:"CommandList"}),"). Sans ça, la feuille suivait la hauteur de son contenu et le champ de recherche descendait à chaque lettre. C'est bien ",e.jsx(s,{children:"h-"})," et pas ",e.jsx(s,{children:"min-h-"})," : quand le clavier s'ouvre, vaul réduit la feuille par un ",e.jsx(s,{children:"height"})," ","en ligne, qu'un ",e.jsx(s,{children:"min-height"})," bloquerait. Sur tablette, le bandeau « Changer de site » (",e.jsx(s,{children:"md:hidden"}),") reste visible de 640 à 767 px, alors que le déclencheur du popover est en haut du contenu : une fois la page défilée, Radix placerait le popover au-dessus de l'écran. À l'ouverture, un"," ",e.jsx(s,{children:"useLayoutEffect"})," appelle donc"," ",e.jsxs(s,{children:["scrollIntoView(","{",' block: "nearest", behavior: "instant" ',"}",")"]})," ","sur le déclencheur, dont le"," ",e.jsx(s,{children:"scroll-mt-[calc(var(--chrome-h-mobile)+3rem)]"})," le garde sous l'en-tête fixe et le bandeau ; c'est sans effet quand on l'ouvre depuis le déclencheur lui-même. On a gardé le popover plutôt qu'ouvrir une feuille depuis le bandeau : une seule présentation par largeur, et ",e.jsx(s,{children:'mobile="sheet"'})," ","n'agit pas au-dessus de 640 px (ce serait une modale centrée). La liste du popover est plafonnée par"," ",e.jsx(s,{children:"--radix-popover-content-available-height"})," (moins le champ, 480 px au plus). Les rangées d'aperçu et les en-têtes cliquables n'existent que si ",e.jsx(s,{children:"can.viewOrganizationPage"})," ","(même règle qu'",e.jsx(s,{children:"OrganizationLink"}),"), pour les deux sélecteurs de l'accueil. Le déclencheur annonce"," ",e.jsx(s,{children:'aria-haspopup="dialog"'})," dans les deux modes, et le ",e.jsx(s,{children:"Command"})," de la feuille porte"," ",e.jsx(s,{children:"aria-label"})," « Sélectionner un site »."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Un seul jeu de rangées pour tous les sélecteurs de site."})," ",e.jsx(s,{children:"client/src/components/site-combobox.tsx"})," exporte trois choses :"," ",e.jsx(s,{children:"groupSitesByOrganization"})," (le regroupement pur),"," ",e.jsx(s,{children:"SiteCommandItems"})," (les rangées cmdk — en-tête d'organisation avec pastille, compteur et filet teinté, tri alphabétique, bloc « Non assigné », classement des résultats par ",e.jsx(s,{children:"siteMatchScore"}),") et"," ",e.jsx(s,{children:"SiteCombobox"})," (le trigger + popover clés en main). L'en-tête d'accueil monte ",e.jsx(s,{children:"SiteCommandItems"})," dans son propre popover ; les pages de filtre montent ",e.jsx(s,{children:"SiteCombobox"}),", qui rend exactement les mêmes rangées."]}),e.jsxs(i,{children:["Le groupement est ",e.jsx(n,{children:"piloté par les données, pas par une option"})," ",": ",e.jsx(s,{children:"SiteCombobox"})," ne s'abonne à"," ",e.jsx(s,{children:"api.organizations.queries.list"})," que si au moins un site passé porte un ",e.jsx(s,{children:"organizationId"}),", et ",e.jsx(s,{children:"groupSitesByOrganization"})," ","renvoie ",e.jsx(s,{children:"null"})," — donc liste plate — quand rien ne se rattache à une organisation vivante. Conséquence pratique : un appelant qui passe une forme allégée ",e.jsx(s,{children:"{ id, name }"})," obtient une liste plate"," ",e.jsx(n,{children:"sans erreur ni avertissement"}),". C'est ce qui distinguait les filtres de site du Centre de maintenance, des Rapports d'entretien, des Garanties prolongées et des deux onglets RMA du sélecteur de l'accueil ; chacun fournit désormais ",e.jsx(s,{children:"organizationId"})," dans ses options."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Un en-tête d'organisation navigue ou sélectionne"}),", jamais les deux. ",e.jsx(s,{children:"onOrgSelect"})," seul = navigation (",e.jsx(s,{children:"/organizations/:orgId"}),") : l'en-tête porte une flèche"," ",e.jsx(s,{children:"ArrowUpRight"})," au survol. Ajouter"," ",e.jsx(s,{children:"orgValue"})," (l'id sélectionné, ",e.jsx(s,{children:'""'})," ","quand rien ne l'est) bascule le composant en"," ",e.jsx(n,{children:"mode sélection"})," : la flèche cède la place à une coche sur l'organisation active, son en-tête se teinte, le trigger affiche le nom de l'organisation avec sa pastille, et la rangée « Tous les sites » perd sa coche — sinon elle prétendrait être le choix actif pendant qu'une organisation filtre la liste. Le discriminant est ",e.jsx(s,{children:"orgValue !== undefined"}),", donc les ~15 appelants qui naviguent ne changent pas de rendu. C'est le filtre de ",e.jsx(a,{children:"/support"})," qui l'utilise : site et organisation sont deux grains d'",e.jsx(n,{children:"un seul"})," contrôle, l'un remplaçant l'autre (voir ",e.jsx(s,{children:"filterByOrg"})," pour l'infobulle). Limite héritée : cmdk rend ses en-têtes de groupe dans un conteneur ",e.jsx(s,{children:"aria-hidden"}),", donc l'en-tête reste un bouton ",e.jsxs(s,{children:["tabIndex=",-1]})," — une affordance au pointeur."]})]}),e.jsxs(t,{id:"tables-responsives",title:"17. Tables responsives — passage en cartes",children:[e.jsxs(i,{children:["Tables (utilisant ",e.jsx(s,{children:"@/components/ui/table"}),") sont remplacées par des ",e.jsx(n,{children:"listes de cartes"})," sur mobile dans :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["All Chargers (",e.jsx(s,{children:"client/src/pages/all-chargers/"}),")."]}),e.jsx(r,{children:"RMA, Warehouse, User Management."})]}),e.jsxs(i,{children:["Le pattern :"," ",e.jsx(s,{children:"{isMobile ? <CardList /> : <Table />}"}),"."]})]}),e.jsx(t,{id:"optimisations-touch",title:"18. Optimisations touch",children:e.jsxs(l,{children:[e.jsxs(r,{children:["Toute cible tactile fait au moins 44 × 44 px sous un pointeur imprécis, et ce sont les composants partagés qui portent ce minimum (CHARGEOPS-506 et CHARGEOPS-605 ci-dessous) : une page ne dimensionne pas ses boutons pour le toucher. Les tailles au repos (souris) ne changent pas : un bouton à icône garde son dessin (",e.jsx(s,{children:"h-9 w-9 p-0"}),", 36 × 36 px, ou plus petit) et ne passe à 44 qu'au toucher ; les actions principales utilisent"," ",e.jsx(s,{children:"h-14 px-4"}),"."]}),e.jsxs(r,{children:["Tap highlight neutralisé :"," ",e.jsx(s,{children:"-webkit-tap-highlight-color: transparent"})," (sur"," ",e.jsx(s,{children:".btn-3d"}),")."]}),e.jsxs(r,{children:[e.jsx(s,{children:"cursor-pointer"})," explicite sur les zones tactiles non-buttons."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Standards de formulaire (CHARGEOPS-506)"})," :"," ",e.jsx(s,{children:"Input"}),", ",e.jsx(s,{children:"SelectTrigger"})," et les tailles"," ","par défaut/",e.jsx(s,{children:"sm"})," de ",e.jsx(s,{children:"Button"})," portent"," ",e.jsx(s,{children:"[@media(pointer:coarse)]:min-h-11"})," — une hauteur minimale de 44 px sur tout pointeur imprécis (« coarse »), y compris un portable à écran tactile en largeur bureau, pas seulement sous un point de rupture. Les champs de texte (",e.jsx(s,{children:"Input"}),", ",e.jsx(s,{children:"SelectTrigger"}),","," ",e.jsx(s,{children:"Textarea"}),") portent"," ",e.jsx(s,{children:"text-base md:text-sm"})," pour qu'un champ ne soit jamais plus petit que 16 px sur téléphone — la taille qui empêche le zoom automatique au focus de Safari iOS. Sur téléphone, ",e.jsx(s,{children:"DatePicker"})," affiche un"," ",e.jsx(s,{children:'<input type="date">'})," natif au lieu du Popover+Calendar, pour que la saisie de date utilise le sélecteur du système ; tablette et bureau gardent le Popover+Calendar, et le contrat d'entrée/sortie"," ",e.jsx(s,{children:"Date"})," reste identique pour les appelants."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Cibles tactiles partagées (CHARGEOPS-605)"})," : la règle des 44×44 vit dans les composants partagés, pas dans chaque page. Sous un pointeur imprécis (à toute largeur), les tailles"," ",e.jsx(s,{children:"xs"}),", ",e.jsx(s,{children:"icon"})," et ",e.jsx(s,{children:"icon-xs"})," de"," ",e.jsx(s,{children:"Button"})," portent ",e.jsx(s,{children:"min-h-11 min-w-11"}),", et une entrée ",e.jsx(s,{children:"compoundVariants"})," ajoute"," ",e.jsx(s,{children:"min-w-11"})," aux tailles ",e.jsx(s,{children:"default"}),"/",e.jsx(s,{children:"sm"})," de toutes les variantes sauf ",e.jsx(s,{children:"link"})," ","(un ",e.jsx(s,{children:'size="sm" className="h-6 w-6 p-0"'})," à icône seule fait maintenant 44 de large en plus de 44 de haut)."," ",e.jsx(s,{children:"min-*"})," est une propriété CSS à part et un groupe twMerge à part : le ",e.jsx(s,{children:"h-6 w-6"})," de l'appelant et le 32×32 fixe de ",e.jsx(s,{children:".btn-icon.btn-xs"})," (index.css) restent la taille au repos, et le minimum ne gagne qu'au toucher, sans"," ",e.jsx(s,{children:"!important"}),". Les segments d'",e.jsx(s,{children:"ActionToolbar"})," reçoivent le même minimum ; au toucher, le conteneur perd sa hauteur fixe et, sous 640 px, passe à la ligne au lieu d'écraser les segments à 26 px. Ce retour à la ligne dépend lui aussi du pointeur imprécis : à la souris, la bande garde sa hauteur fixe et son"," ",e.jsx(s,{children:"overflow-hidden"})," sur une seule ligne, où une deuxième ligne serait coupée. ",e.jsx(s,{children:"TabsTrigger"})," reçoit"," ",e.jsx(s,{children:"min-h-11"})," (et ",e.jsx(s,{children:"TabsList"})," ",e.jsx(s,{children:"h-auto"}),") au toucher à toute largeur, en plus de la rangée d'onglets téléphone. La barre d'onglets de la page de site garde sa liste en ",e.jsx(s,{children:"h-full"})," à partir de 640 px au toucher (",e.jsx(s,{children:"sm:[@media(pointer:coarse)]:h-full"}),") : les onglets de 44 px se centrent alors sur la zone de contenu de 40 px de la barre, pile dans l'indicateur coulissant (",e.jsx(s,{children:"sm:top-2 sm:bottom-2"}),"), là où ",e.jsx(s,{children:"h-auto"})," les faisait descendre de 6 px sous lui. Les lignes ",e.jsx(s,{children:"Item"}),","," ",e.jsx(s,{children:"CheckboxItem"}),", ",e.jsx(s,{children:"RadioItem"})," et"," ",e.jsx(s,{children:"SubTrigger"})," de ",e.jsx(s,{children:"DropdownMenu"})," font 44 de haut, et ",e.jsx(s,{children:"SubContent"})," défile dans la hauteur disponible comme ",e.jsx(s,{children:"Content"}),". Les flèches de mois de"," ",e.jsx(s,{children:"Calendar"})," sont des Button ",e.jsx(s,{children:"icon-xs"}),", elles suivent ; ses cellules de jour ont un plancher de 44 px pour que sept d'entre elles tiennent le bouton de jour de 44 px."," ",e.jsx(s,{children:"Checkbox"})," garde sa case de 16 px et reçoit un"," ",e.jsx(s,{children:"::before"})," invisible qui déborde de 15 px de chaque côté de sa zone intérieure (une zone de 44×44 ; 14 px avant CHARGEOPS-611, ce qui laissait 42×42 dans la bordure de 1 px). Ce pseudo-élément est positionné, donc il se peint par-dessus le texte voisin : dans une liste serrée d'options avec libellé, toucher un libellé cochait la case de la rangée d'à côté. Il disparaît donc partout où un libellé prend déjà le toucher : dans un ",e.jsx(s,{children:"<label>"}),", devant un ",e.jsx(s,{children:"<Label htmlFor>"})," frère (",e.jsx(s,{children:":has(~label)"}),", qui saute l'input caché que Radix insère dans un formulaire), et avec"," ",e.jsx(s,{children:"hitArea={false}"}
27)," quand le libellé est dans un élément frère que le CSS ne voit pas. Dans une cellule d'en-tête de tableau, il s'arrête au bas de la case. Les rangées trop basses pour tenir deux zones de 44 px sans chevauchement passent plutôt à 44 au toucher (les tableaux de réapprovisionnement, d'import de contrats et de couverture de l'aide à la vente, la liste de champs de la synchronisation de modèles). La ",e.jsx(s,{children:"ConnectivityPill"})," de l'en-tête garde son dessin de 24 px avec un"," ",e.jsx(s,{children:"::before"})," de 10 px au-dessus et au-dessous. Sous 1024 px (640 px avant CHARGEOPS-611), les libellés de"," ",e.jsx(s,{children:"CleoStatTile"})," rétrécissent et se coupent avec un trait d'union au lieu de passer sous le bord de la carte. Chaque ajout est derrière"," ",e.jsx(s,{children:"[@media(pointer:coarse)]:"})," (ou ",e.jsx(s,{children:"max-sm:"})," ","pour la mise en page) : à la souris, le rendu est identique. Les appels qui avaient ajouté ces classes à la main (#615, #622, #629) sont des doublons sans effet : twMerge les fusionne et"," ",e.jsx(s,{children:"min-*"})," est idempotent. Une nouvelle page n'a plus à les ajouter. Épinglé par"," ",e.jsx(s,{children:"ui/__tests__/coarse-touch-targets.test.tsx"})," (chaque variante × taille : les classes tactiles sont là, les classes souris sont inchangées ; les suppressions et l'option de retrait de la case à cocher, le retour à la ligne de la barre d'actions réservé au toucher, le défilement du sous-menu),"," ",e.jsx(s,{children:"pages/__tests__/home-tab-bar-touch.test.tsx"})," (la géométrie de la barre d'onglets) et"," ",e.jsx(s,{children:"offline/__tests__/connectivity-pill-touch.test.tsx"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Rangées coupées et cibles propres aux pages (CHARGEOPS-612)"})," : l'audit final au téléphone a trouvé des rangées d'actions et de filtres plus larges que leur carte, dont le bord"," ",e.jsx(s,{children:"overflow-hidden"})," rendait des boutons inatteignables. Elles passent à la ligne sous 1024 px (",e.jsx(s,{children:"max-lg:flex-wrap"})," ","ou ",e.jsx(s,{children:"max-sm:flex-wrap"}),") ; la tête de"," ",e.jsx(s,{children:"SettingsSectionShell"})," laisse en plus ses actions rétrécir (",e.jsx(s,{children:"max-lg:min-w-0 max-lg:shrink"}),"), ce qui règle d'un coup RMA, rapports d'entretien et contrats ; son bloc de titre part d'une base de 16 rem"," ","(",e.jsx(s,{children:"sm:max-lg:basis-64 sm:max-lg:grow"}),"), si bien que la ligne ne casse que quand 16 rem de titre et les actions ne tiennent pas : une longue description seule ne fait plus descendre les actions (gestion des utilisateurs, configuration). Les petites cibles propres à une page prennent"," ",e.jsx(s,{children:"[@media(pointer:coarse)]:min-h-11"})," ; les blocs de la réglette de Ma journée gardent leurs 26 px et reçoivent un"," ",e.jsx(s,{children:"::before"})," invisible de 44 px (tous mènent au même endroit, un chevauchement est sans effet), et la barre de la semaine défile de côté sous 640 px avec des jours de 44 px. Au bureau, seules les classes de la tête de la liste des messages changent (",e.jsx(s,{children:"flex-wrap"}),", ",e.jsx(s,{children:"ml-auto"}),"), un changement voulu : dans sa colonne de 320 px, « Nouvelle conversation » était coupé de 19 px en français et « New conversation » de 8,5 px en anglais (neuf conversations). La tête ne passe à la ligne que si ses actions ne tiennent pas ; sinon rien ne bouge. Épinglé par ",e.jsx(s,{children:"__tests__/phone-clipped-rows.test.ts"}),", qui vérifie aussi qu'une fois les classes conditionnelles retirées, chaque liste de classes est celle d'avant."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Cibles tactiles partagées, deuxième passe (CHARGEOPS-611)"})," ",": les contrôles que CHARGEOPS-605 n'avait pas touchés, et la règle des 44×44 vaut aussi aux largeurs de tablette (700/820 px au toucher, tableaux de bureau compris). Un ",e.jsx(s,{children:"::before"})," ","en position absolue se place contre la zone intérieure, dans la bordure : ses décalages comptent donc aussi la bordure."," ",e.jsx(s,{children:"Switch"})," garde sa piste de 44×24 et reprend le geste de la case à cocher : un ",e.jsx(s,{children:"::before"})," invisible de 10 px au-dessus et au-dessous (décalages de 2 px et 12 px au-delà de sa bordure de 2 px ; une zone de 44×44), retiré dans un"," ",e.jsx(s,{children:"<label>"})," ou devant un libellé frère, avec l'option ",e.jsx(s,{children:"hitArea"})," pour s'en passer. Les segments de ",e.jsx(s,{children:"ViewModeToggle"})," se touchent (0 px d'écart), donc ils grandissent eux-mêmes :"," ",e.jsx(s,{children:"min-h-11 min-w-11 justify-center"})," au toucher. Le déclencheur du popover de ",e.jsx(s,{children:"DatePicker"})," (tablette et bureau) reçoit le même ",e.jsx(s,{children:"min-h-11"})," tactile qu'",e.jsx(s,{children:"Input"})," et ",e.jsx(s,{children:"Select"}),"."," ",e.jsx(s,{children:"SiteLink"}),", ",e.jsx(s,{children:"OrganizationLink"}),","," ",e.jsx(s,{children:"ChargerLink"})," et ",e.jsx(s,{children:"OrganizationBadgeLink"})," ","(",e.jsx(s,{children:"entity-links.tsx"}),") sont des noms de 16 à 28 px dans du texte courant, des lignes de fiche et des cellules de tableau : la boîte du lien ne peut pas grandir sans écarter ses lignes. Un"," ",e.jsx(s,{children:"::before"})," invisible couvre donc la largeur du nom et"," ",e.jsx(s,{children:"calc(50% - 22px)"})," au-dessus et au-dessous de son milieu, soit 44 px quelle que soit la taille du texte. La règle que gardent toutes les autres formes : une zone ne couvre jamais un autre lien, car un pseudo-élément positionné se peint par-dessus toute boîte non positionnée et toute boîte positionnée plus tôt, et leur prend leurs touchers. Un nom imprimé"," ",e.jsx(n,{children:"sous"})," un autre garde son propre bord haut (",e.jsx(s,{children:"top: 0"}),") et la portée vers le bas de la forme centrée, jamais plus : d'office quand il suit un lien frère dans une colonne (",e.jsx(s,{children:".flex-col > a ~ .x"})," : support, rapports de service, RMA, centre de maintenance), et avec"," ",e.jsx(s,{children:'hitArea="below"'})," (rendu en"," ",e.jsx(s,{children:'data-touch-area="below"'}),") quand le nom au-dessus est dans un autre conteneur, qu'aucun sélecteur de frère ne voit : le tableau des rapports d'entretien (le site dans son propre ",e.jsx(s,{children:"span.truncate"}),"), le tableau par site des indicateurs opérationnels et la bulle de la carte des modems. Mesuré avant la correction, un toucher sur le bas du nom de site y ouvrait l'organisation. Des frères côte à côte gardent la zone centrée (elle ne déborde jamais sur le côté). Dans la dernière rangée d'un tableau (",e.jsx(s,{children:"tr:last-child"}),"), la zone s'arrête au bas du lien : le badge d'organisation descendait de 44 px et finissait 11 px sous le tableau, si bien que l'enveloppe ",e.jsx(s,{children:"overflow-x-auto"})," de"," ",e.jsx(s,{children:"/service-reports"})," défilait de 11 px à la verticale au toucher. La zone d'un nom du bas ne fait donc pas 44 px de haut : empilé ou marqué ",e.jsx(s,{children:"below"}),", il reçoit environ h/2 + 22 px (30 à 32 px pour un nom de 16 à 20 px), et dans la dernière rangée d'un tableau seulement sa propre boîte, environ 20 px. Un lien en ",e.jsx(s,{children:"truncate"})," coupe son propre pseudo-élément : il prend plutôt 14 px de marge intérieure verticale et la même marge négative, et les règles « empilé » et « dernière rangée » en retirent le côté concerné. Les listes denses ou qui passent à la ligne relèvent de l'appelant : ",e.jsxs(s,{children:["hitArea=","{false}"]})," retire le traitement tactile, ou les rangées prennent un pas de 44 px au toucher. La légende des organisations de la carte des clients (rangées de 20 px, où la zone de chaque nom couvrait ses voisins) prend un ",e.jsx(s,{children:"min-h-11"})," tactile par rangée ; les lignes d'identité du billet, du rapport d'entretien et de la borne écartent leurs lignes repliées de 12 px au toucher (la portée d'un nom de 20 px), la rangée de pastilles de la carte RMA de 10 px, et les puces de sites de la gestion des utilisateurs de 22 px."," ",e.jsx(s,{children:"user-management.tsx"})," ne porte plus sa propre copie de la marge des liens tronqués. Un nom placé dans un ancêtre qui coupe (le"," ",e.jsx(s,{children:".co-spec dd"}
27)," des plaques d'entretien) ne garde que ce que cet ancêtre montre. ",e.jsx(s,{children:"entityLinkTextClass"}),", exporté, ne change pas. La barre d'outils de l'éditeur du rapport de service (l'éditeur TipTap, qui a gardé les noms de classe ",e.jsx(s,{children:".rdw-option"}),") donne 44×44 à chaque outil dans un bloc ",e.jsx(s,{children:"@media (pointer: coarse)"})," de sa propre feuille de style, et la barre passe à la ligne. Le bouton qui replie un groupe de rapports d'entretien garde son carré de 26 px avec un ",e.jsx(s,{children:"::before"})," de 9 px (dans l'écart de 10 px du bandeau), comme les actions du bandeau des plaques le faisaient déjà avec 6 px. Ces deux actions se touchent (la gouttière du bandeau n'a aucune marge), donc là où elles se rencontrent aucune zone ne déborde sur l'autre : celle de l'aperçu rapide s'arrête à son bord droit, celle de la suppression à son bord gauche (la suppression se peint après, et ses 6 px prenaient les touchers sur la droite de l'aperçu). Leurs côtés extérieurs vont donc à 12 px au lieu de 6 (à gauche de l'aperçu il n'y a que la date de visite en texte simple, à droite de la suppression que le bord du bandeau) : chacune refait 44×44. Le déclencheur de"," ",e.jsx(s,{children:"ChargerMultiSelect"})," reçoit le ",e.jsx(s,{children:"min-h-11"})," ","tactile ; le bouton de 16 px qui retire une puce reçoit un"," ",e.jsx(s,{children:"::before"})," de 14 px. Cette zone dépasse la puce de 22 px de 11 px, et deux rangées s'avancent l'une vers l'autre : les rangées de puces s'écartent donc de 22 px au toucher, et les zones d'une rangée finissent exactement où commencent celles de la suivante. Sous 1024 px, le retour à la ligne des libellés de ",e.jsx(s,{children:"CleoStatTile"})," passe en"," ",e.jsx(s,{children:"max-lg:"})," (ils étaient coupés à 820 px sur"," ",e.jsx(s,{children:"/support"})," et à 700 px sur ",e.jsx(s,{children:"/platform"}),"). Le retour à la ligne seul cassait les mots dans les tuiles de 129 px du volet partagé de ",e.jsx(s,{children:"/support"})," : sous 1024 px, la tuile est donc aussi un conteneur de taille nommé (",e.jsx(s,{children:"max-lg:[container:stat-tile/inline-size]"}),") et, à 13rem ou moins, la pastille d'icône passe au-dessus du libellé, qui prend alors toute la largeur. Cela change aussi les grilles de deux tuiles sur téléphone livrées par CHARGEOPS-605 (#635) : à 375 px ces tuiles font environ 148 px, leur pastille est donc maintenant au-dessus du libellé plutôt qu'à côté. À 1024 px et plus aucun conteneur n'est déclaré, le bureau ne change pas. Les totaux de la console plateforme restent deux par rangée jusqu'à 1024 px (",e.jsx(s,{children:"grid-cols-2 lg:grid-cols-4"}),", avant ",e.jsx(s,{children:"sm:"}),") : à quatre par rangée à 700 px, les tuiles faisaient 123 px et même empilé un libellé se coupait en plein mot. Et le ",e.jsx(s,{children:"StatReadout"})," des rapports d'entretien et de service garde sa découpe téléphone (3×2, 2×2) jusqu'à"," ",e.jsx(s,{children:"lg:"})," plutôt que ",e.jsx(s,{children:"sm:"}),", car le panneau de liste de 320 px de la tablette laissait six colonnes de 36 px. (",e.jsx(s,{children:"max-lg:grid-cols-3"})," par-dessus"," ",e.jsx(s,{children:"sm:grid-cols-6"})," ne marcherait pas : Tailwind émet"," ",e.jsx(s,{children:"max-*"})," avant ",e.jsx(s,{children:"sm:"}),".) Chaque ajout tactile est derrière ",e.jsx(s,{children:"[@media(pointer:coarse)]:"}),", sans"," ",e.jsx(s,{children:"!important"})," ; à la souris, les classes sont identiques octet pour octet. Épinglé par"," ",e.jsx(s,{children:"ui/__tests__/coarse-touch-targets-2.test.tsx"})," (les classes souris, les classes tactiles, le DOM des liens empilés, en frères comme d'un conteneur à l'autre avec le vrai tableau d'entretien, ",e.jsxs(s,{children:["hitArea=","{false}"]}),", le DOM des liens tronqués, les zones de 44×44 sans chevauchement des actions sur la vraie plaque, l'arithmétique de l'écart des rangées de puces, le bloc tactile de l'éditeur) et"," ",e.jsx(s,{children:"chargers/__tests__/charger-identity-header-live-state.test.tsx"})," ","(l'écart tactile de la ligne d'identité de la borne)."]})]})}),e.jsxs(t,{id:"pages-headers-mobiles",title:"19. Pages avec headers mobiles",children:[e.jsxs(i,{children:["Toutes les pages dans ",e.jsx(s,{children:"<AppLayout>"}),", et l'accueil, reçoivent la barre du haut et la barre d'onglets de la coquille téléphone sous ",e.jsx(s,{children:"1024 px"})," (section 14), par exemple :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Home (",e.jsx(a,{children:"/"}),")"]}),e.jsxs(r,{children:["All Chargers (",e.jsx(a,{children:"/all-chargers"}),")"]}),e.jsxs(r,{children:["Warehouse (",e.jsx(a,{children:"/warehouse"}),")"]}),e.jsxs(r,{children:["RMA (",e.jsx(a,{children:"/rma"}),")"]}
27),e.jsxs(r,{children:["User Management (",e.jsx(a,{children:"/user-management"}),")"]}),e.jsxs(r,{children:["Site Management (",e.jsx(a,{children:"/site-management"}),")"]}),e.jsx(r,{children:"Configuration (inclut l'onglet Branding, admin)"})]}),e.jsxs(i,{children:["Pages ",e.jsx(n,{children:"excluées"})," de ",e.jsx(s,{children:"<AppLayout>"})," :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(a,{children:"/auth"}),", ",e.jsx(a,{children:"/forgot-password"}),","," ",e.jsx(a,{children:"/reset-password"})," (pas de header)."]}),e.jsxs(r,{children:[e.jsx(a,{children:"/charger-card-lab"})," (lab de design sans auth)."]})]})]}),e.jsxs(t,{id:"visualisations-mobile",title:"20. Visualisations désactivées sur petit écran",children:[e.jsxs(i,{children:[e.jsx(s,{children:"phase-distribution.tsx"})," (graphique de distribution des phases électriques) est masqué sur mobile car illisible — alternative : badge récapitulatif compact. Pattern :"]}),e.jsx(c,{language:"tsx",children:`{!isMobile && <PhaseDistribution …/>}
28{isMobile && <PhaseSummaryBadge …/>}`}),e.jsx(i,{children:"Pages concernées : Power Management, Home, Charger Management, Site Configuration."})]}),e.jsx(t,{id:"performance-mobile",title:"21. Performance mobile",children:e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Animations réduites"})," :"," ",e.jsx(s,{children:"prefers-reduced-motion"})," est respecté globalement (animations à ",e.jsx(s,{children:"0.01ms"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Préchargement intelligent"})," :"," ",e.jsx(s,{children:"NavPreloader"})," (App.tsx) ne précharge pas si"," ",e.jsx(s,{children:"connection.saveData"})," ou"," ",e.jsx(s,{children:'effectiveType: "2g"'}),", et réchauffe les chunks de la nav ",e.jsx(n,{children:"un à la fois"})," (séquentiellement, les échecs individuels étant ignorés) plutôt que tous en parallèle — Safari échoue plus volontiers les fetches de modules concurrents, ce qui émettait des ",e.jsx(s,{children:"vite:preloadError"})," et provoquait des rechargements de page intempestifs. Le gestionnaire"," ",e.jsx(s,{children:"vite:preloadError"})," (main.tsx) ne recharge d'ailleurs plus que si ",e.jsx(s,{children:"isNewDeploymentLive()"})," confirme un nouveau déploiement (plafonné à un rechargement par 30 s)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Hardware acceleration"})," :"," ",e.jsx(s,{children:"will-change: transform, opacity"})," sur"," ",e.jsx(s,{children:".animate-fade-in-up"}),", ",e.jsx(s,{children:".logo-spin"}),","," ",e.jsx(s,{children:".flowing-water"}),"."," ",e.jsx(s,{children:"-webkit-backface-visibility: hidden"})," sur les wrappers animés."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Fonds masqués"})," :"," ",e.jsx(s,{children:"<AnimatedGridPattern>"})," n'est rendu que"," ",e.jsx(s,{children:"if (!isMobile)"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Lazy routes"})," : toutes les pages sont"," ",e.jsx(s,{children:"React.lazy()"})," (cf. App.tsx) pour découper le bundle."]})]})}),e.jsx(t,{id:"geolocalisation",title:"Géolocalisation (pointages GPS)",children:e.jsxs(i,{children:["Le seul appel à ",e.jsx(s,{children:"navigator.geolocation"})," de l'application est dans"," ",e.jsx(s,{children:"client/src/components/reports/location-stamps.tsx"})," (CHARGEOPS-128) : un ",e.jsx(s,{children:"getCurrentPosition"})," par toucher sur « Arrivé » / « Départ »,"," ",e.jsx(s,{children:"enableHighAccuracy"}),", ",e.jsx(s,{children:"timeout"})," de 10 s,"," ",e.jsx(s,{children:"maximumAge: 0"})," — jamais de ",e.jsx(s,{children:"watchPosition"}),", jamais en arrière-plan. Un refus (code 1) ou un échec affiche un seul toast et le rapport continue. Un minuteur client de 20 s (",e.jsx(s,{children:"STAMP_CLIENT_TIMEOUT_MS"}),") borne aussi une demande d'autorisation restée sans réponse, où le ",e.jsx(s,{children:"timeout"})," de l'API ne démarre jamais : le toucher échoue alors comme une expiration."," ",e.jsx(s,{children:"vercel.json"})," ne pose aucun"," ",e.jsx(s,{children:"Permissions-Policy"}),", donc la géolocalisation reste permise sur l'origine (HTTPS requis par le navigateur)."]})}),e.jsxs(t,{id:"lightbox-photo-partagee",title:"21a. Lightbox photo partagée et bannière d'appel entrant (S3.0c)",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/photo-lightbox.tsx"})," (S3.0c, CHARGEOPS-569) remplace deux visionneuses qui avaient divergé : l'ancienne ",e.jsx(s,{children:"maintenance-reports/photo-lightbox.tsx"})," ","(réutilisée bien au-delà des rapports de maintenance — photos de borne, tiroir d'aperçu de rapport, les deux panneaux de messages, les deux cartes photos/pièces jointes de billets) et l'ancienne visionneuse en ligne de"," ",e.jsx(s,{children:"service-report-detail.tsx"}),"."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Un seul composant"})," — portail vers"," ",e.jsx(s,{children:"<body>"})," (une carte ",e.jsx(s,{children:".co-card"}),"/",e.jsx(s,{children:".ny-card"})," ancêtre pose un ",e.jsx(s,{children:"transform"}),"/",e.jsx(s,{children:"filter"})," qui piégerait un"," ",e.jsx(s,{children:"fixed inset-0"}),"), clavier (flèches, Échap), le compteur, une légende éditable et une suppression à confirmation en deux temps optionnelles, et un emplacement"," ",e.jsx(s,{children:"renderExtraControls"})," pour un appelant (l'éditeur d'étiquettes des pièces jointes de billet). La navigation est ",e.jsx(n,{children:"bornée"})," aux extrémités (comme l'ancienne visionneuse des rapports de maintenance),"," ",e.jsx(n,{children:"pas cyclique"})," (l'ancien comportement de la visionneuse en ligne des rapports de service) — les deux différaient, et le balayage tactile ci-dessous impose le comportement borné."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Nouveau ici"})," — ni l'une ni l'autre ancienne visionneuse ne l'avait : un balayage horizontal de plus de 60 px change de photo, un balayage vers le bas de plus de 100 px ferme, un pincement à deux doigts zoome (borné 1–3×, un double-tap bascule 1×/2×, l'attribut"," ",e.jsx(s,{children:"data-zoomed"}),"), et le remplissage de zone sûre via"," ",e.jsx(s,{children:"--safe-top"}),"/",e.jsx(s,{children:"--safe-bottom"}),". Le geste de balayage réutilise ",e.jsx(s,{children:"lockSwipeAxis"})," de"," ",e.jsx(s,{children:"client/src/components/detail/tab-swipe.ts"})," (le même verrou d'angle à 30° que les onglets glissables de DetailLayout) : un balayage surtout vertical ne change jamais de photo, même si sa composante horizontale seule dépasserait 60 px."]}),e.jsxs(r,{children:[e.jsxs(n,{children:["Balayage = ",e.jsx(s,{children:"drag"})," de framer-motion"]})," — même patron que ",e.jsx(s,{children:"SwipeRow"})," de"," ",e.jsx(s,{children:"ResponsiveList"})," (S2.3) : un"," ",e.jsx(s,{children:"motion.div"})," avec ",e.jsx(s,{children:"drag"}),","," ",e.jsx(s,{children:"onDragEnd"})," lit ",e.jsxs(s,{children:["info.offset.","{x,y}"]}),". Le pincement, lui, utilise de simples événements pointeur (deux pointeurs actifs suivis dans une ",e.jsx(s,{children:"Map"}),") à côté du"," ",e.jsx(s,{children:"drag"})," à un seul doigt de framer-motion."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Adaptateur maintenu"})," —"," ",e.jsx(s,{children:"maintenance-reports/photo-lightbox.tsx"})," devient un pur adaptateur de forme devant le composant partagé : même nom d'export, même forme ",e.jsx(s,{children:"LightboxPhoto"})," ","(",e.jsx(s,{children:"id"}),"/",e.jsx(s,{children:"url"}),"/",e.jsx(s,{children:"caption"}),"/",e.jsx(s,{children:"fileName"}),") et mêmes callbacks (",e.jsx(s,{children:"onClose"}),"/",e.jsx(s,{children:"onNavigate"}),"/",e.jsx(s,{children:"onSaveCaption"}),"/",e.jsx(s,{children:"onDelete"}),"/",e.jsx(s,{children:"renderExtraControls"}),"), donc ses sept appelants n'ont pas changé une ligne.",e.jsx(s,{children:"service-report-detail.tsx"})," appelle directement le composant partagé."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La bannière d'appel entrant"})," (",e.jsx(s,{children:"client/src/components/calls/incoming-call-dialog.tsx"}),") était l'autre lacune que le spec S3 §4.1 signalait — une bannière ",e.jsx(s,{children:"fixed"})," brute, à ",e.jsx(s,{children:"right-4 top-4"})," ","sans égard pour la barre du haut fixe (",e.jsx(s,{children:"--chrome-h-mobile"}),") ni, sur tablette, le rail de navigation gauche (",e.jsx(s,{children:"--rail-w"}),"). Des classes"," ",e.jsx(s,{children:"max-lg:"})," déplacent la bannière au clair des deux sur téléphone/tablette (",e.jsx(s,{children:"max-lg:left-[var(--rail-w)]"}),","," ",e.jsx(s,{children:"max-lg:top-[calc(var(--chrome-h-mobile)+0.75rem)]"}),") ; les classes sans préfixe (desktop, ≥1024 px) restent identiques au caractère près."]})]}),e.jsxs(i,{children:["Tests : ",e.jsx(s,{children:"ui/__tests__/photo-lightbox.test.tsx"})," (portail, clavier, balayage borné, fermeture au balayage vers le bas, un balayage surtout vertical ne change jamais de photo, double-tap →"," ",e.jsx(s,{children:"data-zoomed"}),", ",e.jsx(s,{children:"aria-label"})," depuis"," ",e.jsx(s,{children:"t()"}),") et"," ",e.jsx(s,{children:"calls/__tests__/incoming-call-offsets.test.tsx"})," (les classes ",e.jsx(s,{children:"max-lg:"})," ajoutées, les classes desktop inchangées au caractère près)."]})]}),e.jsxs(t,{id:"mobile-audit-harness",title:"21b. Harnais d'audit mobile",children:[e.jsxs(i,{children:[e.jsx(s,{children:"tools/mobile-audit/"})," (S3.0d, CHARGEOPS-570) est une application Vite committée, réservée au développement, qui monte le ",e.jsx(n,{children:"vrai"})," ",e.jsx(s,{children:"client/src/App.tsx"})," — vraies routes, vrai ",e.jsx(s,{children:"AppLayout"}),", vraies coquilles téléphone/tablette, vraies routes scindées — sur des fixtures au lieu d'un vrai backend Convex, afin d'ouvrir n'importe quelle page à n'importe quelle largeur sans session ni ",e.jsx(s,{children:".env.local"}),". Il n'est jamais inclus dans le build ni dans aucun périmètre",e.jsx(s,{children:"tsc"})," — voir"," ",e.jsx(s,{children:"__tests__/mobile-audit-isolation.test.ts"}),"."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Lancer"})," : ",e.jsx(s,{children:"npm run mobile-audit"})," ","(port 5232) ou ",e.jsxs(s,{children:["preview_start(",'{name:"mobile-audit"}',")"]}),", puis ouvrir"," ",e.jsx(s,{children:"/?route=/support&lang=fr&dark=0&empty=0"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Frontière stubée"})," : ",e.jsx(s,{children:"convex/react"}),","," ",e.jsx(s,{children:"@convex-dev/auth/react"}),","," ",e.jsx(s,{children:"@/hooks/use-auth"}),", ",e.jsx(s,{children:"@/hooks/use-language"}),","," ",e.jsx(s,{children:"@/hooks/use-permissions"})," et l'import relatif"," ",e.jsx(s,{children:"./lib/convex"})," de ",e.jsx(s,{children:"App.tsx"})," — le reste de l'arbre est le code réel. Le routage utilise"," ",e.jsx(s,{children:"wouter/memory-location"}),", posé en"," ",e.jsx(s,{children:"<Router>"}
28)," AUTOUR de ",e.jsx(s,{children:"<App/>"})," plutôt que par une modification d'",e.jsx(s,{children:"App.tsx"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Fixtures"})," : ",e.jsx(s,{children:"fixtures/index.ts"})," ","expose ",e.jsxs(s,{children:["registerFixture(routePattern, ",'{ "module:fn": (args) => data }',")"]}),", la clé étant le nom du reference Convex tel que renvoyé par"," ",e.jsx(s,{children:"getFunctionName"}),". Une tranche de vague ajoute sa page en créant son propre ",e.jsx(s,{children:"fixtures/<slice>.ts"})," ","— rien d'autre : ",e.jsx(s,{children:"fixtures/index.ts"})," charge tous ses voisins via ",e.jsx(s,{children:"import.meta.glob"})," (des imports"," ",e.jsx(n,{children:"dynamiques"}),", pour éviter un cycle ES module avec le registre lui-même), donc des tranches parallèles ne modifient jamais la même liste."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"measure.js"})})," : une fonction brute, sans import, à coller dans le panneau navigateur (",e.jsx(s,{children:"javascript_tool"})," ou ",e.jsx(s,{children:"browser_evaluate"})," ","de Playwright). Elle applique le contrôle largeur-téléphone (spec §7) : débordement horizontal, cibles tactiles sous 44×44, chevauchements de barres fixes/collantes, contenu caché sous une barre du bas, comptage de ",e.jsx(s,{children:"h1"}),"/",e.jsx(s,{children:"main"}),". À exécuter à 375×812 (tactile) et à 700×1000, et à 820×1180 sur une page scindée."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Débordement rogné"})," (CHARGEOPS-606) : la racine de la coquille est en ",e.jsx(s,{children:"overflow-x-clip"}),", donc"," ",e.jsx(s,{children:"overflowX"})," reste à 0 même quand une rangée dépasse largement sa carte. ",e.jsx(s,{children:"clipped"})," signale chaque élément dont le bord droit dépasse l'ancêtre qui le rogne horizontalement, ou la fenêtre, sous la forme"," ",e.jsx(s,{children:"{ sel, overflowPx, clipper }"})," (le plus externe seulement, 20 au plus). Sont ignorés : le contenu d'un conteneur qui défile de côté, les éléments ",e.jsx(s,{children:"sr-only"})," ou de taille nulle, le texte ",e.jsx(s,{children:"truncate"})," et tout ce qui est sous ",e.jsx(s,{children:"[data-audit-allow-clip]"}),". Le contrôle 1 ne passe que si ",e.jsx(s,{children:"overflowX"})," vaut 0"," ",e.jsx(n,{children:"et"})," que ",e.jsx(s,{children:"clipped"})," est vide."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Moins de faux positifs"})," : une cible de 43,5 px ou plus compte pour 44 (arrondi sous-pixel) ; les contrôles"," ",e.jsx(s,{children:"aria-hidden"})," ou masqués visuellement (le"," ",e.jsx(s,{children:"<select>"})," natif caché du Select de Radix) ne sont pas des cibles ; un élément collant dans un panneau défilant (la première colonne épinglée d'un"," ",e.jsx(s,{children:'<Table phone="scroll">'}),") n'est pas une barre de page. Le stub ",e.jsx(s,{children:"convex/react"})," répond aussi à"," ",e.jsx(s,{children:"usePaginatedQuery"})," depuis les fixtures (un tableau ou un ",e.jsx(s,{children:"{ page }"}),", renvoyé en entier avec"," ",e.jsx(s,{children:'status: "Exhausted"'}),"). Tests unitaires :"," ",e.jsx(s,{children:"__tests__/mobile-audit-measure.test.ts"}),"."]})]})]}),e.jsxs(t,{id:"mobile-map-toggle",title:"21c. Cartes — bascule Carte/Liste et fiche d'épingle",children:[e.jsxs(i,{children:["S3 vague 3 (CHARGEOPS-596, spec §5 « Special designs → Maps »). Trois briques partagées, prêtes pour toute future page « carte » (",e.jsx(s,{children:"customer-map.tsx"}),", ",e.jsx(s,{children:"modems-map.tsx"}),") :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/hooks/use-map-view-mode.ts"})})," — ",e.jsx(s,{children:"useMapViewMode(storageKey)"})," ","renvoie ",e.jsx(s,{children:'["map" | "list", setMode]'}),", mémorisé par page dans ",e.jsx(s,{children:"localStorage"})," (lecture/écriture gardées par un"," ",e.jsx(s,{children:"try/catch"})," — un stockage bloqué revient simplement à « Carte »)."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/components/maps/map-view-toggle.tsx"})})," —"," ",e.jsx(s,{children:"<MapViewToggle mode onChange mapLabel listLabel groupLabel/>"}),", un groupe de deux boutons ",e.jsx(s,{children:'role="tab"'})," stylé"," ",e.jsx(s,{children:"co-card"}),", cibles d'au moins 44px. Rendu en"," ",e.jsx(s,{children:"absolute"})," par-dessus la carte OU la liste — il ne pousse pas leur hauteur, qui doit remplir exactement l'espace entre la barre du haut et la barre d'onglets (",e.jsx(s,{children:"h-[calc(100dvh-var(--chrome-h-mobile)-var(--tabbar-h))]"}
28),", appliquée seulement sous 640px — la tablette garde le calc desktop ",e.jsx(s,{children:"h-[calc(100vh-4rem)]"}),", spec §5 « tablets keep the full map »)."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/components/maps/map-pin-sheet.tsx"})})," —"," ",e.jsx(s,{children:"<MapPinSheet open onOpenChange title description status facts actions/>"}),", la feuille du bas ouverte au toucher d'une épingle : le"," ",e.jsx(s,{children:"Dialog"})," adaptatif forcé en ",e.jsx(s,{children:'mobile="sheet"'})," ","(ces pages sont ",e.jsx(s,{children:"fullWidth"}),", donc sans ce forçage le mode résolu serait le modal centré par défaut du téléphone). Elle affiche exactement le résumé et l'action principale que l'ancienne bulle flottante ancrée offrait — celle-ci reste montée pour tablette/ordinateur, gardée derrière"," ",e.jsx(s,{children:"!isPhone &&"}),"."]}),e.jsxs(r,{children:["Le mode Liste est un ",e.jsx(s,{children:"ResponsiveList"})," (§7 ci-dessus) des mêmes éléments que la carte, avec"," ",e.jsx(s,{children:"desktop={null}"})," (toujours des cartes — la page ne le monte que quand elle est déjà en téléphone). Sur"," ",e.jsx(s,{children:"modems-map.tsx"}),", l'action d'une carte de liste rouvre la même ",e.jsx(s,{children:"MapPinSheet"})," plutôt que de dupliquer un second point d'entrée ; le bouton « Détails » de la fiche rouvre l'existant ",e.jsx(s,{children:"ModemDetailSheet"})," (aucune duplication de son contenu)."]})]})]}),e.jsxs(t,{id:"mobile-chart-dashboards",title:"21d. Tableaux de bord à graphiques — carrousel, options téléphone, pastilles KPI",children:[e.jsxs(i,{children:["S3 vague 4 (CHARGEOPS-599, spec §5 « Chart dashboards »). Tous les graphiques de l'application sont du chart.js via"," ",e.jsx(s,{children:"react-chartjs-2"}),". Trois briques partagées, utilisées par ",e.jsx(s,{children:"analytics.tsx"})," et"," ",e.jsx(s,{children:"operations-dashboard.tsx"})," ; hors téléphone (≥ 640px) chacune rend exactement ce que la page rendait déjà :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/components/charts/phone-chart-options.ts"})})," —"," ",e.jsx(s,{children:"usePhoneChartOptions(options, kind?)"})," renvoie le MÊME objet d'options hors téléphone, et sur téléphone une copie (",e.jsx(s,{children:"toPhoneChartOptions"}),", fonction pure, testée) : légende"," ",e.jsx(s,{children:'position: "bottom"'})," ; axe de catégories éclairci (",e.jsx(s,{children:"autoSkip"}),", ",e.jsx(s,{children:"maxTicksLimit: 6"}),", jamais incliné, libellés longs raccourcis par « … » — l'équivalent chart.js du ",e.jsx(s,{children:'interval="preserveStartEnd"'})," de recharts que cite le spec) ; axes de valeurs à 5 graduations ; infobulle au toucher (",e.jsx(s,{children:'events: ["click"]'})," — un doigt qui glisse pour défiler n'en ouvre jamais —, et"," ",e.jsx(s,{children:'interaction: { mode: "index", intersect: false }'})," ","pour les graphiques cartésiens). Un formateur de graduation propre au graphique (le ",e.jsx(s,{children:"%"})," de la Pareto) est conservé."," ",e.jsx(s,{children:'kind: "radial"'})," pour un anneau (pas d'axes). Branché dans ",e.jsx(s,{children:"StatsBarChart"}),", ",e.jsx(s,{children:"DistributionDoughnut"}),","," ",e.jsx(s,{children:"ParetoChart"})," et les options des graphiques d'Analytiques. Le document PDF des Indicateurs dessine ses propres graphiques : il n'est pas touché."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/components/charts/chart-carousel.tsx"})})," —"," ",e.jsx(s,{children:"<ChartCarousel className label slideLabels? testId?>"})," ",": hors téléphone, rend ",e.jsx(s,{children:"<div className={className}>"})," ","(la grille de la page, inchangée). Sur téléphone, chaque enfant devient une diapositive pleine largeur (",e.jsx(s,{children:"snap-x snap-mandatory"}),"), avec un point par diapositive (zone de 44×44 sous pointeur grossier) et un compteur « n / N » en région"," ",e.jsx(s,{children:"aria-live"}),". Les enfants ",e.jsx(s,{children:"false"})," /"," ",e.jsx(s,{children:"null"})," (graphique masqué pour ce rôle) ne sont pas des diapositives ; s'il n'en reste qu'une, la grille simple est rendue. Aucun graphique n'est retiré sur téléphone."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"@/components/kpi/kpi-chip-row.tsx"})})," —"," ",e.jsx(s,{children:"<KpiChipRow className …>"})," rend la grille de tuiles hors téléphone, et sur téléphone une rangée de pastilles"," ",e.jsx(s,{children:"co-chip"})," défilant de côté (le même dessin que les compteurs de Ma journée). Elle signale par contexte à chaque"," ",e.jsx(s,{children:"KpiTile"})," de se dessiner en ",e.jsx(s,{children:"KpiChip"})," : libellé, valeur, sous-ligne, ton d'alerte et le même lien vers la page du KPI. Utilisée par toutes les bandes de tuiles des Indicateurs (vue d'ensemble, service, finances, satisfaction) ; Analytiques rend directement quatre ",e.jsx(s,{children:"KpiChip"}),"."]})]}),e.jsxs(i,{children:["Tablette inchangée, à une correction près : les grilles"," ",e.jsx(s,{children:"grid gap-4 lg:grid-cols-2"})," de"," ",e.jsx(s,{children:"service-kpi-section.tsx"})," prennent"," ",e.jsx(s,{children:"max-lg:grid-cols-1"}),". Leur piste implicite"," ",e.jsx(s,{children:"auto"})," s'élargissait au tableau le plus large, que la carte coupait ; avec ",e.jsx(s,{children:"minmax(0,1fr)"}),", le corps"," ",e.jsx(s,{children:"flush"})," du panneau fait défiler le tableau de côté."]})]}),e.jsxs(t,{id:"push-appareil",title:"21e. Notifications push sur l'appareil (S5.3)",children:[e.jsxs(i,{children:["CHARGEOPS-661, côté appareil du web push (CHARGEOPS-394 ; le serveur est décrit dans la partie notifications de"," ",e.jsx(s,{children:"tech-auth-rbac"}),"). Tout est inerte tant que"," ",e.jsx(s,{children:"api.push.config.getPublicKey"})," renvoie"," ",e.jsx(s,{children:"null"})," (les trois ",e.jsx(s,{children:"VAPID_*"})," absents) : aucune interface push n'apparaît."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"client/public/sw-push.js"})})," — la logique pure des événements ",e.jsx(s,{children:"push"})," et"," ",e.jsx(s,{children:"notificationclick"})," (",e.jsx(s,{children:"parsePayload"}),","," ",e.jsx(s,{children:"notificationOptions"}
28),", ",e.jsx(s,{children:"pickClient"}),","," ",e.jsx(s,{children:"handlePush"}),", ",e.jsx(s,{children:"handleClick"}),"), exposée sur"," ",e.jsx(s,{children:"self.__chargeopsPush"})," et en CommonJS pour les tests."," ",e.jsx(s,{children:"push"})," affiche la notification avec son"," ",e.jsx(s,{children:"tag"})," (",e.jsx(s,{children:"<entityType>:<id>"})," : une répétition remplace la précédente) et ",e.jsx(s,{children:"data.link"}),", puis"," ",e.jsx(s,{children:"navigator.setAppBadge(badge)"})," (ou"," ",e.jsx(s,{children:"clearAppBadge()"})," à 0) là où c'est supporté. Une charge illisible n'affiche rien et ne lève jamais. Un lien qui n'est pas un chemin interne (",e.jsx(s,{children:"//hôte"}),", ",e.jsx(s,{children:"https://…"}),") devient ",e.jsx(s,{children:"/"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"notificationclick"})})," — ferme la notification ; une fenêtre de l'app déjà ouverte (même origine, la focalisée d'abord) reçoit ",e.jsx(s,{children:"focus()"})," puis"," ",e.jsx(s,{children:'postMessage({ type: "NAVIGATE", link })'})," ; sinon"," ",e.jsx(s,{children:"clients.openWindow(link)"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"client/public/sw.js"})})," — charge"," ",e.jsx(s,{children:'importScripts("/sw-push.js")'})," dans un"," ",e.jsx(s,{children:"try"})," (un échec ne doit jamais emporter le cache hors ligne) et relaie les deux événements. ",e.jsx(s,{children:"vercel.json"})," ","sert ",e.jsx(s,{children:"/sw-push.js"})," en ",e.jsx(s,{children:"no-store"})," : un script importé est revérifié à chaque mise à jour du worker, et la règle générique ",e.jsx(s,{children:"immutable"})," des ",e.jsx(s,{children:".js"})," l'aurait figé un an (",e.jsx(s,{children:"__tests__/vercel-headers.test.ts"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"components/shell/use-sw-navigate.ts"})})," ","— ",e.jsx(s,{children:"<SwNavigate />"}),", monté une fois dans"," ",e.jsx(s,{children:"App.tsx"})," à côté de ",e.jsx(s,{children:"StaleChunkCleanup"}),", écoute ",e.jsx(s,{children:"NAVIGATE"})," et appelle le ",e.jsx(s,{children:"setLocation"})," ","de wouter (chemins internes seulement) : la page change sur place et ← reste dans l'app. Bureau, téléphone, tablette et portail."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"lib/push/push-client.ts"})})," —"," ",e.jsx(s,{children:"pushSupport()"})," (",e.jsx(s,{children:"serviceWorker"})," +"," ",e.jsx(s,{children:"PushManager"})," + ",e.jsx(s,{children:"Notification"})," ; un iPhone ou iPad hors mode installé donne ",e.jsx(s,{children:'"ios-needs-install"'}),", jamais ",e.jsx(s,{children:'"unsupported"'}),"), ",e.jsx(s,{children:"currentPermission()"}),","," ",e.jsx(s,{children:"enablePush(key, save)"})," (permission demandée seulement sur un toucher, ",e.jsx(s,{children:"subscribe({ userVisibleOnly: true, applicationServerKey })"}),", remplace un abonnement fait pour une autre clé),"," ",e.jsx(s,{children:"disablePush(remove)"}),","," ",e.jsx(s,{children:"releasePushOnSignOut"})," (borné à 2 s, ne lève jamais) et ",e.jsx(s,{children:"deviceLabel()"})," (« iPhone · Safari », « Android · Chrome »…)."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"components/notifications/push-device-card.tsx"})})," ","— première carte de ",e.jsx(s,{children:"NotificationPreferencesPanel"})," ","(donc ",e.jsx(a,{children:"/notifications"})," et"," ",e.jsx(a,{children:"/portal/notifications"}),"). États de l'appareil : masquée (non configuré, ou non supporté hors iOS), consigne d'installation iOS, impersonation, ",e.jsx(s,{children:"default"}),"/accordée sans abonnement → « Activer », refusée → comment la rétablir, abonnée → « Désactiver ». Moitié compte : la bascule"," ",e.jsx(s,{children:"pushEnabled"})," (réécrit la matrice ",e.jsx(n,{children:"enregistrée"})," ","telle quelle, jamais le brouillon du panneau, car"," ",e.jsx(s,{children:"updateMine"})," est un remplacement complet) et la liste"," ",e.jsx(s,{children:"listMine"})," avec ",e.jsx(s,{children:"remove"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:e.jsx(s,{children:"components/notifications/push-prompt-card.tsx"})})," ","— la proposition unique, sur Accueil, Ma journée et la vue d'ensemble du portail : téléphone/tablette, app installée (",e.jsx(s,{children:"display-mode: standalone"}),"), push configuré et supporté, permission ",e.jsx(s,{children:"default"}),", hors impersonation. « Plus tard » est retenu par utilisateur dans"," ",e.jsx(s,{children:"localStorage"})," (",e.jsx(s,{children:"chargeops.pushPrompt.dismissed.<userId>"}
28),", lecture et écriture dans un ",e.jsx(s,{children:"try/catch"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Déconnexion"})," — ",e.jsx(s,{children:"logoutMutation"})," et"," ",e.jsx(s,{children:"logoutEverywhereMutation"})," (",e.jsx(s,{children:"hooks/use-auth.tsx"}),") appellent ",e.jsx(s,{children:"unsubscribe"})," avec l'endpoint de l'appareil puis ",e.jsx(s,{children:"pushSubscription.unsubscribe()"})," avant"," ",e.jsx(s,{children:"signOut()"}),". Le garde de déconnexion hors ligne enveloppe ces deux chemins : le désabonnement suit donc sa confirmation."]})]}),e.jsxs(i,{children:["Bureau : seule la carte de réglages s'ajoute. Tests :"," ",e.jsx(s,{children:"__tests__/sw-push.test.ts"})," (le vrai ",e.jsx(s,{children:"sw.js"})," +"," ",e.jsx(s,{children:"sw-push.js"})," dans un contexte ",e.jsx(s,{children:"node:vm"}),"),"," ",e.jsx(s,{children:"lib/push/__tests__/push-client.test.ts"}),","," ",e.jsx(s,{children:"components/notifications/__tests__/push-*.test.tsx"}),","," ",e.jsx(s,{children:"components/shell/__tests__/use-sw-navigate.test.tsx"}),","," ",e.jsx(s,{children:"hooks/__tests__/use-auth-push-signout.test.tsx"}),"."]})]}),e.jsxs(t,{id:"scanner-integre",title:"Scanner intégré — Scanner → ouvrir une borne (S4.1)",children:[e.jsxs(i,{children:["Mobile S4.1 (CHARGEOPS-654, spec"," ",e.jsx(a,{children:"docs/superpowers/specs/2026-09-28-mobile-s4-native-extras-design.md"})," §4.1). Une seule feuille caméra générique, un seul résolveur, trois points d'entrée."]}),e.jsx(h,{rows:[{key:e.jsx(a,{children:"client/src/lib/scanner/detect.ts"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"createDetector()"})," : le ",e.jsx(s,{children:"BarcodeDetector"})," du navigateur quand ",e.jsx(s,{children:"getSupportedFormats()"})," couvre tout ",e.jsx(s,{children:"SCAN_FORMATS"})," (",e.jsx(s,{children:"qr_code"}),", ",e.jsx(s,{children:"code_128"}),", ",e.jsx(s,{children:"code_39"}),", ",e.jsx(s,{children:"ean_13"}),", ",e.jsx(s,{children:"ean_8"}),", ",e.jsx(s,{children:"data_matrix"}),") — Chrome sur Android ; sinon un ",e.jsx(s,{children:'import("barcode-detector/ponyfill")'})," paresseux (MIT, zxing-wasm) : Safari sur iPhone et Firefox ne téléchargent son JS et son ",e.jsx(s,{children:"zxing_reader.wasm"})," (~1,1 Mo) qu'à l'ouverture du scanner. Le ponyfill vise par défaut le CDN jsDelivr : le ",e.jsx(s,{children:"locateFile"})," de ",e.jsx(s,{children:"prepareZXingModule"})," est remplacé par l'URL de ",e.jsx(s,{children:"zxing-wasm/reader/zxing_reader.wasm?url"}),", un asset Vite haché servi depuis notre propre origine (la CSP couvre déjà ",e.jsx(s,{children:"'wasm-unsafe-eval'"})," et ",e.jsx(s,{children:"connect-src 'self'"}),"). ",e.jsx(s,{children:"fireImmediately: true"})," le charge d'emblée : un échec atteint la feuille (saisie manuelle) plutôt que chaque image."]})},{key:e.jsx(a,{children:"client/src/lib/scanner/resolve.ts"}),value:e.jsxs(e.Fragment,{children:[e.jsxs(s,{children:["resolveScanText(text, ","{ byRef, bySerial }",")"]}),", pur : une URL d'autocollant ChargeOps (",e.jsx(a,{children:"/c/<nom>"})," ou ",e.jsx(a,{children:"/scan?ref="})," sur ",e.jsx(s,{children:"chargeops.net"}),", un sous-domaine ou l'hôte de ce déploiement — ",e.jsx(s,{children:"extractStickerRef"}),") → ",e.jsx(s,{children:"byRef"}),", et s'arrête là ; toute autre URL → ",e.jsx(s,{children:"none"})," ; sinon le texte comme nom / identifiant PE (",e.jsx(s,{children:"byRef"}),"), puis comme numéro de série (",e.jsx(s,{children:"normalizeSerial"})," : espaces retirés, majuscules → ",e.jsx(s,{children:"bySerial"}),"). Répond ",e.jsx(s,{children:"one"})," / ",e.jsx(s,{children:"many"})," / ",e.jsx(s,{children:"none"}),"."]})},{key:e.jsx(a,{children:"client/src/components/scanner/scanner-sheet.tsx"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"ScannerSheet"})," sur le ",e.jsx(s,{children:"Dialog"})," adaptatif de S2 : ",e.jsx(s,{children:'mobile="fullscreen"'})," sur téléphone, une modale ",e.jsx(s,{children:"max-w-2xl"})," sur tablette et ordinateur, ",e.jsxs(s,{children:["confirmDiscard=","{false}"]}),". ",e.jsxs(s,{children:["getUserMedia(",'{ video: { facingMode: { ideal: "environment" } } }',")"]}),", un cadre de visée, une bascule de lampe quand ",e.jsx(s,{children:"track.getCapabilities().torch"})," (",e.jsxs(s,{children:["applyConstraints(","{ advanced: [{ torch }] }",")"]}),"), la saisie manuelle. Décode toutes les ",e.jsx(s,{children:"SCAN_INTERVAL_MS"})," (150 ms, ",e.jsx(s,{children:"setTimeout"}),", images à ",e.jsx(s,{children:"readyState < 2"})," sautées) ; une lecture compte quand le même texte revient deux fois de suite, puis ",e.jsx(s,{children:'haptic("success")'}),", toutes les pistes s'arrêtent, ",e.jsx(s,{children:"onResult"})," part une seule fois. ",e.jsx(s,{children:"NotAllowedError"})," → une explication plus la saisie manuelle ; pas de ",e.jsx(s,{children:"mediaDevices"})," / ",e.jsx(s,{children:"NotFoundError"})," → la saisie manuelle seule. Toutes les pistes s'arrêtent à la fermeture, au démontage, et quand la caméra est accordée après la fermeture."]})},{key:e.jsx(a,{children:"client/src/components/scanner/scan-to-open.tsx"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"ScanToOpen"})," branche ",e.jsx(s,{children:"resolveScanText"})," sur ",e.jsx(s,{children:"convex.query"})," (",e.jsx(s,{children:"chargers.queries.resolveScanRef"}),", puis ",e.jsx(s,{children:"chargers.queries.findBySerial"}),"). ",e.jsx(s,{children:"one"})," → ",e.jsx(s,{children:"scanDestination"})," (la règle de /scan : personnel → ",e.jsx(a,{children:"/chargers/:id/field"}),", opérateur de site confiné → ",e.jsx(a,{children:"/portal/cases/charger/:id"}),") ; ",e.jsx(s,{children:"many"})," → un sélecteur (",e.jsx(s,{children:"co-card-inset"})," + ",e.jsx(s,{children:"co-row"}),", nom et site) ; ",e.jsx(s,{children:"none"})," → « Aucune borne trouvée pour « … » » avec Rechercher (",e.jsx(a,{children:"/all-chargers?q="}),", personnel seulement) et Scanner de nouveau ; une recherche en échec le dit. Une réponse arrivée après la fermeture ou un nouveau scan est ignorée. ",e.jsx(s,{children:"useScanToOpen()"})," rend ",e.jsx(s,{children:"{ open, element }"})," et ne monte ",e.jsx(s,{children:"ScanToOpen"})," qu'après le premier ",e.jsx(s,{children:"open()"}),"."]})},{key:"Points d'entrée",value:e.jsxs(e.Fragment,{children:["La tuile Scanner du menu « + » (",e.jsx(s,{children:"create-sheet.tsx"}),") garde le ",e.jsx(s,{children:'href="/scan"'}
28)," du registre — l'instantané nav-parity ne change pas — et fait ",e.jsx(s,{children:"preventDefault"})," sur la touche pour ouvrir le scanner. La barre du haut (",e.jsx(s,{children:"top-bar.tsx"}),", téléphone et tablette seulement : le balisage ordinateur est intact) affiche un bouton icône ",e.jsx(s,{children:"ScanLine"})," avant la recherche sur les pages du personnel : pas sous ",e.jsx(a,{children:"/portal"}),", pas pour un opérateur de site confiné, et seulement si ",e.jsx(s,{children:'isRouteEnabled("/scan")'}),". ",e.jsx(a,{children:"/scan?open=1"})," sans ref (le raccourci du manifeste de S4.3) affiche ",e.jsx(s,{children:"ScanLanding"})," dans la mise en page du personnel ou du portail, la feuille déjà ouverte ; un ",e.jsx(s,{children:"ref"})," l'emporte toujours."]})},{key:e.jsx(a,{children:"client/src/lib/haptics.ts"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"haptic(kind)"}),", ",e.jsx(s,{children:'"tap" | "success" | "warning" | "error"'})," → ",e.jsx(s,{children:"navigator.vibrate"})," 10 / [12,40,12] / 30 / [40,60,40] ; sans effet sans l'API (iOS, ordinateur) et sous ",e.jsx(s,{children:"prefers-reduced-motion"}),". Ajouté ici pour le succès du scan ; S4.4b branche ses autres usages."]})}]}),e.jsx(d,{tone:"info",title:"Confidentialité",children:"Les images sont décodées dans la page et jamais envoyées ; les seuls appels réseau sont les deux recherches, limitées au locataire, avec le texte décodé."})]}),e.jsxs(t,{id:"scan-to-fill-native-links",title:"Scanner dans un champ, liens téléphone et cartes (S4.2)",children:[e.jsx(i,{children:"Mobile S4.2 (CHARGEOPS-655, spec §4.1–4.2). Trois petits modules posés sur le scanner de S4.1, chacun remplaçant du code ad hoc à ses points d'appel."}),e.jsx(h,{rows:[{key:e.jsx(a,{children:"client/src/components/scanner/scan-input.tsx"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"ScanInput"}),", remplaçant direct (",e.jsx(s,{children:"forwardRef"}),") d'un ",e.jsx(s,{children:"Input"})," de numéro de série (mêmes props, plus ",e.jsx(s,{children:"onScanned?(text)"}),"). Un bouton ",e.jsx(s,{children:"ScanLine"})," au bout du champ (",e.jsx(s,{children:"absolute inset-y-0 right-0 w-11"})," : 44 px de large et la hauteur du champ, qui est ",e.jsx(s,{children:"min-h-11"})," en pointeur grossier) ouvre ",e.jsx(s,{children:"ScannerSheet"})," avec le libellé « numéro de série » (",e.jsx(s,{children:"scanner.fill*"}),"). Le texte décodé, nettoyé et coupé à ",e.jsx(s,{children:"maxLength"}),", passe par le setter natif de ",e.jsx(s,{children:"HTMLInputElement.value"})," puis un événement ",e.jsx(s,{children:"input"})," qui remonte, que React transforme en ",e.jsx(s,{children:"onChange"})," : un champ contrôlé, un champ react-hook-form (",e.jsx(s,{children:"register()"})," ou ",e.jsx(s,{children:"FormField"}),") et un champ non contrôlé le voient tous. Le bouton n'apparaît qu'avec ",e.jsx(s,{children:"useCoarsePointer()"})," ou ",e.jsx(s,{children:'useViewport() !== "desktop"'}),", si ",e.jsx(s,{children:"navigator.mediaDevices.getUserMedia"})," existe et si le champ n'est ni désactivé ni en lecture seule ; sinon c'est l'",e.jsx(s,{children:"Input"})," seul, identique octet pour octet (épinglé dans ",e.jsx(s,{children:"scan-input.test.tsx"}),"). Points d'appel : ",e.jsx(s,{children:"add-charger-form"}),", ",e.jsx(s,{children:"bulk-add-chargers-form"}),", ",e.jsx(s,{children:"charger-edit-dialog"}),", ",e.jsx(s,{children:"claim-recovery-dialog"})," (série du remplacement), ",e.jsx(s,{children:"warehouse.tsx"})," (ajout en lot, modification en ligne, fenêtre de modification, formulaire d'ajout) et ",e.jsx(s,{children:"infrastru
28cture-info"})," (éléments CHRGPK, panneaux principaux et secondaires, transformateurs)."]})},{key:"Changements à ScannerSheet",value:e.jsxs(e.Fragment,{children:["Props de libellé facultatives ",e.jsx(s,{children:"description"}),", ",e.jsx(s,{children:"manualPlaceholder"})," et ",e.jsx(s,{children:"manualSubmit"})," (valeurs par défaut inchangées), et la soumission de la saisie manuelle appelle maintenant ",e.jsx(s,{children:"stopPropagation()"})," : la feuille est rendue dans un portail, mais React fait remonter un submit dans l'arbre des composants, donc un ",e.jsx(s,{children:"ScanInput"})," dans un ",e.jsx(s,{children:"<form>"})," soumettrait aussi ce formulaire."]})},{key:e.jsx(a,{children:"client/src/components/ui/phone-link.tsx"}),value:e.jsxs(e.Fragment,{children:[e.jsxs(s,{children:["PhoneLink ","{ phone }"]})," rend un ",e.jsx(s,{children:"<a>"})," dont le href est ",e.jsx(s,{children:"telHref(phone)"})," : ",e.jsx(s,{children:"tel:"})," et les chiffres, avec un ",e.jsx(s,{children:"+"})," initial seulement si le numéro en commence par un (",e.jsx(s,{children:'"(514) 555-0100"'})," → ",e.jsx(s,{children:"tel:5145550100"}),", ",e.jsx(s,{children:'"+1 514 555 0100"'})," → ",e.jsx(s,{children:"tel:+15145550100"}),"). Il affiche le numéro tel que saisi (ou ses enfants), ajoute ",e.jsx(s,{children:"[@media(pointer:coarse)]:min-h-11"}),", et transmet ref et props pour fonctionner comme enfant Radix ",e.jsx(s,{children:"asChild"}),". Remplace les quatre liens ",e.jsx(s,{children:"tel:"})," ad hoc (contacts d'organisation, entrepreneur par défaut du site, contact du billet, bon de travail public)."]})},{key:e.jsx(a,{children:"client/src/lib/maps-href.ts"}),value:e.jsxs(e.Fragment,{children:[e.jsxs(s,{children:["mapsHref(","{ address?, lat?, lng?, label? }",", ","{ directions?, platform? }",")"]})," ; la plateforme vient de ",e.jsx(s,{children:"detectMapsPlatform()"})," (iPhone/iPad/iPod, ou un agent Mac avec points tactiles → ",e.jsx(s,{children:"ios"})," ; ",e.jsx(s,{children:"Android"})," → ",e.jsx(s,{children:"android"}),"). iOS : ",e.jsx(s,{children:"maps.apple.com/?q="}),", ",e.jsx(s,{children:"?ll=lat,lng[&q=label]"}),", itinéraire ",e.jsx(s,{children:"?daddr="}),". Android : ",e.jsx(s,{children:"geo:0,0?q=adresse"})," ou ",e.jsx(s,{children:"geo:lat,lng?q=lat,lng(label)"})," ; l'itinéraire passe par l'URL ",e.jsx(s,{children:"dir"})," de Google Maps (l'application Maps l'ouvre ; ",e.jsx(s,{children:"geo:"})," n'a pas de mode itinéraire). Ailleurs : les URL Google Maps ",e.jsx(s,{children:"search"})," / ",e.jsx(s,{children:"dir"})," d'avant, octet pour octet, donc les liens sur ordinateur ne changent pas. Les coordonnées l'emportent sur l'adresse. ",e.jsx(s,{children:"openMaps()"})," est la forme pour un gestionnaire de clic : un URI ",e.jsx(s,{children:"geo:"})," part dans ",e.jsx(s,{children:"location.assign"})," (un ",e.jsx(s,{children:"window.open"})," laisse un onglet vide sur Android), une URL web dans ",e.jsx(s,{children:'window.open(…, "_blank", "noopener,noreferrer")'}),". Remplace les URL ad hoc de ",e.jsx(s,{children:"my-day.tsx"})," (son ",e.jsx(s,{children:"directionsUrl"})," disparaît), ",e.jsx(s,{children:"client-list.tsx"}),", ",e.jsx(s,{children:"customer-map.tsx"}),", ",e.jsx(s,{children:"modems-map.tsx"}),", ",e.jsx(s,{children:"mobile-site-info.tsx"})," et ",e.jsx(s,{children:"site-contact-info.tsx"})," ; les pages de documentation gardent leurs propres liens."]})}]})]})]}),e.jsxs(o,{id:"partie-3",title:"Partie 3 — Branding et customisation",children:[e.jsxs(t,{id:"page-branding",title:"22. Studio d'image de marque (Super-admin, par locataire)",children:[e.jsxs(i,{children:["Fichiers : ",e.jsx(s,{children:"client/src/components/configuration/branding-studio.tsx"})," ","(+ ",e.jsx(s,{children:"branding-studio-preview.tsx"})," pour l'aperçu en direct), rendus comme section ",e.jsx(a,{children:"Configuration → Plateforme ChargeOps → Image de marque"})," ","(l'ancienne route ",e.jsx(a,{children:"/branding"})," y redirige). Réservé aux"," ",e.jsx(n,{children:"Super-admins"})," (",e.jsx(s,{children:"isPlatformAdmin"}),", via"," ","la porte ",e.jsx(s,{children:'"platformAdmin"'})," de ",e.jsx(s,{children:"CONFIG_TABS"}),") ; ni ",e.jsx(s,{children:"platform-support"})," ni les admins de tenant ne voient l'onglet."]}),e.jsxs(i,{children:["Architecture en trois zones (grid ",e.jsx(s,{children:"lg:grid-cols-[15rem_1fr_21.5rem]"}),") :"]}),e.jsxs(p,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Rail des locataires"})," — liste cherchable via"," ",e.jsx(s,{children:"api.platform.queries.listTenants"})," (nom, slug, pastille de statut, point de marque) ; la sélection charge"," ",e.jsx(s,{children:"api.platform.queries.getTenantBranding"}),". Sélection initiale : le ",e.jsx(s,{children:"?tenant="})," de l'URL s'il est valide, sinon"," ",e.jsx(s,{children:"user.tenantId"})," (voir ci-dessous)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Éditeur"})," — sept groupes dépliables (Identité, Terminologie, Actions d'en-tête, Présence cartographique, Chargement, Rapports, Courriel) pilotés par ",e.jsx(s,{children:"react-hook-form"})," ; barre d'enregistrement collante à l'état dirty, seuls les ",e.jsx(s,{children:"dirtyFields"})," sont envoyés à"," ",e.jsx(s,{children:"api.platform.mutations.updateTenantBranding"})," ; réinitialisation par groupe (",e.jsx(s,{children:"GROUP_DEFAULTS"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Aperçu en direct"})," — ",e.jsx(s,{children:"useWatch"})," sur le form : mini en-tête (logos + boutons), animation de chargement réelle, marqueur de carte, pastilles de terminologie et mini-couverture de rapport (fond"," ",e.jsx(s,{children:"reportSecondaryColor"}),", filet ",e.jsx(s,{children:"reportPrimaryColor"}),", slogan résolu), mis à jour avant l'enregistrement."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"Garde-fous (2026-07-27)."})," Contrôle de contraste"," ",e.jsx(n,{children:"WCAG"})," sur les couleurs de rapport, bouton"," ",e.jsx(n,{children:"« Réinitialiser »"})," par emplacement d'upload, validation de type/taille sur le glisser-déposer, messages d'erreur de validation visibles, et une ",e.jsx(n,{children:"garde de modifications non enregistrées"})," ","au changement de locataire dans le rail. L'aperçu de rapport suit désormais exactement la règle du PDF (aucun slogan Polara affiché à tort sur un tenant secondaire qui n'en imprime aucun). Bouton"," ",e.jsx(n,{children:"d'envoi de test SMTP"})," dans le groupe Courriel (voir la section 29)."]}),e.jsxs(i,{children:[e.jsxs(n,{children:["Deep-link ",e.jsx(s,{children:"?tenant=<id>"}),"."]})," La console plateforme lie chaque ligne de tenant vers"," ",e.jsx(s,{children:"/configuration?tab=branding&tenant=<id>"}),". Le Studio lit le paramètre avec ",e.jsx(s,{children:"useSearch()"})," (wouter) — le"," ",e.jsx(s,{children:"?tab="})," était déjà géré par ",e.jsx(s,{children:"configuration.tsx"})," ","(",e.jsx(s,{children:"tabParam"}),"), seul ",e.jsx(s,{children:"?tenant="})," est nouveau. Auparavant l'effet de sélection initiale posait"," ",e.jsx(n,{children:"toujours"})," ",e.jsx(s,{children:"user.tenantId"}),", si bien qu'un simple lien vers ",e.jsx(s,{children:"?tab=branding"})," atterrissait sur le mauvais tenant. Deux garde-fous encadrent la nouvelle résolution :"]}),e.jsxs(p,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Validation"})," — l'id n'est honoré que s'il est"," ",e.jsxs(n,{children:["présent dans ",e.jsx(s,{children:"listTenants"})]}),", donc dans les tenants que cet admin peut réellement voir. Un id périmé ou fabriqué retombe sur le tenant de l'admin plutôt que d'afficher un Studio vide."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Course au chargement"})," — l'effet"," ",e.jsx(s,{children:"return"})," tôt (il attend) quand un paramètre"," ",e.jsx(s,{children:"tenant"})," est présent et que"," ",e.jsx(s,{children:"tenants === undefined"})," ; sans cela, le repli"," ",e.jsx(s,{children:"user.tenantId"})," s'appliquait dès sa résolution et écrasait un ",e.jsx(s,{children:"?tenant="})," pourtant valide avant l'arrivée de la liste."]})]}),e.jsxs(d,{tone:"info",title:"L'aperçu résout pour le tenant ÉDITÉ, pas pour l'admin",children:[e.jsx(s,{children:"editedIsMainTenant = isMainTenantSlug(selectedTenant?.slug)"})," ","(",e.jsx(s,{children:"shared/tenant-slug.ts"}),") et"," ",e.jsx(s,{children:"editedPrimaryLogo = resolvePrimaryLogo(...)"})," sont calculés pour le locataire sélectionné dans le rail, puis passés à"," ",e.jsx(s,{children:"StaticBrandedSpinner"})," et à"," ",e.jsx(s,{children:"resolveBrandGlyph"})," dans ",e.jsx(s,{children:"MapFields"}),","," ",e.jsx(s,{children:"LoadingFields"})," et ",e.jsx(s,{children:"branding-studio-preview.tsx"}),". Les aperçus inline et la miniature montrent donc ce que"," ",e.jsx(n,{children:"ce"})," tenant verra : pin nue et spinner générique pour un tenant secondaire sans logo, jamais l'asset Polara du super-admin."]}),e.jsxs(i,{children:["Nouveaux champs ",e.jsx(s,{children:"brandingSettings"})," (Branding v2) :"," ",e.jsx(s,{children:"ctrlbxLabel"}),", ",e.jsx(s,{children:"headerButton1Enabled"}),","," ",e.jsx(s,{children:"headerButton2Enabled"})," (undefined = activé),"," ",e.jsx(s,{children:"headerButton1LogoUrl"}),". La redaction SMTP est conservée (les secrets sont masqués à la lecture, réécrits seulement si saisis)."]}),e.jsxs(i,{children:["Champs Rapports (Branding v2 Task 7) : ",e.jsx(s,{children:"reportTaglineFr"})," /"," ",e.jsx(s,{children:"reportTaglineEn"})," (slogan de couverture/dos par langue de rapport ; repli langue du rapport → autre langue → slogan Polara, via"," ",e.jsx(s,{children:"report-branding.ts::resolveReportTagline"}),") et"," ",e.jsx(s,{children:"reportContactLines"})," (tableau de chaînes, une par ligne rendue au dos — la zone de texte du Studio est découpée sur les sauts de ligne à l'enregistrement ; tableau vide = adresses Polara). Les couleurs"," ",e.jsx(s,{children:"reportPrimaryColor"})," (accent, défaut ",e.jsx(s,{children:"#EF4923"}),") et"," ",e.jsx(s,{children:"reportSecondaryColor"})," (profonde, défaut ",e.jsx(s,{children:"#0E1638"}),") s'appliquent désormais réellement aux deux documents PDF (entretien et service) ; non configurés, le rendu Polara est identique au pixel près."," ",e.jsx(s,{children:"use-branding.tsx"})," expose les valeurs brutes (",e.jsx(s,{children:"reportPrimaryColorRaw"}),"/",e.jsx(s,{children:"reportSecondaryColorRaw"}),","," ",e.jsx(s,{children:"null"})," si non définies) consommées par les deux pages de détail."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Replis Polara réservés au tenant principal."})," ",e.jsx(s,{children:"resolveReportTagline(language, custom, fallback, isMainTenant)"})," ","prend un quatrième argument (défaut ",e.jsx(s,{children:"true"}),", donc les appelants existants sont inchangés) : sans slogan configuré, un tenant secondaire imprime une chaîne ",e.jsx(n,{children:"vide"})," plutôt que le slogan Polara. Symétriquement,"," ",e.jsx(s,{children:"resolveReportContactBlock(contactLines, isMainTenant)"})," ","(nouveau, avec la constante ",e.jsx(s,{children:"POLARA_CONTACT_BLOCK"})," ","partagée par les deux documents) retourne"," ",e.jsx(s,{children:'{ kind: "custom" }'})," dès que le tenant a saisi des lignes, ",e.jsx(s,{children:'{ kind: "polara" }'})," (Longueuil / Newark) sur le tenant principal seulement, et"," ",e.jsx(s,{children:'{ kind: "none" }'})," ailleurs — le dos de rapport omet alors tout le bloc. Les ",e.jsx(n,{children:"couleurs"})," par défaut, elles, restent communes à tous les tenants."]}),e.jsxs(i,{children:["Tous les uploads passent par ",e.jsx(n,{children:"Convex Storage"})," :"]}),e.jsx(c,{language:"ts",children:`const uploadUrl = await generateUploadUrl(
28);
29await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": file.type }, body: file });
30const { storageId } = await result.json();
31const url = await convexClient.query(api.files.queries.getStorageUrl, { storageId });
32await updateTenantBranding({ tenantId, companyLogoUrl: url });`})]}),e.jsxs(t,{id:"menu-customization",title:"22a. Personnalisation des menus (Super-admin, par locataire)",children:[e.jsxs(i,{children:["Fichiers : ",e.jsx(s,{children:"client/src/components/configuration/menu-customization.tsx"})," ","(éditeur, rendu comme section"," ",e.jsx(a,{children:"Configuration → Plateforme ChargeOps → Menus"}),") ; le"," ",e.jsx(s,{children:"MENU_REGISTRY"})," (",e.jsx(s,{children:"client/src/lib/menu-registry.ts"}),") — le catalogue déclaratif de ce qui est renommable/réordonnable par surface ; le noyau pur sans React (",e.jsx(s,{children:"client/src/lib/menu-customization.ts"}),") — types, "," ",e.jsx(s,{children:"menuItemId"}),", ",e.jsx(s,{children:"resolveLabel"}),", ",e.jsx(s,{children:"applyOrder"}),","," ",e.jsx(s,{children:"applyMenuCustomization"})," ; et l'écriture,"," ",e.jsx(s,{children:"platform.mutations.updateTenantMenuCustomization"}),", une"," ",e.jsx(s,{children:"platformMutation"})," (donc ",e.jsx(s,{children:"requirePlatformAdmin"})," —"," ",e.jsx(s,{children:"platform-support"})," ne peut pas écrire), symétrique de"," ",e.jsx(s,{children:"updateTenantBranding"})," : elle ne patch que le champ"," ",e.jsx(s,{children:"menuCustomization"})," de la ligne ",e.jsx(s,{children:"brandingSettings"})," du tenant ciblé (les champs de branding, y compris les secrets SMTP, ne sont jamais touchés)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Surfaces"})," (",e.jsx(s,{children:"MENU_SURFACES"}),") : ",e.jsx(s,{children:"topMenu"})," (barre de navigation du haut), ",e.jsx(s,{children:"subnav.maintenance"}),","," ",e.jsx(s,{children:"subnav.vehicles"}),", ",e.jsx(s,{children:"subnav.rma"}),","," ",e.jsx(s,{children:"subnav.warehouse"}),", ",e.jsx(s,{children:"subnav.support"})," (les cinq sous-menus de section pilotés par les composants d'onglets partagés — l'onglet « RSA » fait partie de la surface Maintenance, ce n'est pas une surface séparée),"," ",e.jsx(s,{children:"profileItem"})," et ",e.jsx(s,{children:"profileSections"})," (les liens et les en-têtes de section du menu profil, réordonnables indépendamment)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Contrat des identifiants"})," : chaque élément personnalisable est adressé par un id namespacé globalement unique,"," ",e.jsx(s,{children:"menuItemId(surface, localKey)"})," — composé en"," ",e.jsx(s,{children:'"surface:localKey"'}),". Les id des menus réellement rendus, ceux du registre de l'éditeur et la composition faite par l'éditeur doivent tous concorder — un décalage se manifeste comme « le renommage n'a aucun effet »."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Modèle de données"})," : ",e.jsx(s,{children:"brandingSettings.menuCustomization"})," ","(par tenant) — ",e.jsx(s,{children:"{ labels?: Record<itemId, { fr?, en? }>, order?: Record<surfaceId, itemId[]> }"}),". Lu app-large via ",e.jsx(s,{children:"useBranding()"}),". Comportement par défaut"," ",e.jsx(n,{children:"inerte"})," : sans personnalisation enregistrée,"," ",e.jsx(s,{children:"resolveLabel"})," et ",e.jsx(s,{children:"applyOrder"})," renvoient l'entrée inchangée et chaque menu se rend exactement comme avant cette fonctionnalité."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Libellés"})," : un champ vide (FR ou EN) retombe automatiquement sur le libellé intégré — jamais de chaîne vide persistée."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Préremplissage bilingue de l'éditeur"})," :"," ",e.jsx(s,{children:"registryDefaultLabels()"})," (",e.jsx(s,{children:"menu-registry.ts"}),") résout le nom intégré de chaque id dans les ",e.jsx(n,{children:"deux"})," langues via"," ",e.jsx(s,{children:'getTranslation("fr"/"en", …)'}),", et le brouillon de l'éditeur porte donc du texte FR/EN complet pour chaque ligne. Les aides pures de"," ",e.jsx(s,{children:"client/src/lib/menu-editor-draft.ts"})," empêchent ce confort de transformer chaque ligne affichée en override : ",e.jsx(s,{children:"splitSavedLabels"})," amorce le brouillon (défauts, écrasés par les overrides enregistrés) et met de côté en"," ",e.jsx(s,{children:"orphans"})," les overrides dont l'id ne figure plus au registre ;"," ",e.jsx(s,{children:"collectLabelOverrides"})," rediffe le brouillon vers la carte creuse enregistrée — une ligne restée (ou revenue) à son défaut n'écrit rien, et les orphelins traversent l'enregistrement intacts. ",e.jsx(s,{children:"isRowDirty"})," (non rogné) garde le bouton « Réinitialiser » accessible depuis un champ à demi vidé, tandis que l'encadré de ligne suit ",e.jsx(s,{children:"overrideForRow"})," — donc ce qui serait réellement enregistré."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Chargement des tables"})," : la table FR est importée paresseusement, et tant qu'elle n'a pas atterri ",e.jsx(s,{children:'getTranslation("fr", …)'})," renvoie l'anglais. L'éditeur attend donc ",e.jsx(s,{children:'ensureLanguageLoaded("fr")'})," ","avant de calculer ses défauts et d'amorcer le brouillon — sinon la colonne FR serait semée d'anglais, puis réamorcée en écrasant la saisie en cours. Un échec de ce chargement ne compte ",e.jsx(n,{children:"pas"})," comme « prêt » : l'éditeur affiche une erreur et refuse d'enregistrer, car un override FR dont le texte coïncide avec le libellé anglais intégré serait sinon écarté comme « inchangé ». Le bouton « Enregistrer » est de la même façon désactivé tant que le brouillon n'est pas amorcé (et pendant le chargement de la ligne d'un locataire fraîchement sélectionné) : enregistrer un brouillon vide effacerait toute la personnalisation du locataire."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Ordre"})," : ",e.jsx(s,{children:"applyOrder"})," place d'abord les id présents dans l'ordre enregistré (dans cet ordre), puis ajoute les id absents à la suite dans leur ordre relatif d'origine — un ",e.jsx(n,{children:"élément nouvellement livré"})," n'est donc ",e.jsx(n,{children:"jamais masqué"})," par un ordre déjà enregistré."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Tiroir mobile"})," : reçoit uniquement les renommages du"," ",e.jsx(n,{children:"menu principal"})," (appliqués par correspondance d'id de route) ; les renommages du menu profil et des sous-menus ne l'affectent pas, et le réordonnancement reste réservé à l'ordinateur — limitation v1 volontaire."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"Éditeur"})," : même schéma que le Studio d'image de marque — rail de locataires cherchable (",e.jsx(s,{children:"ny-card"}),", ",e.jsx(s,{children:"Input"})," de recherche), puis une carte ",e.jsx(s,{children:"ny-card"})," par surface du registre contenant une liste"," ",e.jsx(s,{children:"Reorder.Group"})," (Framer Motion) d'éléments glissables, chacun avec deux champs ",e.jsx(s,{children:"Input"})," (FR/EN) et un bouton de réinitialisation par élément ; un bouton ",e.jsx(s,{children:"Button"})," « Enregistrer » en haut écrit le brouillon via"," ",e.jsx(s,{children:"updateTenantMenuCustomization"}),". La surface"," ",e.jsx(s,{children:"profileItem"})," fait exception : ses lignes sont regroupées par"," ",e.jsx(s,{children:"MenuRegistryItem.section"})," sous un sous-titre par section, avec"," ",e.jsxs(n,{children:["un ",e.jsx(s,{children:"Reorder.Group"})," par section"]}),", si bien qu'un élément ne se déplace qu'à l'intérieur de sa section — ce qui reflète la façon dont"," ",e.jsx(s,{children:"profile-menu.tsx"})," applique l'ordre ",e.jsx(s,{children:"profileItem"})," enregistré"," ",e.jsx(n,{children:"à l'intérieur de chaque section"})," (les déplacements entre sections se font en réordonnant ",e.jsx(s,{children:"profileSections"}),"). Un glisser reconstruit l'ordre plat de la surface comme la concaténation de l'ordre de chaque section ; les identifiants stockés et la sémantique d'",e.jsx(s,{children:"applyOrder"})," restent inchangés."]})]}),e.jsx(t,{id:"logo-societe",title:"23. Logo de société",children:e.jsxs(l,{children:[e.jsxs(r,{children:["Upload via"," ",e.jsx(s,{children:'<Input type="file" accept="image/*">'})," (limite serveur ~5 Mo, formats PNG/JPG/WebP)."]}),e.jsxs(r,{children:["Affichage : aperçu ",e.jsx(s,{children:"h-24 w-auto object-contain"})," bordé."]}),e.jsxs(r,{children:["Champ Convex : ",e.jsx(s,{children:"brandingSettings.companyLogoUrl"})," ","(optionnel, string URL)."]}),e.jsxs(r,{children:["État vide : icône ",e.jsx(s,{children:"<ImageIcon>"})," dans une zone tiretée (",e.jsx(s,{children:"border-dashed"}),")."]})]})}),e.jsx(t,{id:"org-sous-marque",title:"23a. Sous-image de marque par organisation",children:e.jsxs(i,{children:["Une couche facultative ",e.jsx(n,{children:"un cran sous le tenant"})," : la table ",e.jsx(s,{children:"organizations"})," porte"," ",e.jsx(s,{children:"brandingLogoUrl"}),", ",e.jsx(s,{children:"reportPrimaryColor"}),","," ",e.jsx(s,{children:"reportSecondaryColor"}),", ",e.jsx(s,{children:"reportTaglineFr"})," /"," ",e.jsx(s,{children:"reportTaglineEn"}),", écrits par"," ",e.jsx(s,{children:"organizations/mutations.ts::update"})," (tri-état :"," ",e.jsx(s,{children:"undefined"})," = inchangé, ",e.jsx(s,{children:"null"})," = efface)."," ",e.jsx(s,{children:"convex/lib/orgBranding.ts::resolveOrgBranding"})," applique la chaîne ",e.jsxs(n,{children:["organisation → ",e.jsx(s,{children:"brandingSettings"})," du tenant → défaut plateforme"]})," (chaque champ absent hérite). Consommée par l'en-tête du portail opérateur (",e.jsx(s,{children:"portal-layout.tsx"}),") et les PDF de rapport (",e.jsx(s,{children:"report-document.tsx"})," / ",e.jsx(s,{children:"service-report-document.tsx"}),"). Tests : ",e.jsx(s,{children:"convex/lib/__tests__/orgBranding.test.ts"}),"."]})}),e.jsxs(t,{id:"branded-logo",title:"24. Composant <BrandedLogo>",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/branded-logo.tsx"})," — wrapper"," ",e.jsx(s,{children:"<img>"})," mémorisé qui résout le bon logo selon"," ",e.jsx(s,{children:"brand"})," et ",e.jsx(s,{children:"theme"})," :"]}),e.jsx(c,{language:"tsx",children:`<BrandedLogo brand="polara" />              // défaut
33<BrandedLogo brand="chargeops" className="h-4 w-auto" />`}),e.jsxs(i,{children:[e.jsx(n,{children:"Proportions garanties."})," Le composant fusionne"," ",e.jsx(s,{children:"object-contain"})," avec le ",e.jsx(s,{children:"className"})," de l'appelant (via ",e.jsx(s,{children:"cn()"}),") : si une barre trop étroite écrase la boîte de l'image, la marque se réduit proportionnellement au lieu de s'étirer. Le rapport largeur/
33hauteur est donc structurel, pas à la charge de chaque appelant."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Logique de résolution"})," (",e.jsx(s,{children:"useBranding().getLogoForTheme"}),", désormais"," ",e.jsx(n,{children:"nullable"})," pour la marque ",e.jsx(s,{children:"polara"})," — la marque principale — via ",e.jsx(s,{children:"lib/primary-logo.ts::resolvePrimaryLogo"}),", qui dépend de ",e.jsx(s,{children:"users.me.isMainTenant"}),") :"]}),e.jsx(u,{headers:["Brand","Theme","Source (priorité décroissante)"],rows:[[e.jsx(s,{children:"polara"}),e.jsx(s,{children:"light"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"branding.polaraLogoUrl"})," →"," ",e.jsx(s,{children:"branding.companyLogoUrl"})," (locataires secondaires seulement) → locataire principal/inconnu :"," ",e.jsx(s,{children:"polaraLogoDefault"})," (asset bundle) ; secondaire :"," ",e.jsx(s,{children:"null"})," (rien n'est rendu)"]})],[e.jsx(s,{children:"polara"}),e.jsx(s,{children:"dark"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"branding.polaraLogoDarkUrl"})," →"," ",e.jsx(s,{children:"branding.polaraLogoUrl"})," →"," ",e.jsx(s,{children:"branding.companyLogoUrl"})," (secondaires) → défaut Polara (principal/inconnu) ou ",e.jsx(s,{children:"null"})]})],[e.jsx(s,{children:"chargeops"}),e.jsx(s,{children:"light"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"branding.chargeopsLogoUrl"})," →"," ",e.jsx(s,{children:"chargeOpsLogoDefault"})]})],[e.jsx(s,{children:"chargeops"}),e.jsx(s,{children:"dark"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"branding.chargeopsLogoDarkUrl"})," →"," ",e.jsx(s,{children:"branding.chargeopsLogoUrl"})," →"," ",e.jsx(s,{children:"chargeOpsLogoDarkDefault"})]})]]}),e.jsx(i,{children:"Chaque variante peut être uploadée séparément dans le groupe « Identité » du Studio d'image de marque. Les variantes sombres sont prévisualisées sur leur vrai fond marine pour vérifier le contraste."}),e.jsxs(i,{children:[e.jsx(n,{children:"Résolution nullable :"})," quand"," ",e.jsx(s,{children:"resolvePrimaryLogo"})," retourne ",e.jsx(s,{children:"null"})," (un locataire secondaire sans logo), ",e.jsx(s,{children:"BrandedLogo"})," ne rend"," ",e.jsx(n,{children:"rien"})," (pas d'",e.jsx(s,{children:"<img>"}),") et les séparateurs adjacents des en-têtes sont masqués. La même marque principale résolue (",e.jsx(s,{children:"useBranding().primaryLogo"})," /"," ",e.jsx(s,{children:"primaryLogoDark"}),", nullables) se propage aux documents PDF (",e.jsx(s,{children:"report-document.tsx"}),","," ",e.jsx(s,{children:"service-report-document.tsx"}),") : couverture et dos de rapport omettent le bloc logo quand elle est ",e.jsx(s,{children:"null"}),". Le logo Polara intégré n'apparaît donc que sur l'espace de travail principal (ou sur l'hôte générique — apex, ",e.jsx(s,{children:"www"}),","," ",e.jsx(s,{children:"localhost"}),", prévisualisations — où aucun locataire n'est identifiable)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Résolution avant la connexion (host-aware)."})," ",e.jsx(s,{children:"api.system.queries.getBrandingSettings"})," est une requête"," ",e.jsx(n,{children:"authentifiée"})," : elle vaut ",e.jsx(s,{children:"undefined"})," ","avant login, ce qui faisait retomber les pages publiques sur l'asset Polara sur ",e.jsx(n,{children:"tous"})," les sous-domaines."," ",e.jsx(s,{children:"useBranding()"})," lit désormais aussi"," ",e.jsx(s,{children:"useTenantHost()"})," et utilise le branding public du tenant de l'hôte (",e.jsx(s,{children:"hostTenant"}),") comme source de repli des logos et du ",e.jsx(s,{children:"loadingAnimationType"}),". Les deux appels de hooks restent inconditionnels ; le ",e.jsx(s,{children:"try/catch"})," n'absorbe que le throw « provider manquant » des harnais de test isolés."]}),e.jsx(u,{headers:["Contexte","isMainTenant","Source du branding"],rows:[["Session résolue",e.jsx(s,{children:"user.isMainTenant ?? false"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"getBrandingSettings"})," (ligne authentifiée du tenant)"]})],[e.jsxs(e.Fragment,{children:["Avant login, sous-domaine ",e.jsx(s,{children:"<slug>"})]}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"false"})," — ",e.jsx(s,{children:"MAIN_TENANT_SLUG"})," est réservé, donc un ",e.jsx(s,{children:"hostSlug"})," résolu est toujours secondaire"]}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"hostTenant"})," (",e.jsx(s,{children:"getTenantBySlug"}),", requête publique)"]})],["Avant login, hôte générique (apex, www, localhost)",e.jsx(s,{children:"true"}),"Aucune — assets par défaut (identité Polara/ChargeOps)"]]}),e.jsxs(i,{children:["Le hook expose deux nouvelles valeurs :"," ",e.jsx(s,{children:"isMainTenant"}
33)," (l'axe unique dont dépendent tous les replis de marque, pour éviter que chaque surface le re-dérive) et"," ",e.jsx(s,{children:"tenantName"})," (le nom à afficher dans la copie visible — pied de page, écran d'espace suspendu —, issu de la session ou, avant login, du sous-domaine ; ",e.jsx(s,{children:"null"})," sur l'hôte générique)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Emplacements des pages d'authentification."})," Sur"," ",e.jsx(s,{children:"auth-page.tsx"}),", le logo du tenant hôte occupait la"," ",e.jsx(n,{children:"carte de connexion"})," (à la place de la marque ChargeOps) pendant que la marque principale, en haut à gauche, restait Polara — soit exactement l'inverse du modèle. Désormais : le ",e.jsx(n,{children:"slot primaire"})," (haut-gauche) porte"," ",e.jsx(s,{children:'<BrandedLogo brand="polara">'})," — donc le logo du tenant, ou rien — et la carte garde"," ",e.jsx(s,{children:'<BrandedLogo brand="chargeops">'}),", marque de la plateforme. L'inversion ",e.jsx(s,{children:"dark:brightness-0 dark:invert"})," ","n'est appliquée que sur l'hôte principal (elle ne convient qu'au wordmark monochrome Polara ; le logo d'un tenant s'affiche tel quel) et la ligne de copyright utilise"," ",e.jsx(s,{children:'user?.tenantName || hostTenant?.name || "Polara"'}),". Même traitement sur ",e.jsx(s,{children:"forgot-password.tsx"})," et"," ",e.jsx(s,{children:"reset-password.tsx"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Textes alternatifs."})," Les ",e.jsx(s,{children:"alt"})," du slot primaire ne nomment plus Polara (",e.jsx(s,{children:"suspended-workspace.tsx"})," et les pages d'auth au nom du tenant) — un ",e.jsx(s,{children:"alt"})," codé « Polara » aurait fuité la marque sur les lecteurs d'écran et les images cassées d'un tenant secondaire."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Navigation :"})," ",e.jsx(s,{children:"BrandedLogo"})," ne rend qu'un"," ",e.jsx(s,{children:"<img>"})," (aucune logique de lien). Pour ramener à l'accueil, le logo ChargeOps est enveloppé dans un"," ",e.jsx(s,{children:'<Link href="/">'})," wouter (",e.jsx(s,{children:'data-testid="link-logo-home"'}),") dans les deux en-têtes desktop :"," ",e.jsx(s,{children:"app-layout.tsx"})," et l'en-tête propre de"," ",e.jsx(s,{children:"home.tsx"}),". Sur téléphone, l'accueil est le premier onglet de la coquille téléphone. Le logo Polara et la pastille « Tableau de bord » restent inchangés."]})]}),e.jsxs(t,{id:"map-markers",title:"25. Marqueurs cartes personnalisés",children:[e.jsxs(i,{children:["Champ : ",e.jsx(s,{children:"brandingSettings.mapMarkerMode"})," ∈"," ",e.jsx(s,{children:'"none" | "polara" | "custom"'}),"."]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(s,{children:"none"})," — marqueur Google Maps standard (pin gris)."]}),e.jsxs(r,{children:[e.jsx(s,{children:"polara"})," (défaut) — marqueur teardrop avec dégradé bleu-rouge (",e.jsx(s,{children:"marker-pin.expanded"}),") portant"," ",e.jsx(n,{children:"la marque par défaut du tenant"})," : l'épingle Polara (",e.jsx(s,{children:"Polara-Marker"}),") sur le tenant principal, la marque principale résolue sur un tenant secondaire, et"," ",e.jsx(n,{children:"aucun glyphe"})," quand celui-ci n'a pas de logo."]}),e.jsxs(r,{children:[e.jsx(s,{children:"custom"})," — image uploadée (",e.jsx(s,{children:"brandingSettings.customMapMarkerUrl"}),")."]})]}),e.jsxs(i,{children:["L'aperçu et l'upload se font dans le groupe « Présence cartographique » du Studio (upload visible seulement si"," ",e.jsx(s,{children:'mapMarkerMode === "custom"'}),"). Le mode s'applique sur la carte des clients ",e.jsx(n,{children:"et"})," la carte des modems (parité ",e.jsx(s,{children:'"none"'})," ajoutée à"," ",e.jsx(s,{children:"modems-map.tsx"})," en Branding v2)."]}),e.jsxs(i,{children:["Le hook ",e.jsx(s,{children:"client/src/hooks/use-brand-marker.ts::useBrandedMapMarker"})," ","— jumeau de ",e.jsx(s,{children:"useBrandedLoaderImage()"}),", même chaîne"," ",e.jsx(s,{children:"lib/brand-glyph.ts"})," — est la seule source des deux cartes. Il retourne ",e.jsx(s,{children:"null"})," dans deux cas confondus à l'affichage : mode ",e.jsx(s,{children:'"none"'}),", ou tenant secondaire sans logo ; l'appelant dessine alors l'épingle colorée seule."]})]}),e.jsxs(t,{id:"loading-animation",title:"26. Loading animation",children:[e.jsxs(i,{children:["Champ : ",e.jsx(s,{children:"brandingSettings.loadingAnimationType"})," ∈"," ",e.jsx(s,{children:'"polara" | "custom" | "standard"'})," (cf. section 6)."]}),e.jsxs(i,{children:["Groupe « Chargement » du Studio : trois options + zone d'upload conditionnelle si ",e.jsx(s,{children:"custom"})," ; l'aperçu en direct joue l'animation réellement configurée. Depuis Branding v2,"," ",e.jsx(n,{children:"toutes"})," les surfaces de chargement passent par"," ",e.jsx(s,{children:"branded-spinner.tsx"})," et honorent ce champ (cf. la note de la section 6), y compris le glyphe du pied de page."]}),e.jsxs(i,{children:["Le mode ",e.jsx(s,{children:'"polara"'})," se lit maintenant « la marque par défaut de ce tenant » : voir la chaîne partagée de la section 26a."]})]}),e.jsxs(t,{id:"brand-glyph",title:"26a. Chaîne partagée des glyphes secondaires — brand-glyph.ts",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/lib/brand-glyph.ts::resolveBrandGlyph"})," — pur et sans effet de bord, jumeau de"," ",e.jsx(s,{children:"lib/primary-logo.ts::resolvePrimaryLogo"})," (qui, lui, gère le ",e.jsx(n,{children:"wordmark principal"}),"). Il résout les deux"," ",e.jsx(n,{children:"glyphes secondaires"}
33),", ces petites marques carrées qui retombaient toutes deux en dur sur un asset Polara :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["le ",e.jsx(n,{children:"glyphe de chargement"})," (",e.jsx(s,{children:"loadingAnimationType"})," /"," ",e.jsx(s,{children:"customLoadingAnimationUrl"}),") — consommé par"," ",e.jsx(s,{children:"useBrandedLoaderImage()"}),", donc tous les loaders de page/section/onglet, le glyphe du pied de page et le héros de l'accueil ;"]}),e.jsxs(r,{children:["le ",e.jsx(n,{children:"marqueur de carte"})," (",e.jsx(s,{children:"mapMarkerMode"})," /"," ",e.jsx(s,{children:"customMapMarkerUrl"}),") — consommé par"," ",e.jsx(s,{children:"useBrandedMapMarker()"}),", donc la carte des clients et la carte des modems."]})]}),e.jsx(i,{children:"Ordre de résolution, identique pour les deux surfaces :"}),e.jsxs(p,{children:[e.jsxs(r,{children:["mode d'",e.jsx(n,{children:"arrêt explicite"})," (",e.jsx(s,{children:'"standard"'})," ","pour le loader, ",e.jsx(s,{children:'"none"'})," pour le marqueur) →"," ",e.jsx(s,{children:"null"}),", sur tous les tenants ;"]}),e.jsxs(r,{children:[e.jsx(s,{children:'"custom"'})," + un téléversement pour cette surface → ce téléversement, sur tous les tenants ;"]}),e.jsxs(r,{children:["sinon (y compris ",e.jsx(s,{children:'"polara"'})," et ",e.jsx(s,{children:"undefined"}),") → ",e.jsx(n,{children:"la marque par défaut de ce tenant"})," : l'asset Polara intégré si ",e.jsx(s,{children:"isMainTenant"}),", sinon la marque principale résolue du tenant (",e.jsx(s,{children:"primaryLogo"}),"), et"," ",e.jsx(s,{children:"null"})," quand celle-ci est nulle."]})]}),e.jsxs(d,{tone:"info",title:"Aucune migration de données",children:["La valeur d'énumération stockée ",e.jsx(s,{children:'"polara"'})," est"," ",e.jsx(n,{children:"inchangée"})," en base — seule son interprétation change. Les lignes ",e.jsx(s,{children:"brandingSettings"})," existantes, les validateurs Convex et le schéma partagé ne bougent pas."]})]}),e.jsxs(t,{id:"favicon-metadata",title:"26b. Favicon par tenant et métadonnées statiques",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/hooks/use-branded-favicon.ts::useBrandedFavicon"}),", monté une fois via le composant ",e.jsx(s,{children:"BrandedFavicon"})," de"," ",e.jsx(s,{children:"App.tsx"})," (rend ",e.jsx(s,{children:"null"}),"). L'application est un"," ",e.jsx(n,{children:"build statique unique"})," servi à l'octet près à tous les sous-domaines : le ",e.jsx(s,{children:'link rel="icon"'})," de"," ",e.jsx(s,{children:"client/index.html"})," portait donc le glyphe Polara dans l'onglet et les favoris de ",e.jsx(n,{children:"tous"})," les tenants."]}),e.jsxs(l,{children:[e.jsxs(r,{children:["tenant (n'importe lequel) avec un"," ",e.jsx(n,{children:"favicon explicite"})," (",e.jsx(s,{children:"brandingSettings.faviconUrl"}),", téléversé dans le groupe Identité du Studio) → ce favicon — il gagne"," ",e.jsx(n,{children:"même sur le tenant principal"})," ;"]}),e.jsxs(r,{children:["tenant principal (ou hôte générique) sans favicon → les liens du markup sont ",e.jsx(n,{children:"laissés intacts"})," ;"]}),e.jsxs(r,{children:["tenant secondaire avec logo → ",e.jsx(s,{children:"href"})," réécrit vers ce logo, l'attribut ",e.jsx(s,{children:"sizes"})," est retiré (le téléversement est une image de taille arbitraire, un indice périmé ferait choisir la mauvaise) ;"]}),e.jsxs(r,{children:["tenant secondaire sans favicon ni logo → la marque"," ",e.jsx(n,{children:"ChargeOps"})," neutre."]})]}),e.jsxs(i,{children:[e.jsx(n,{children:"Plomberie du favicon explicite."})," Le champ optionnel"," ",e.jsx(s,{children:"brandingSettings.faviconUrl"})," s'écrit via"," ",e.jsx(s,{children:"convex/platform/mutations.ts::updateTenantBranding"})," ","(platform-admin, comme tout le branding) et remonte par deux chemins : la liste blanche d'identité visuelle de"," ",e.jsx(s,{children:"convex/public/branding.ts::getTenantBySlug"})," avant login (type ",e.jsx(s,{children:"HostTenant"})," de ",e.jsx(s,{children:"use-tenant-host.tsx"})," — l'onglet de la page de connexion est donc déjà brandé) et"," ",e.jsx(s,{children:"system/queries.ts::getBrandingSettings"})," après login ;"," ",e.jsx(s,{children:"useBranding()"})," expose ",e.jsx(s,{children:"faviconUrl"})," depuis l'une ou l'autre source. Côté Studio, l'emplacement « Favicon (onglet du navigateur) » siège à côté du logo du locataire (même ",e.jsx(s,{children:"LogoSlot"})," glisser-déposer, image carrée 32×32 px ou plus recommandée) et l'aperçu en direct (",e.jsx(s,{children:"branding-studio-preview.tsx"}),") rend un mini-onglet de navigateur au-dessus de la miniature (",e.jsx(s,{children:'data-testid="preview-browser-tab"'})," /"," ",e.jsx(s,{children:"preview-favicon"}),") montrant le favicon résolu et le nom du tenant."]}),e.jsx(i,{children:"L'effet mémorise les valeurs d'origine et les restaure au cleanup — un changement de tenant ou un téléversement de logo dans la même session SPA ne laisse pas d'icône périmée."}),e.jsxs(d,{tone:"warning",title:"Limite connue : apple-touch-icon et manifeste",children:["L'",e.jsx(s,{children:"apple-touch-icon"})," et les icônes du"," ",e.jsx(s,{children:"manifest.json"})," sont lus dans le HTML servi"," ",e.jsx(n,{children:"au moment de l'installation"})," et ne peuvent pas être remplacés après le premier rendu : ils restent sur l'asset statique, même quand un favicon explicite est téléversé. Un raccourci d'écran d'accueil garde donc l'icône de la plateforme."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Métadonnées statiques."})," Comme un seul build est servi à tous les tenants, les chaînes qui nommaient « Polara ChargeOps » sont devenues « ChargeOps » :"," ",e.jsx(s,{children:"og:site_name"}),", ",e.jsx(s,{children:"og:title"})," et"," ",e.jsx(s,{children:"twitter:title"})," dans ",e.jsx(s,{children:"client/index.html"}),", et"," ",e.jsx(s,{children:"name"})," dans ",e.jsx(s,{children:"client/public/manifest.json"})," (",e.jsx(s,{children:"short_name"})," valait déjà ",e.jsx(s,{children:"ChargeOps"}),")."]})]}),e.jsx(t,{id:"footer-attribution",title:"26c. Attribution du pied de page",children:e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/ui/footer.tsx"})," imprimait"," ",e.jsxs(s,{children:["© ","{année}"," Polara / Orbite Tech"]})," avec les liens vers"," ",e.jsx(s,{children:"polara.energy"})," et ",e.jsx(s,{children:"orbitetech.ca"})," sur"," ",e.jsx(n,{children:"tous"})," les tenants. Le bloc est désormais conditionné à ",e.jsx(s,{children:"useBranding().isMainTenant"})," ; un tenant secondaire est crédité par ",e.jsx(s,{children:"tenantName"})," (texte simple, sans lien). Le glyphe du pied de page, lui, suit la chaîne"," ",e.jsx(s,{children:"brand-glyph.ts"})," comme les autres loaders."]})}),e.jsxs(t,{id:"operator-name",title:"26d. Vocabulaire d'exploitant — useBranding().operatorName",children:[e.jsxs(i,{children:["Certaines chaînes ne décrivent pas une ",e.jsx(n,{children:"marque affichée"})," ","mais l'",e.jsx(n,{children:"exploitant nommé"})," dans une phrase métier :"," ",e.jsx(s,{children:"soldByPolara"}),", ",e.jsx(s,{children:"notSoldByPolara"}),","," ",e.jsx(s,{children:"thirdPartyChargerDesc"}),", ",e.jsx(s,{children:"noWarrantyNeeded"}),","," ",e.jsx(s,{children:"polara"}),"/",e.jsx(s,{children:"nonPolara"}),","," ",e.jsx(s,{children:"polaraSourcedPercent"}),","," ",e.jsx(s,{children:"carbonCreditPolaraManaged"}),"/",e.jsx(s,{children:"carbonCreditPolaraDescription"}),","," ",e.jsx(s,{children:"pdfSectionCarbonCredit"}),","," ",e.jsx(s,{children:"polaraTechnicians"}),","," ",e.jsx(s,{children:"polaraCustomerLocations"}),". Elles écrivaient « Polara » en dur, y compris pour le personnel des autres tenants."]}),e.jsxs(i,{children:["Les traductions FR et EN prennent maintenant un paramètre"," ",e.jsx(s,{children:"{brand}"}),", interpolé avec"," ",e.jsx(s,{children:"useBranding().operatorName"})," :"]}),e.jsx(c,{language:"ts",children:`operatorName: isMainTenant
34  ? "Polara"
35  : (user?.tenantName ?? hostTenantName ?? "ChargeOps"),`}),e.jsxs(i,{children:["Les clés d'énumération et les ",e.jsx(n,{children:"valeurs stockées"})," ne bougent pas (",e.jsx(s,{children:"soldByPolara"})," reste le nom du champ et de la colonne d'export) — même contrat que les libellés CHRGPK/CTRLBX. Surfaces migrées : ",e.jsx(s,{children:"operations-dashboard.tsx"}),","," ",e.jsx(s,{children:"operations/brand-model-table.tsx"})," +"," ",e.jsx(s,{children:"lib/export-brand-model-excel.ts"}),","," ",e.jsx(s,{children:"operations/carbon-credit-panel.tsx"}),","," ",e.jsx(s,{children:"operations/export-pdf-dialog.tsx"}),","," ",e.jsx(s,{children:"add-charger-form.tsx"}),","," ",e.jsx(s,{children:"charger-management/charger-edit-dialog.tsx"}),","," ",e.jsx(s,{children:"charger-management.tsx"}),","," ",e.jsx(s,{children:"column-configurator.tsx"})," et"," ",e.jsx(s,{children:"pages/configuration.tsx"}),"."]}),e.jsxs(i,{children:["Au passage, deux libellés du Studio nomment ce qu'ils désignent réellement : « Polara Animation » devient « Animation de l'espace (défaut) » et « Polara Logo (Default) » devient « Logo de l'espace (défaut) » — la"," ",e.jsx(n,{children:"valeur stockée"})," reste ",e.jsx(s,{children:'"polara"'}),". Le titre de l'export Cléo passe de « Polara ChargeOps · » à « ChargeOps · »."]})]}),e.jsxs(t,{id:"chrgpk-label",title:"27. Labels CHRGPK et CTRLBX personnalisables",children:[e.jsxs(i,{children:["Champs : ",e.jsx(s,{children:"brandingSettings.chrgpkLabel"})," (défaut"," ",e.jsx(s,{children:'"CHRGPK"'}),") et ",e.jsx(s,{children:"brandingSettings.ctrlbxLabel"})," ","(défaut ",e.jsx(s,{children:'"CTRLBX"'}),"), édités dans le groupe « Terminologie » du Studio avec exemples en contexte."]}),e.jsxs(i,{children:["Depuis Branding v2, ces libellés renomment"," ",e.jsx(n,{children:"réellement"})," les termes dans toute l'application : JSX codé en dur remplacé par ",e.jsx(s,{children:"useBranding().chrgpkLabel"}),"/",e.jsx(s,{children:"ctrlbxLabel"}),", chaînes de traduction interpolées (",e.jsx(s,{children:"{chrgpkLabel}"}),"), badges de connectivité (map valeur→libellé), en-têtes CSV, page Modems, dialogues et PDF de rapports. Les ",e.jsx(n,{children:"valeurs brutes"})," ne changent jamais : noms de tables, ",e.jsx(s,{children:"appliesTo"}),", valeurs de connectivité"," ",e.jsx(s,{children:"ctrlbx"}),", valeurs CSV, sentinelles"," ",e.jsx(s,{children:"__chrgpk__"}),"/",e.jsx(s,{children:"no-chrgpk"}),", noms de champs."]})]}),e.jsxs(t,{id:"header-buttons",title:"28. Boutons d'en-tête configurables",children:[e.jsxs(i,{children:["Deux boutons (",e.jsx(s,{children:"headerButton1"})," et"," ",e.jsx(s,{children:"headerButton2"}),") dans l'en-tête de l'accueil (",e.jsx(s,{children:"home.tsx"}),"), configurables via le groupe « Actions d'en-tête » du Studio :"]}),e.jsx(u,{headers:["Champ","Défaut","Usage"],rows:[[e.jsx(s,{children:"headerButton1Enabled"}),e.jsx(s,{children:"undefined"}),"undefined = activé ; false masque le bouton"],[e.jsx(s,{children:"headerButton1Text"}),e.jsx(s,{children:'"Cleo"'}),"Lien rapide vers le portail Cleo (le texte par défaut rend le pictogramme Cléo hérité)"],[e.jsx(s,{children:"headerButton1Url"}),"—","URL externe"],[e.jsx(s,{children:"headerButton1LogoUrl"}),"—","Logo personnalisé affiché à la place du texte"],[e.jsx(s,{children:"headerButton2Enabled"}),e.jsx(s,{children:"undefined"}),"undefined = activé ; false masque le bouton"],[e.jsx(s,{children:"headerButton2Text"}),e.jsx(s,{children:'"Back Office Cleo"'}),"Back-office Cleo"],[e.jsx(s,{children:"headerButton2Url"}),"—","URL externe"]]}),e.jsxs(i,{children:["Rendu : la visibilité passe par"," ",e.jsx(s,{children:"lib/branding-labels.ts::shouldShowHeaderButton"}),", qui exige ",e.jsx(s,{children:"enabled"})," ",e.jsx(n,{children:"et"})," un texte"," ",e.jsx(n,{children:"et"})," une URL — un tenant qui activait l'interrupteur en laissant l'URL vide obtenait un bouton silencieusement absent ; le Studio affiche désormais un indice en ligne dans ce cas (le prédicat, lui, est inchangé), et ra
35ppelle que les deux boutons ne vivent que dans l'en-tête de ",e.jsx(s,{children:"home.tsx"}),". Priorité du bouton 1 : logo personnalisé → pictogramme Cléo hérité (texte par défaut « Cleo ») → texte. Le bouton"," ",e.jsx(n,{children:"DocsForge"})," voisin est réservé au locataire principal (",e.jsx(s,{children:"user.isMainTenant"}),")."]}),e.jsxs(i,{children:["Audience de ",e.jsx(s,{children:"headerButton2"})," (« Back Office Cléo ») : sur le tenant principal (Polara), réservé aux rôles ",e.jsx(s,{children:"admin"}),","," ",e.jsx(s,{children:"technician"})," et aux super-admins de la plateforme"," ","(",e.jsx(s,{children:'platformRole === "platform-admin"'}),") ; sur les autres tenants, visible pour tous sauf ",e.jsx(s,{children:"site-operator"}),". La règle (indépendante du toggle d'activation par tenant) vit dans"," ",e.jsx(s,{children:"client/src/lib/back-office-cleo-visibility.ts"})," et s'appuie sur ",e.jsx(s,{children:"me.isMainTenant"}),"."]})]}),e.jsxs(t,{id:"config-email",title:"29. Configuration email",children:[e.jsx(i,{children:'Card "Email Settings" — quatre providers :'}),e.jsx(u,{headers:["Provider","Champs requis","Note"],rows:[[e.jsx(n,{children:"Gmail"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"smtpUser"})," (email Gmail), ",e.jsx(s,{children:"smtpPassword"})," ","(App Password)"]}),"Nécessite un mot de passe d'application Google (2FA)."],[e.jsx(n,{children:"Outlook"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"smtpUser"}),", ",e.jsx(s,{children:"smtpPassword"})]}),"Mot de passe régulier."],[e.jsx(n,{children:"SMTP"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"smtpHost"}),", ",e.jsx(s,{children:"smtpPort"}),","," ",e.jsx(s,{children:"smtpSecure"})," (switch), ",e.jsx(s,{children:"smtpUser"}),","," ",e.jsx(s,{children:"smtpPassword"})]}),"Provider générique."],[e.jsx(n,{children:"SendGrid"}),e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"smtpPassword"})," (API Key, format"," ",e.jsx(s,{children:"SG.xxxxx…"}),")"]}),"Pas de username."]]}),e.jsxs(i,{children:["Tous : ",e.jsx(s,{children:"emailFrom"})," (adresse expéditrice)."]}),e.jsxs(i,{children:[`La carte "Current Active Email Configuration" (sous l'en-tête) affiche le provider actuellement utilisé et sa source (`,e.jsx(s,{children:"database"})," vs ",e.jsx(s,{children:"environment"}),") — les variables d'environnement sont écrasées par les valeurs DB si présentes."]}),e.jsx(i,{children:"Schema Convex (extrait) :"}),e.jsx(c,{language:"ts",children:`brandingSettings: {
36  emailProvider: v.optional(v.string()),
37  smtpHost, smtpPort, smtpSecure, smtpUser, smtpPassword,
38  emailFrom: v.optional(v.string()),
39  // …
40}`}),e.jsxs(i,{children:[e.jsx(n,{children:"Envoi d'un courriel de test."})," ",e.jsx(s,{children:"platform/actions.ts::sendTestEmail"})," rend un courriel de marque avec la config SMTP et les logos résolus du"," ",e.jsx(n,{children:"tenant ciblé"})," et l'expédie à l'appelant — la vérification en un clic de l'adresse d'expéditeur, des identifiants SMTP et des logos, sans attendre un vrai événement. Tests :"," ",e.jsx(s,{children:"convex/platform/__tests__/smtpTestSend.test.ts"}),"."]})]}),e.jsxs(t,{id:"email-brand",title:"29a. Marque de l'en-tête des courriels",children:[e.jsxs(i,{children:[e.jsx(s,{children:"convex/lib/emailLayout.ts::wrapInBrandedLayout"})," accepte un paramètre optionnel ",e.jsx(s,{children:"brand"})," de type"," ",e.jsx(s,{children:"EmailBrand = { isMainTenant: boolean; logoUrl: string | null }"}),". L'en-tête marine a deux emplacements : à gauche la marque de l'expéditeur, à droite la marque ChargeOps de la plateforme (inchangée). L'emplacement de gauche suit la règle habituelle — marque Polara intégrée si ",e.jsx(s,{children:"isMainTenant"}),", sinon le"," ",e.jsx(s,{children:"logoUrl"})," du tenant, et la cellule"," ",e.jsx(n,{children:"omise"})," (",e.jsx(s,{children:"&nbsp;"}),") quand il n'y en a pas. ",e.jsx(s,{children:"brand"})," absent ⇒ comportement historique (tenant principal), donc aucun appelant non migré ne casse."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Dimensionnement des logos : une seule dimension."})," ","Les deux emplacements passent par"," ",e.jsx(s,{children:"headerLogoImg()"}),", qui n'écrit que la"," ",e.jsx(n,{children:"hauteur"})," (22 px), à la fois en attribut HTML"," ",e.jsx(s,{children:"height"})," — pour le moteur Word d'Outlook, qui ignore le CSS — et en style inline, avec"," ",e.jsx(s,{children:"width: auto"})," et un garde-fou"," ",e.jsx(s,{children:"max-width: 200px"}),". Le balisage figeait auparavant"," ",e.jsx(s,{children:'width="103" height="22"'})," sur l'emplacement ChargeOps : tout logo téléversé était comprimé dans le ratio 4,67:1 de l'image intégrée et partait"," ",e.jsx(n,{children:"déformé"})," dans la boîte de réception. La règle est donc : ne jamais contraindre les deux dimensions à la fois — pas d'attribut ",e.jsx(s,{children:"width"}),", pas de ",e.jsx(s,{children:"width"})," en pixels. Le ",e.jsx(s,{children:"src"})," et l'",e.jsx(s,{children:"alt"})," passent par"," ",e.jsx(s,{children:"escapeHtml"}),", une URL de logo ne pouvant plus sortir de son attribut. Invariant verrouillé par"," ",e.jsx(s,{children:"convex/lib/__tests__/emailLayout.test.ts"}),"."]}),e.jsxs(i,{children:["La résolution vit dans"," ",e.jsx(s,{children:"convex/lib/emailBranding.ts::resolveEmailBrand"})," ","(",e.jsx(s,{children:"polaraLogoDarkUrl"})," →"," ",e.jsx(s,{children:"polaraLogoUrl"})," → ",e.jsx(s,{children:"companyLogoUrl"})," ; la variante sombre gagne, l'en-tête étant toujours sur fond marine) et est exposée aux actions par la ",e.jsx(s,{children:"internalQuery"})," ",e.jsx(s,{children:"internal.system.internalQueries.getEmailBrand"})," — un"," ",e.jsx(s,{children:"tenantId"})," absent ou introuvable retombe sur"," ",e.jsx(s,{children:"MAIN_TENANT_EMAIL_BRAND"}),". Câblée dans les courriels de réinitialisation de mot de passe, de bienvenue (",e.jsx(s,{children:"convex/actions/email.ts"}),") et les trois alertes de conformité (",e.jsx(s,{children:"convex/compliance/alerts.ts"}),"). La chaîne de pied de courriel ne dit plus « Polara ChargeOps » mais « ChargeOps » (FR et EN)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Courriels de notification d'événement."}
40)," ",e.jsx(s,{children:"emailNotificationSettings"})," porte désormais un"," ",e.jsx(s,{children:"tenantId"})," optionnel : chaque locataire route"," ",e.jsx(n,{children:"ses propres"})," destinataires et objets via"," ",e.jsx(s,{children:"convex/notifications/settings.ts"})," (",e.jsx(s,{children:"getForTenant"})," / ",e.jsx(s,{children:"updateForTenant"}),","," ",e.jsx(s,{children:"tenantMutation"}),") — ",e.jsx(n,{children:"seul éditeur"})," depuis la fusion des deux systèmes de notifications par courriel. La ligne"," ",e.jsx(n,{children:"globale"})," historique (",e.jsx(s,{children:"tenantId"})," absent) n'a plus d'éditeur : elle ne sert qu'à amorcer la première ligne d'un locataire (forme et langue, jamais les destinataires). Le registre ",e.jsx(s,{children:"EVENT_KEYS"})," (",e.jsx(s,{children:"shared/notification-events.ts"}),", ré-exporté par"," ",e.jsx(s,{children:"convex/notifications/templates.ts"}),") gagne"," ",e.jsx(s,{children:"ticket_created"}),", ",e.jsx(s,{children:"ticket_status_changed"})," ","et ",e.jsx(s,{children:"sla_breached"}),". ",e.jsx(s,{children:"dispatchEventNotification"})," ","reçoit le ",e.jsx(s,{children:"tenantId"})," de l'appelant (",e.jsx(s,{children:"ctx.tenantId"}),"), résout la config du tenant puis retombe sur la globale, et c'est cette marque-là qui part dans l'en-tête."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Corps des courriels d'événement."})," Les six clés du registre ont chacune un gabarit dans"," ",e.jsx(s,{children:"convex/notifications/service.ts::buildEventHtml"}),". Les trois clés support (",e.jsx(s,{children:"ticket_created"}),","," ",e.jsx(s,{children:"ticket_status_changed"}),", ",e.jsx(s,{children:"sla_breached"}),") n'en avaient pas et tombaient sur un"," ",e.jsx(s,{children:'<pre>{JSON.stringify(tokens){"}"}</pre>'})," — un objet de tokens brut expédié au service de soutien. La branche par défaut rend maintenant des lignes libellées issues de"," ",e.jsx(s,{children:"EVENT_TOKEN_DEFS"}),". Les blocs réutilisables vivent dans"," ",e.jsx(s,{children:"convex/lib/emailLayout.ts"})," : ",e.jsx(s,{children:"badge"})," (pastille colorée, tons via ",e.jsx(s,{children:"PRIORITY_TONES"})," /"," ",e.jsx(s,{children:"STATUS_TONES"}),"), ",e.jsx(s,{children:"highlightBlock"}),","," ",e.jsx(s,{children:"textBlock"})," (partagé avec"," ",e.jsx(s,{children:"convex/tickets/actions.ts"}),"), ",e.jsx(s,{children:"transitionBlock"}),","," ",e.jsx(s,{children:"metaLine"}),", ",e.jsx(s,{children:"formatDateTime"})," et un"," ",e.jsx(s,{children:"infoBox"})," qui retire les lignes sans valeur. Les tokens"," ",e.jsx(s,{children:"description"}),", ",e.jsx(s,{children:"category"})," et"," ",e.jsx(s,{children:"chargerNames"})," ont été ajoutés à ",e.jsx(s,{children:"ticket_created"})," ","(",e.jsx(s,{children:"description"})," + ",e.jsx(s,{children:"status"})," à"," ",e.jsx(s,{children:"sla_breached"}),", ",e.jsx(s,{children:"priority"})," à"," ",e.jsx(s,{children:"ticket_status_changed"}),")."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Langue."})," ",e.jsx(s,{children:"emailNotificationSettings"})," porte un champ ",e.jsx(s,{children:"language"})," optionnel (",e.jsx(s,{children:'"en"'})," |"," ",e.jsx(s,{children:'"fr"'}),") — ",e.jsx(s,{children:"fr"})," quand il est absent, langue première du produit et celle des gabarits d'objet"," ",e.jsx(s,{children:"ticket_*"}),". Il est éditable dans l'éditeur par locataire (le seul depuis la fusion) et supprime le cas « objet français, corps anglais » que produisait le ",e.jsx(s,{children:'getEmailStrings("en")'})," ","codé en dur."]}),e.jsxs(d,{tone:"warning",title:"Sauvegardes planifiées : marque plateforme, pas absence de marque",children:["Les courriels de ",e.jsx(n,{children:"sauvegarde planifiée"})," restent sur la marque plateforme — ",e.jsx(s,{children:"backupSchedules"})," est une table de niveau plateforme (inter-tenants), sans tenant destinataire à résoudre. Mais « pas de tenant » ne veut pas dire « pas de"," ",e.jsx(s,{children:"brand"})," » : n'en passer aucun faisait retomber l'emplacement droit sur l'asset intégré"," ",e.jsx(s,{children:"chargeops-logo-white.png"}),", donc la marque ChargeOps téléversée au Studio ne partait jamais."," ",e.jsx(s,{children:"backupScheduler.ts"})," appelle maintenant"," ",e.jsx(s,{children:"getEmailBrand"})," ",e.jsx(n,{children:"sans"})," ",e.jsx(s,{children:"tenantId"})," : le singleton ",e.jsx(s,{children:"platformBranding"})," ","est lu avant toute résolution de locataire, donc l'emplacement droit est correct et l'emplacement gauche garde la marque Polara du tenant principal."]})]})]}),e.jsxs(o,{id:"partie-4",title:"Partie 4 — Configuration système",children:[e.jsxs(t,{id:"page-configuration",title:"30. Page Configuration",children:[e.jsxs(i,{children:["Fichier : ",e.jsx(s,{children:"client/src/pages/configuration.tsx"}),". Route :"," ",e.jsx(a,{children:"/configuration"}),". Accès : tous les rôles authentifiés pour les onglets catalogue (lecture seule pour les catalogues partagés — voir plus bas) ;"," ",e.jsx(s,{children:"admin"})," pour les onglets d'administration de tenant ; le personnel de la plateforme (",e.jsx(s,{children:"platform-admin"}),") pour l'onglet ",e.jsx(s,{children:"appearance"})," ","(bascule globale du design Cléo)."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Onglets"})," (",e.jsx(s,{children:"useState<ActiveTab>"}),") :"]}),e.jsx(u,{headers:["Onglet","Contenu"],rows:[[e.jsx(s,{children:"chargerModels"}),"Marques + modèles de bornes"],[e.jsx(s,{children:"carriers"}),"Carriers SIM"],[e.jsx(s,{children:"usefulLinks"}),"Liens utiles"],[e.jsx(s,{children:"columns"}),"Configurateur de colonnes"],[e.jsx(s,{children:"notifications"}),"Notifications système globales"],[e.jsx(s,{children:"cleanup"}),"Outils de normalisation (admin)"],[e.jsx(s,{children:"warranty"}),"Réglages de garantie"],[e.jsx(s,{children:"personnel"}
40),"Project managers, salespersons, technicians"],[e.jsx(s,{children:"organizations"}),"Organisations clientes"],[e.jsx(s,{children:"carbonCredits"}),"Sociétés de crédits carbone"],[e.jsx(s,{children:"vehicleBrands"}),"Marques + modèles de véhicules"]]}),e.jsxs(i,{children:["Chaque onglet est CRUD complet (ajout, édition inline, suppression avec ",e.jsx(s,{children:"<AlertDialog>"})," de confirmation)."]})]}),e.jsxs(t,{id:"charger-brands",title:"31. Marques de bornes (chargerBrands)",children:[e.jsxs(i,{children:["Système ",e.jsx(n,{children:"ID-based"})," (Convex"," ",e.jsx(s,{children:'Id<"chargerBrands">'}),") — un changement de nom n'invalide plus les références (contrairement à un système name-based)."]}),e.jsx(i,{children:"Schema :"}),e.jsx(c,{language:"ts",children:`const chargerBrands = defineTable({
41  name: v.string(),
42  createdAt: v.number(),
43}).index("by_name", ["name"]);`}),e.jsxs(i,{children:["Queries : ",e.jsx(s,{children:"api.configuration.queries.getBrands"}),","," ",e.jsx(s,{children:"getBrandsWithModels"})," (jointure en mémoire pour éviter N+1).",e.jsx("br",{}),"Mutations : ",e.jsx(s,{children:"createBrand"}),", ",e.jsx(s,{children:"updateBrand"}),","," ",e.jsx(s,{children:"deleteBrand"})," (dans"," ",e.jsx(s,{children:"convex/configuration/mutations.ts"}),")."," ",e.jsx(s,{children:"deleteBrand"})," refuse une marque qui a encore des modèles (lus via ",e.jsx(s,{children:"chargerModels.by_brandId"}),", 101 lignes au plus) avec ",e.jsx(s,{children:"ConvexError"})," ",e.jsx(s,{children:"{ code: CATALOG_BRAND_HAS_MODELS, modelCount, message }"})," ","de ",e.jsx(s,{children:"shared/catalog-errors.ts"})," ; la page Configuration traduit le code en toast. Avant CHARGEOPS-407, elle supprimait la marque et laissait ses modèles orphelins : ils disparaissaient de"," ",e.jsx(s,{children:"getBrandsWithModels"})," alors que les écritures par identifiant les acceptaient encore."]}),e.jsxs(i,{children:["UI : table avec drag-and-drop (",e.jsx(s,{children:"@dnd-kit/sortable"}),") pour réordonner, édition inline (Input + boutons Save/Cancel), badge de comptage de modèles par marque."]})]}),e.jsxs(t,{id:"charger-models",title:"32. Modèles de bornes (chargerModels)",children:[e.jsxs(i,{children:["Référencent ",e.jsx(s,{children:"brandId"})," (FK vers ",e.jsx(s,{children:"chargerBrands"}),"), avec valeurs par défaut héritées :"]}),e.jsx(c,{language:"ts",children:`const chargerModels = defineTable({
44  brandId: v.id("chargerBrands"),
45  name: v.string(),
46  maxPower: v.string(),                    // numeric(8,2) en string pour précision
47  connectorCount: v.number(),
48  chargerClass: v.optional(v.union(v.literal("ac"), v.literal("dc"))),
49  warrantyDuration: v.optional(v.number()),
50  createdAt: v.number(),
51}).index("by_brandId", ["brandId"]);`}),e.jsxs(i,{children:["Onglet UI : recherche (",e.jsx(s,{children:"modelSearchQuery"}),"), filtre par marque (",e.jsx(s,{children:"modelBrandFilter"}),"), tri par ",e.jsx(s,{children:"name"}),"/",e.jsx(s,{children:"brand"}),"/",e.jsx(s,{children:"maxPower"})," (asc/desc). Création par formulaire compact avec ",e.jsx(s,{children:"<Select>"})," de marques. Champ ",e.jsx(s,{children:"chargerClass"})," (",e.jsx(s,{children:"ac"})," | ",e.jsx(s,{children:"dc"}),") influence l'icône d'affichage et les calculs de puissance."]}),e.jsxs(i,{children:[e.jsx(s,{children:"deleteModel"})," et ",e.jsx(s,{children:"deleteBrand"})," refusent une ligne qu'un locataire utilise encore (CHARGEOPS-439) : une borne (",e.jsx(s,{children:"chargers.by_modelId"})," / ",e.jsx(s,{children:"by_brandId"}),"), même supprimée — l'annulation d'un RMA et la restauration d'une sauvegarde ramènent une borne, et son historique affiche toujours le modèle — ou, pour un modèle, une ligne"," ",e.jsx(s,{children:"extendedWarrantyDefaults"})," ","d'un locataire (",e.jsx(s,{children:"by_modelId"}),"). Ces trois index sont inter-locataires à dessein et ne servent qu'à ce test d'existence"," ",e.jsx(s,{children:".first()"})," sous ",e.jsx(s,{children:"platformMutation"}),". Le refus est ",e.jsx(s,{children:"{ code: CATALOG_IN_USE, usedBy, message }"})," et ne nomme ni locataire, ni borne, ni nombre."," ",e.jsx(s,{children:"parts.compatibleModelIds"})," n'est pas vérifié : un tableau ne s'indexe pas, et un identifiant mort disparaît au prochain enregistrement de la pièce."]})]}),e.jsxs(t,{id:"sim-carriers",title:"33. Carriers SIM (simCarriers)",children:[e.jsx(c,{language:"ts",children:`const simCarriers = defineTable({
52  name: v.string(),
53  createdAt: v.number(),
54}).index("by_name", ["name"]);`}),e.jsxs(i,{children:["CRUD simple : input + bouton Add, table avec édition inline. Utilisé partout où les bornes ont une SIM (table ",e.jsx(s,{children:"chargers"}),", RMA, Warehouse)."]}),e.jsxs(i,{children:["Queries : ",e.jsx(s,{children:"api.configuration.queries.getCarriers"}),". Hook :"," ",e.jsx(s,{children:"useCarriers()"})," dans ",e.jsx(s,{children:"use-bootstrap.tsx"}),"."]})]}),e.jsxs(t,{id:"useful-links",title:"34. Liens utiles (usefulLinks)",children:[e.jsx(i,{children:"Schema :"}),e.jsx(c,{language:"ts",children:`const usefulLinks = defineTable({
55  title: v.string(),
56  url: v.string(),
57  faviconUrl: v.optional(v.string()),
58  faviconSource: v.optional(v.string()),
59  displayOrder: v.number(),
60  isActive: v.boolean(),
61  createdAt: v.number(),
62  updatedAt: v.number(),
63}
63).index("by_displayOrder", ["displayOrder"]);`}),e.jsxs(l,{children:[e.jsxs(r,{children:["Edit inline avec champ de tri (",e.jsx(s,{children:"displayOrder"}),") ; switch"," ",e.jsx(s,{children:"isActive"}),"."]}),e.jsxs(r,{children:["Affichés dans"," ",e.jsx(s,{children:"client/src/components/useful-links-menu.tsx"})," (menu déroulant accessible depuis la barre nav)."]}),e.jsxs(r,{children:["Favicons résolus via"," ",e.jsx(s,{children:"client/src/components/ui/favicon-icon.tsx"})," (avec cache)."]}),e.jsxs(r,{children:["Table parallèle ",e.jsx(s,{children:"userCustomLinks"})," permet à chaque utilisateur d'ajouter ses propres liens (clé ",e.jsx(s,{children:"userId"}),", indexée par ",e.jsx(s,{children:"by_userId_displayOrder"}),")."]})]})]}),e.jsx(t,{id:"visibilite-colonnes",title:"35. Visibilité des colonnes",children:e.jsxs(i,{children:["Onglet ",e.jsx(s,{children:"columns"})," — utilise"," ",e.jsx(s,{children:"<ColumnConfigurator>"})," (cf. section 11). Les changements admin écrivent dans"," ",e.jsx(s,{children:"columnConfigurations"})," avec ",e.jsx(s,{children:"isDefault: true"}),","," ",e.jsx(s,{children:"userId: undefined"}),"."]})}),e.jsxs(t,{id:"notifications-systeme",title:"36. Notifications système",children:[e.jsxs(i,{children:["Bannière globale ",e.jsx(s,{children:"<SystemNotificationBanner>"})," ","(visible sur Home et autres pages racines). Schema :"]}),e.jsx(c,{language:"ts",children:`const systemNotifications = defineTable({
64  message: v.string(),
65  type: v.string(),     // "info" | "warning" | "success" | "error"
66  isActive: v.boolean(),
67  createdBy: v.optional(v.id("users")),
68  createdAt: v.number(),
69  updatedAt: v.number(),
70}).index("by_isActive", ["isActive"]);`}),e.jsxs(i,{children:["UI : ",e.jsx(s,{children:"<Textarea>"})," pour le message,"," ",e.jsx(s,{children:"<Select>"})," pour le type, switch"," ",e.jsx(s,{children:"isActive"}),". Halos colorés via"," ",e.jsxs(s,{children:[".notification-glow-","{blue,red,amber,emerald}"]}),". Table globale, partagée par tous les tenants — publiable uniquement par le personnel de la plateforme (",e.jsx(s,{children:"platform-admin"}),")."]})]}),e.jsxs(t,{id:"cleanup",title:"37. Outils de normalisation (admin)",children:[e.jsxs(i,{children:["Onglet ",e.jsx(s,{children:"cleanup"})," — exporte la liste des chargers avec adresses/noms à normaliser (typos, doublons, casse). Apparait seulement si l'utilisateur est admin et active la requête (",e.jsx(s,{children:'activeTab === "cleanup" ? {} : "skip"'})," pour économiser le quota Convex)."]}),e.jsx(i,{children:"Outils :"}),e.jsxs(l,{children:[e.jsx(r,{children:"Export CSV des sites avec adresses suspectes."}),e.jsx(r,{children:"Suggestion de fusion de noms similaires (Levenshtein côté serveur)."}),e.jsx(r,{children:"Réécriture batch d'adresses (Google Geocoding)."})]})]}),e.jsxs(t,{id:"profile-menu",title:"38. Profile menu",children:[e.jsxs(i,{children:[e.jsx(s,{children:"client/src/components/profile-menu.tsx"})," — menu déroulant accessible via l'avatar dans le header desktop. Les liens sont regroupés par catégories via le composant"," ",e.jsx(s,{children:"MenuSection"})," : en-tête en petites capitales avec point d'accent dégradé et filet dégressif (",e.jsx(s,{children:"bg-gradient-to-r from-border to-transparent"}),"), puis grille ",e.jsx(s,{children:"grid-cols-1 sm:grid-cols-2"})," d'items. Chaque section apparaît avec un léger stagger Framer Motion (",e.jsx(s,{children:"delay: 0.04 * index"}),"). Largeur du panneau :"," ",e.jsx(s,{children:"w-[min(30rem,calc(100vw-1rem))]"}),"."]}),e.jsxs(p,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Header utilisateur"})," — initiales + nom + email + chip de rôle (violet/fuchsia si ",e.jsx(s,{children:"isPlatformAdmin"}),", ardoise sinon — basé sur l'axe ",e.jsx(s,{children:"platformRole"}),", pas sur le rôle tenant), carte ",e.jsx(s,{children:"bg-white/20 backdrop-blur-sm"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Opérations"})," — Indicateurs opérationnels (visible à tous les utilisateurs authentifiés ; les comptes cadrés par organisation voient des données filtrées), Chargers, Charging Sessions Viewer et Power Calculations Tool (conditionnel"," ",e.jsx(s,{children:"usePowerManagementEnabled()"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Clients et sites"})," (« Sites » pour un site-operator) — Customers/Sites, Customer Map, Contacts, Vehicles, et Organisations (affiché si"," ",e.jsx(s,{children:"can.viewOrganizations"})," : tous les rôles principaux sauf site-operator ; sans badge)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Service et inventaire"})," — Maintenance Center, RMA, Service Reports, Support Cases (",e.jsx(s,{children:"/support"}),", ouvert à tous les utilisateurs actifs), plus Warehouse Inventory et License Management (masqués pour les site-operators)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Administration"})," — visible si"," ",e.jsx(s,{children:'user.role === "admin"'}),". Le chip ",e.jsx(s,{children:"ADMIN"})," est porté par l'en-tête de section (plus de badge répété sur chaque item) : User Management, Analytics, Configuration (avec Cleo Link comme onglet à l'intérieur — Branding y est aussi listé, mais réservé au personnel de la plateforme), et — seulement si ce même compte porte ",e.jsx(n,{children:"aussi"})," ","un ",e.jsx(s,{children:"platformRole"})," — Backup Manager."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Plateforme ChargeOps"})," — section distincte, visible si"," ",e.jsx(s,{children:"platformRole"})," est défini (indépendamment du rôle tenant). Chip"," ",e.jsx(s,{children:"SUPER"})," (violet, ",e.jsx(s,{children:"platform-admin"}),") ou"," ",e.jsx(s,{children:"SUPPORT"})," (",e.jsx(s,{children:"platform-support"}),"). Contient un unique lien vers la console plateforme (",e.jsx(a,{children:"/platform"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Compte"})," — Documentation et Change Password, qui ouvre le ",e.jsx(s,{children:"<ChangePasswordDialog>"})," partagé (",e.jsx(s,{children:"client/src/components/change-password-dialog.tsx"})," — même composant rendu par le menu ☰ du téléphone (",e.jsx(s,{children:"shell/nav-menu.tsx"}),") et le menu profil du Portail opérateur, ",e.jsx(s,{children:"PortalProfileMenu"})," ","dans ",e.jsx(s,{children:"portal-layout.tsx"}),"). Logout reste hors catégorie, en rangée pleine largeur destructive sous un séparateur."]})]}),e.jsxs(i,{children:["Chaque item affiche une icône ",e.jsx(n,{children:"monoline"})," (contour Lucide, ",e.jsxs(s,{children:["strokeWidth=","{1.75}"]}),", ",e.jsx(s,{children:"h-5 w-5"}),") dans une couleur d'accent unie personnalisée (",e.jsx(s,{children:'color="text-amber-500"'}),"), sans tuile de fond ni dégradé ; les libellés sont tronqués (",e.jsx(s,{children:"truncate"})," +"," ",e.jsx(s,{children:"title"}),") pour tenir dans la grille à deux colonnes."]}),e.jsxs(i,{children:["L'avatar a un halo bleu pulsant en boucle (",e.jsx(s,{children:"animate={{ boxShadow: [...] }}"}),") qui devient solide quand le menu est ouvert."]}),e.jsxs(i,{children:["Le menu utilise"," ",e.jsx(s,{children:"bg-white/95 dar
70k:bg-gray-900/95 backdrop-blur-2xl"})," ","(glassmorphism prononcé) + ",e.jsx(s,{children:"nav-font"})," (DM Sans). Les en-têtes de catégories viennent des clés imbriquées"," ",e.jsx(s,{children:"profileMenu.*"})," dans"," ",e.jsx(s,{children:"client/src/lib/translations.en.ts"})," /"," ",e.jsx(s,{children:"translations.fr.ts"}),"."]})]})]}),e.jsxs(o,{id:"partie-5",title:"Partie 5 — Internationalisation (i18n)",children:[e.jsxs(t,{id:"bilingue",title:"39. Bilingue FR / EN",children:[e.jsx(i,{children:"Toutes les pages, modales, toasts, validations Zod et tooltips sont disponibles en français et en anglais."}),e.jsxs(i,{children:[e.jsx(n,{children:"Switcher"})," :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["Desktop : ",e.jsx(s,{children:"<Select>"})," minuscule (",e.jsx(s,{children:"w-14 h-6"}),") dans le header (AppLayout)."]}),e.jsxs(r,{children:["Mobile : un sélecteur segmenté ",e.jsx(s,{children:"FR"}),"/",e.jsx(s,{children:"EN"})," dans le pied du menu ☰ du téléphone."]})]})]}),e.jsxs(t,{id:"implementation-i18n",title:"40. Implémentation",children:[e.jsx(i,{children:"Fichiers clés :"}),e.jsx(h,{rows:[{key:e.jsx(s,{children:"client/src/lib/translations.ts"}),value:e.jsxs(e.Fragment,{children:["Loader, types, fonction"," ",e.jsx(s,{children:"getTranslation(language, key, params)"}),"."]})},{key:e.jsx(s,{children:"client/src/lib/translations.en.ts"}),value:"Source de vérité — toutes les clés en anglais."},{key:e.jsx(s,{children:"client/src/lib/translations.fr.ts"}),value:e.jsxs(e.Fragment,{children:["Traductions françaises (chargé en lazy via"," ",e.jsx(s,{children:'import("./translations.fr")'}),")."]})},{key:e.jsx(s,{children:"client/src/hooks/use-language.tsx"}),value:e.jsxs(e.Fragment,{children:["Provider, hook ",e.jsx(s,{children:"useLanguage()"}),", mutation Convex pour persister côté serveur. Expose ",e.jsx(s,{children:"isLoading"})," ","pendant le chargement du bundle FR et re-rend les consommateurs une fois le fichier chargé — sans quoi les chaînes ",e.jsx(s,{children:"t()"})," resteraient en anglais jusqu'au prochain re-render."]})}]}),e.jsxs(i,{children:[e.jsx(n,{children:"Lazy loading du français"})," :"]}),e.jsx(c,{language:"ts",children:`let frCache: any = null;
71export async function loadFrench() {
72  if (!frCache) frCache = (await import("./translations.fr")).default;
73  return frCache;
74}
75export async function ensureLanguageLoaded(lang: string) {
76  if (lang === "fr" && translations.fr === translations.en) {
77    translations.fr = await loadFrench();
78  }
79}`}),e.jsx(i,{children:"L'utilisateur EN ne charge jamais le bundle FR, et vice-versa après le premier switch."}),e.jsxs(i,{children:[e.jsx(n,{children:"Une langue n'est publiée qu'une fois chargée"})," (CHARGEOPS-225) :"," ",e.jsx(s,{children:"LanguageProvider"})," distingue la langue demandée de celle qu'il expose."," ",e.jsx(s,{children:"language"})," ne passe à ",e.jsx(s,{children:'"fr"'})," qu'après"," ",e.jsx(s,{children:'ensureLanguageLoaded("fr")'})," (",e.jsx(s,{children:"isLanguageLoaded"})," le dit sans attendre). Auparavant il changeait aussitôt, ",e.jsx(s,{children:"getTranslation"})," renvoyait encore l'anglais, et tout ",e.jsx(s,{children:"useMemo(…, [language])"})," qui appelait"," ",e.jsx(s,{children:"t()"})," gardait ces libellés anglais : la lecture de la liste des rapports d'entretien restait en anglais après un passage EN → FR. Un tel memo n'a donc pas besoin d'autre dépendance que ",e.jsx(s,{children:"language"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Usage dans un composant"})," :"]}),e.jsx(c,{language:"tsx",children:`const { language } = useLanguage();
80const t = (key: string, params?: Record<string, any>) =>
81  getTranslation(language, key, params);
82
83return <h1>{t("configuration.title")}</h1>;`}),e.jsxs(i,{children:[e.jsx(n,{children:"Clés imbriquées"})," : ",e.jsx(s,{children:"getTranslation"})," ","supporte la notation ",e.jsx(s,{children:"dot"})," (",e.jsx(s,{children:'"branding.companyLogo"'})," →"," ",e.jsx(s,{children:"translations[lang].branding.companyLogo"}),")."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Interpolation"})," :"]}),e.jsx(c,{language:"ts",children:'t("welcome", { name: user.firstName });  // "Bonjour, {name}!" → "Bonjour, Pierre!"'}),e.jsxs(i,{children:[e.jsx(n,{children:"Fallback"})," : si la clé n'existe pas en français, retombe sur l'anglais ; si elle n'existe ni l'une ni l'autre, retourne la clé brute."]})]}),e.jsxs(t,{id:"coherence-cles",title:"41. Cohérence des clés",children:[e.jsxs(i,{children:["Le projet a corrigé plusieurs vagues de"," ",e.jsx(n,{children:"clés dupliquées"})," dans le passé (deux entrées"," ",e.jsx(s,{children:'"profileLogout"'})," avec valeurs différentes, par exemple). Process actuel :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(s,{children:"translations.en.ts"})," est la"," ",e.jsx(n,{children:"source de vérité"})," (TypeScript en infère les types)."]}),e.jsxs(r,{children:[e.jsx(s,{children:"translations.fr.ts"})," doit avoir la"," ",e.jsx(n,{children:"même structure"})," (typé"," ",e.jsx(s,{children:"as EnTranslations"}),")."]}),e.jsx(r,{children:"Lors de l'ajout d'une clé, la mettre dans EN d'abord puis FR. Une revue manuelle de PR vérifie l'alignement."}),e.jsx(r,{children:"Quand une clé manque en FR, le fallback EN évite la régression visuelle, mais le linter d'i18n peut signaler les manques (à intégrer en CI si non encore présent)."})]})]}),e.jsx(t,{id:"persistance-langue",title:"42. Persistance du choix",children:e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"localStorage"})," (clé ",e.jsx(s,{children:"app.lang"}),", valeurs"," ",e.jsx(s,{children:'"en"'})," | ",e.jsx(s,{children:'"fr"'}),") — lecture au mount."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Côté serveur"})," (champ ",e.jsx(s,{children:"users.language"})," ","dans Convex) — synchronisé via"," ",e.jsx(s,{children:"api.users.mutations.updateProfile"})," quand un utilisateur authentifié change la langue."]}),e.jsxs(r,{children:["À la prochaine connexion (depuis un autre device par ex.),"," ",e.jsx(s,{children:"useEffect(() => { if (user.language !== language) setLanguageState(user.language) }, [user.language])"})," ","réaligne la langue locale sur la préférence serveur."]}),e.jsxs(r,{children:["L'attribut ",e.jsx(s,{children:'<html lang="…">'})," est mis à jour à chaque changement (accessibilité, SEO)."]})]})}),e.jsxs(t,{id:"couverture-i18n",title:"43. Couverture i18n",children:[e.jsx(i,{children:"Tout est traduit :"}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Pages"})," : toutes les pages de"," ",e.jsx(s,{children:"/client/src/pages/*"})," consomment"," ",e.jsx(s,{children:"getTranslation"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Composants UI partagés"})," : tooltips, placeholders, libellés de boutons."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Validations Zod"})," : messages d'erreur de formulaires."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Toasts"})," : titre + description."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Métriques analytics"})," : labels de pages traduits."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Branding"})," : labels personnalisables (",e.jsx(s,{children:"chrgpkLabel"}),", ",e.jsx(s,{children:"ctrlbxLabel"}),", header buttons texts) — ces valeurs ne sont pas traduites, elles s
83ont par tenant."]}),e.jsxs(r,{children:[e.jsx(n,{children:"PDF rapports de service et d'entretien"})," : la boîte de dialogue d'export propose un sélecteur de langue (English / Français), pré-rempli avec la langue courante de l'utilisateur ; le document généré (moteur partagé ",e.jsx(s,{children:"branded-report-pdf.ts"}),") suit la langue choisie."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Emails sortants"})," : générés serveur-side selon la langue préférée du destinataire (",e.jsx(s,{children:"users.language"}),")."]})]})]})]}),e.jsx(o,{id:"partie-6",title:"Partie 6 — Analytics frontend",children:e.jsxs(t,{id:"amplitude",title:"44. Amplitude — initialisation différée",children:[e.jsxs(d,{tone:"info",title:"Note",children:["La mention CLAUDE.md indique une init différée d'Amplitude dans"," ",e.jsx(s,{children:"main.tsx"}),", mais le repo actuel"," ",e.jsx(n,{children:"n'inclut plus de code Amplitude"})," (aucun import, aucun fichier référence). L'analytics actif est"," ",e.jsx(n,{children:"Convex-native"})," : tables"," ",e.jsx(s,{children:"userAnalyticsSessions"})," et ",e.jsx(s,{children:"pageViews"})," ","peuplées par le hook ",e.jsx(s,{children:"useAnalytics()"}),"."]}),e.jsxs(i,{children:["Hook actif : ",e.jsx(s,{children:"client/src/hooks/use-analytics.tsx"})," — instancié une seule fois via"," ",e.jsx(s,{children:"<AnalyticsTracker />"})," dans ",e.jsx(s,{children:"App.tsx"}),"."]}),e.jsxs(i,{children:[e.jsx(n,{children:"Évènements trackés"})," :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Sessions"})," : création (",e.jsx(s,{children:"createSession"}),"), heartbeat périodique (",e.jsx(s,{children:"updateSession.lastActivity"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Page views"})," :"," ",e.jsxs(s,{children:["createPageView(","{ sessionId, pagePath, pageTitle, siteId? }",")"]})," ","à chaque changement de route wouter ; ",e.jsx(s,{children:"updatePageView"})," ","à la sortie pour calculer la durée et le"," ",e.jsx(s,{children:"pausedDuration"})," (durée passée onglet en arrière-plan via ",e.jsx(s,{children:"Page Visibility API"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Beacon API"})," au ",e.jsx(s,{children:"unload"})," :"," ",e.jsx(s,{children:"useBeacon = true"})," permet d'envoyer le dernier event même si l'onglet se ferme."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Mapping URL → titre"})," : ",e.jsx(s,{children:"getPageTitle()"})," ",'retourne des titres lisibles ("Site Dashboard - Site 42", "Site Audit - Site 42", "Analytics", etc.).']}),e.jsxs(r,{children:["Pages consultables dans ",e.jsx(a,{children:"/analytics"})," (admin) et"," ",e.jsx(a,{children:"/analytics/user/:userId"})," (drill-down par utilisateur)."]})]}),e.jsxs(i,{children:["Si Amplitude doit être (ré)introduit, il s'attendrait à être init dans ",e.jsx(s,{children:"main.tsx"})," après le ",e.jsx(s,{children:"createRoot(...)"})," ","initial, avec ",e.jsx(s,{children:"requestIdleCallback"})," pour ne pas bloquer le first paint :"]}),e.jsx(c,{language:"tsx",children:`// main.tsx (référence — non actif aujourd'hui)
84import { createRoot } from "react-dom/client";
85import App from "./App";
86import "./index.css";
87createRoot(document.getElementById("root")!).render(<App />);
88
89// Init différée — n'est PAS dans le code actuel
90if ("requestIdleCallback" in window) {
91  requestIdleCallback(() => {
92    import("@amplitude/analytics-browser").then(({ init }) => {
93      init(import.meta.env.VITE_AMPLITUDE_API_KEY);
94    });
95  });
96}`})]})}),e.jsxs(o,{id:"partie-7",title:"Partie 7 — Extension Chrome officielle (MV3)",children:[e.jsx(t,{id:"extension-architecture",title:"45. Architecture et build",children:e.jsxs(i,{children:["L'extension officielle Chrome (Manifest V3) vit dans"," ",e.jsx(s,{children:"extension/"})," : ",e.jsx(s,{children:"public/"})," (manifest, icônes, polices Indivisible auto-hébergées — copiés tels quels dans le build), ",e.jsx(s,{children:"src/lib/"})," (stockage réglages/jetons, i18n FR/EN, liens profonds, formatage), ",e.jsx(s,{children:"src/popup/"})," ","(application React — système de design Cléo dans"," ",e.jsx(s,{children:"styles.css"})," : violet ",e.jsx(s,{children:"#3021C3"})," → cyan"," ",e.jsx(s,{children:"#5CC2FF"}),", en-tête marine ",e.jsx(s,{children:"#00004B"}),") et"," ",e.jsx(s,{children:"src/background/"})," (service worker MV3). Build via"," ",e.jsx(s,{children:"npm run build:extension"})," (config"," ",e.jsx(s,{children:"vite.extension.config.ts"})," à la racine du dépôt : entrée popup + entrée service worker au nom stable"," ",e.jsx(s,{children:"background.js"}),"), sortie dans"," ",e.jsx(s,{children:"extension/dist/"}),", chargée non empaquetée via"," ",e.jsx(a,{children:"chrome://extensions"}),". Type-check séparé :"," ",e.jsx(s,{children:"npm run check:extension"})," (",e.jsx(s,{children:"tsc -p extension/tsconfig.json"}),", strict, types"," ",e.jsx(s,{children:"@types/chrome"}),"). Permissions minimales :"," ",e.jsx(s,{children:"storage"}),", ",e.jsx(s,{children:"alarms"}),","," ",e.jsx(s,{children:"notifications"}),","," ",e.jsx(s,{children:"https://*.convex.cloud/*"})," — pas de content scripts, pas de requêtes tierces. Doc complète :"," ",e.jsx(s,{children:"extension/README.md"}),"."]})}),e.jsx(t,{id:"extension-session",title:"46. Session partagée et rafraîchissement du JWT",children:e.jsxs(i,{children:["La connexion utilise le ",e.jsx(n,{children:"même fournisseur Convex Auth mot de passe"}
96)," que l'application web (",e.jsx(s,{children:'flow: "signIn"'})," ","uniquement — l'extension ne crée pas de comptes). Les jetons vivent dans ",e.jsx(s,{children:"chrome.storage.local"}),", partagés entre le popup et le service worker ; le worker rafraîchit le JWT par le flux refresh-token standard, sous un mutex"," ",e.jsx(n,{children:"Web Locks"})," contre les rafraîchissements concurrents. Les sessions expirent après 8 heures, comme dans l'app. Les liens profonds (",e.jsx(s,{children:"src/lib/deepLinks.ts"}),") reflètent la règle de confinement de"," ",e.jsx(s,{children:"protected-route.tsx"})," : un compte exploitant de site (non-plateforme) est dirigé vers ",e.jsx(a,{children:"/portal"}),"."]})}),e.jsx(t,{id:"extension-surface-serveur",title:"47. Surface serveur et temps réel",children:e.jsxs(i,{children:["Nouvelle surface dédiée ",e.jsx(s,{children:"convex/extension/queries.ts"})," — trois lectures via ",e.jsx(s,{children:"tenantQuery"})," (RLS + périmètre organisationnel via ",e.jsx(s,{children:"lib/scope.ts"}),") :"," ",e.jsx(s,{children:"pulse"})," (statut vivant : compteur de non-lus dédupliqué entre cloche et messagerie — les lignes de cloche de type message sont exclues et les conversations non lues rajoutées, un message = une unité de pastille — plus un petit fil pour composer les notifications de bureau), ",e.jsx(s,{children:"globalSearch"})," (bornes par nom OU numéro de série, sites, contacts, organisations — insensible aux accents, balayage borné en mémoire comme"," ",e.jsx(s,{children:"searchReferences"}),") et ",e.jsx(s,{children:"getEntityCard"})," ","(fiche détaillée d'une entité). Le reste réutilise les fonctions existantes de l'app : ",e.jsx(s,{children:"notifications/inbox"})," ","(",e.jsx(s,{children:"getMine"}),", ",e.jsx(s,{children:"markRead"}),","," ",e.jsx(s,{children:"markAllRead"}),"), ",e.jsx(s,{children:"messages/*"})," (liste, fil paginé, envoi, marquage lu), ",e.jsx(s,{children:"tickets/*"})," ","(",e.jsx(s,{children:"listCases"}),", ",e.jsx(s,{children:"settings.getEffective"}),","," ",e.jsx(s,{children:"mutations.create"})," — y compris la branche exploitant de site qui soumet pour révision) et"," ",e.jsx(s,{children:"portal/queries.listMySubmissions"}),". Deux modes de consommation : le ",e.jsx(n,{children:"popup"})," s'abonne par le websocket Convex (mises à jour en direct), le"," ",e.jsx(n,{children:"service worker"})," interroge ",e.jsx(s,{children:"pulse"})," en HTTP sur une alarme ",e.jsx(s,{children:"chrome.alarms"})," d'une minute (popup fermé) pour tenir la pastille de l'icône et lever les notifications de bureau — comportement désactivable dans les réglages."]})}),e.jsxs(t,{id:"extension-packaging",title:"48. Empaquetage automatique et distribution in-app",children:[e.jsxs(i,{children:[e.jsx(s,{children:"scripts/package-extension.mjs"})," compile l'extension (",e.jsx(s,{children:"vite build -c vite.extension.config.ts"}),") puis écrit deux artefacts dans ",e.jsx(s,{children:"client/public/downloads/"})," :"," ",e.jsx(s,{children:"chargeops-extension-<version>.zip"})," (le manifeste à la ",e.jsx(n,{children:"racine"})," du zip, comme l'exige le Web Store) et"," ",e.jsx(s,{children:"extension-package.json"})," (",e.jsx(s,{children:"version"}),", ",e.jsx(s,{children:"filename"}),", ",e.jsx(s,{children:"bytes"}),","," ",e.jsx(s,{children:"fileCount"}),", ",e.jsx(s,{children:"builtAt"}),") que lit la carte de l'onglet Outils. L'écriture du zip est faite par un mini-écrivain PKZIP interne (deflate, horodatages figés → archive reproductible), sans dépendance supplémentaire."]}),e.jsxs(i,{children:["Le script est branché ",e.jsx(n,{children:"avant"})," ",e.jsx(s,{children:"vite build"})," ","dans ",e.jsx(s,{children:"npm run build"})," — Vite recopie ensuite"," ",e.jsx(s,{children:"client/public/"})," dans le bundle déployé, donc le téléchargement existe dans ",e.jsx(n,{children:"tout environnement déployé"})," sans étape manuelle. ",e.jsx(s,{children:"npm run deploy"})," et le ",e.jsx(s,{children:"buildCommand"})," de ",e.jsx(s,{children:"vercel.json"})," passent désormais par ",e.jsx(s,{children:"npm run build"})," plutôt que par"," ",e.jsx(s,{children:"vite build"}),". Exécution isolée :"," ",e.jsx(s,{children:"npm run package:extension"}),". Un échec d'empaquetage est",e.jsx(n,{children:" journalisé puis avalé"})," : l'extension est un compagnon optionnel, elle ne doit jamais faire tomber un déploiement de l'application. Les artefacts s
96ont git-ignorés (",e.jsx(s,{children:"client/public/downloads/"}),","," ",e.jsx(s,{children:"extension/store/dist/"}),") — ils sont régénérés à chaque compilation."]}),e.jsxs(i,{children:["Côté client, la carte —"," ",e.jsx(s,{children:"ChromeExtensionCleanup"})," dans"," ",e.jsx(s,{children:"configuration/sections/cleanup/chrome-extension.tsx"})," ","(onglet Outils / ",e.jsx(s,{children:"cleanup"}),", admin) — va chercher"," ",e.jsx(s,{children:"/downloads/extension-package.json"})," en"," ",e.jsx(s,{children:"fetch"})," ",e.jsx(s,{children:'cache: "no-store"'})," quand l'onglet devient actif. L'état est ternaire —"," ",e.jsx(s,{children:"undefined"})," (en cours), objet (paquet disponible),"," ",e.jsx(s,{children:"null"})," (absent) — pour afficher un message explicite en"," ",e.jsx(s,{children:"vite dev"}),", où aucune compilation n'a eu lieu, au lieu d'un chargement infini."]})]}),e.jsxs(t,{id:"extension-web-store",title:"49. Préparation Chrome Web Store",children:[e.jsxs(i,{children:["Conformité du manifeste : ",e.jsx(s,{children:"description"})," ramenée sous la limite de ",e.jsx(n,{children:"132 caractères"})," et clé"," ",e.jsx(s,{children:"author"})," retirée — Chrome l'ignore depuis février 2024 et elle provoque un rejet lorsqu'elle ne correspond pas au compte éditeur."]}),e.jsxs(i,{children:["Le dossier ",e.jsx(s,{children:"extension/store/"})," regroupe l'outillage de publication : ",e.jsx(s,{children:"capture.mjs"})," régénère les captures",e.jsx(n,{children:" 1280 × 800"})," exigées par le store dans"," ",e.jsx(s,{children:"extension/store/screenshots/"}),", en rendant les"," ",e.jsx(n,{children:"vrais composants du popup"})," avec des données d'exemple (",e.jsx(s,{children:"fixtures.ts"}),", mocks Convex dans"," ",e.jsx(s,{children:"mocks/"}),") — le popup est capturé à son 400 × 600 réel puis composité sur le canevas du store. Chrome est piloté par le"," ",e.jsx(n,{children:"DevTools Protocol"})," plutôt qu'en"," ",e.jsx(s,{children:"--screenshot"}),"/",e.jsx(s,{children:"--virtual-time-budget"})," : le temps virtuel avance les minuteries sans laisser le compositeur se stabiliser, ce qui capturait des transitions framer-motion à moitié jouées. La sortie est déterministe (l'horloge du harnais est figée). ",e.jsx(s,{children:"PRIVACY.md"})," tient la politique de confidentialité (à héberger à une URL publique sans connexion — Google la récupère pendant la revue) et ",e.jsx(s,{children:"LISTING.md"})," la fiche de publication prête à coller, champ par champ."]})]})]}),e.jsxs(o,{id:"partie-8",title:"Partie 8 — Mode terrain hors ligne",children:[e.jsxs(i,{children:["Le mode terrain hors ligne (épique CHARGEOPS-25, conception dans"," ",e.jsx(s,{children:"docs/superpowers/specs/2026-09-19-offline-field-mode-design.md"}),") permet à un technicien sans réseau d'ouvrir le formulaire de rapport de service et l'assistant de rapport d'entretien, de choisir un site et ses bornes, et de continuer à écrire. Le stockage sur l'appareil et la file d'envoi sont dans ",e.jsx(s,{children:"client/src/lib/offline/"}),". Cette partie décrit ce qui permet aux formulaires de ",e.jsx(n,{children:"se charger"})," hors ligne (CHARGEOPS-43) : le cache des fichiers, « Préparer pour le hors-ligne », l'instantané des données de référence et la dernière identité connue, puis (CHARGEOPS-44) ce qui garde le travail en file visible et jamais écrasé en silence."]}),e.jsxs(t,{id:"offline-asset-cache",title:"50. Cache des fichiers dans le service worker",children:[e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(s,{children:"client/public/sw.js"})," garde son estampille"," ",e.jsx(s,{children:"BUILD_ID"}),", sa vérification de mise à jour et son disjoncteur de rechargement. Il ajoute un second cache,"," ",e.jsx(s,{children:"polara-chargeops-assets-<BUILD_ID>"}),", en"," ",e.jsx(n,{children:"cache d'abord"})," pour ",e.jsx(s,{children:"/assets/*"}),". Les noms de fichiers hachés sont immuables : une copie stockée est toujours la bonne pour son URL."]}),e.jsxs(r,{children:["Seule une réponse au statut ",e.jsx(n,{children:"exactement 200"}),", de type JS, CSS, police, image ou wasm, est stockée. Un corps HTML sous une URL de fichier (la réécriture SPA de Vercel qui répond à un 404 en plein déploiement) n'est ",e.jsx(n,{children:"jamais"})," stocké : l'autoréparation existante (nouvelle requête avec ",e.jsx(s,{children:'cache: "reload"'}),") et la nouvelle tentative unique restent inchangées."]}),e.jsxs(r,{children:["À l'",e.jsx(s,{children:"activate"}),", tout cache qui n'est pas l'un des deux de ce build est supprimé : un déploiement purge les anciens fichiers. Sur un appareil qui a déjà un cache de fichiers, l'",e.jsx(s,{children:"install"})," ","remplit d'abord le cache du nouveau build (les fichiers inchangés sont copiés depuis l'ancien ; l'installation n'échoue jamais pour ça). Un téléphone préparé qui perd le réseau juste après un déploiement continue donc de fonctionner."]}),e.jsxs(r,{children:["La coquille servie hors ligne porte"," ",e.jsx(s,{children:"X-SW-Offline-Fallback: 1"})," : ",e.jsx(s,{children:"stale-chunk.ts::checkDeployment"})," ","répond alors ",e.jsx(s,{children:'"unknown"'})," (de même quand"," ",e.jsx(s,{children:"navigator.onLine"})," vaut false). Un morceau en échec hors ligne ne recharge donc jamais la page."]}),e.jsxs(r,{children:["La CSP de ",e.jsx(s,{children:"vercel.json"}),", ",e.jsx(s,{children:"sw-register.js"})," et la logique de mise à jour et de rechargement ne changent pas."]})]}),e.jsx(u,{headers:["Message (page → worker)","Réponse","Usage"],rows:[[e.jsx(s,{children:"PREPARE_OFFLINE"}),e.jsx(s,{children:"{ total, cached, failed }"}),"Télécharge chaque fichier hors ligne estampillé qui n'est pas encore en cache (4 à la fois)"],[e.jsx(s,{children:"GET_OFFLINE_STATUS"}),e.jsx(s,{children:"{ total, cached, bytes, version }"}
96),"Page Mode terrain, préchargement en arrière-plan"],[e.jsx(s,{children:"CLEAR_OFFLINE_ASSETS"}),e.jsx(s,{children:"OFFLINE_CLEARED"}),"« Effacer les données de l'appareil »"]]}),e.jsxs(i,{children:["Côté page, ",e.jsx(s,{children:"lib/offline/sw-client.ts"})," passe par un MessageChannel avec délais d'expiration et renvoie ",e.jsx(s,{children:"null"})," ","quand aucun worker ne répond (serveur de dev, navigateur non pris en charge)."]})]}),e.jsxs(t,{id:"offline-chunk-list",title:"51. Liste des morceaux des routes de rapport",children:[e.jsxs(i,{children:[e.jsx(s,{children:"stampServiceWorker()"})," dans ",e.jsx(s,{children:"vite.config.ts"})," ","écrit ",e.jsx(s,{children:"OFFLINE_ASSETS"})," dans ",e.jsx(s,{children:"sw.js"})," à la place de"," ",e.jsx(s,{children:"/* @offline-assets */ []"}),", à côté de ",e.jsx(s,{children:"BUILD_ID"}),". La liste vient de ",e.jsx(s,{children:"scripts/sw-offline-assets.mjs"})," →"," ",e.jsx(s,{children:"collectOfflineAssets(bundle, OFFLINE_ROUTE_MATCHERS)"}),", qui lit le bundle Rollup (les mêmes données que le manifeste Vite, donc aucun fichier de manifeste n'est publié). Elle comprend :"]}),e.jsxs(l,{children:[e.jsx(r,{children:"le morceau d'entrée, ses imports statiques, leurs CSS et fichiers ;"}),e.jsxs(r,{children:["chaque module de route (",e.jsx(s,{children:"pages/service-report-form.tsx"}),","," ",e.jsx(s,{children:"pages/maintenance-reports/wizard.tsx"}),","," ",e.jsx(s,{children:"pages/charger-field-hub.tsx"}),", ",e.jsx(s,{children:"pages/field-mode.tsx"})," ","et ",e.jsx(s,{children:"pages/pending-sync.tsx"}),"), avec ses imports statiques"," ",e.jsx(n,{children:"et"})," dynamiques suivis de proche en proche (par exemple l'éditeur de texte riche monté à la demande)."]})]}),e.jsx(i,{children:"Le parcours s'arrête aux morceaux déjà couverts par la coquille. Un morceau de route importe le morceau d'entrée, et suivre les imports dynamiques de l'entrée entraînerait toutes les pages : mesuré à 475 fichiers et 14 Mo, contre environ 60 fichiers et 2,4 Mo de JS maintenant."}),e.jsxs(i,{children:["Une seconde liste, ",e.jsx(s,{children:"OFFLINE_SHELL_MATCHERS"}),", couvre ce qu'une application ouverte déjà hors ligne charge d'abord :"," ",e.jsx(s,{children:"pages/home.tsx"})," et ",e.jsx(s,{children:"pages/my-day*.tsx"})," (URL de démarrage ",e.jsx(a,{children:"/"}),") et ",e.jsx(s,{children:"lib/translations.fr.ts"}),". On n'y suit que les imports statiques. Un morceau fusionné par Rollup n'a pas de façade : il est reconnu par ses ",e.jsx(s,{children:"moduleIds"}),"."]})]}),e.jsx(t,{id:"offline-snapshot",title:"52. Préparer pour le hors-ligne et instantané des données de référence",children:e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Portée"})," : les sites épinglés sur cet appareil (25 au plus) et les 10 derniers ouverts. Un site compte comme ouvert quand sa page ",e.jsx(a,{children:"/sites/:siteId"})," est affichée ou quand il est choisi dans l'un des deux formulaires (",e.jsx(s,{children:"useRecordRecentSite"}),"). Les deux listes sont dans le magasin ",e.jsx(s,{children:"refdata"}),", avec la clé ",e.jsx(s,{children:"tenantId:userId"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Lectures"})," (",e.jsx(s,{children:"lib/offline/snapshot.ts"})," →"," ",e.jsx(s,{children:"buildOfflineSnapshot"}),") : uniquement les requêtes existantes, limitées au tenant, que les formulaires utilisent,"," ",e.jsx(s,{children:"serviceReports.queries.getFormOptions"})," et"," ",e.jsx(s,{children:"maintenanceReports.queries.getFormOptions"})," (sans site puis par site), ",e.jsx(s,{children:"maintenanceReports.templates.list"})," et"," ",e.jsx(s,{children:"maintenanceReports.categories.list"}),". Un formulaire que l'utilisateur ne peut pas ouvrir est sauté, et une lecture en échec est comptée sans perdre le reste. Pour la fiche terrain de la borne (",e.jsx(a,{children:"/chargers/:id/field"}),"), jusqu'à 150 bornes de ces sites reçoivent aussi les lectures de la fiche : ",e.jsx(s,{children:"chargers.getById"}),","," ",e.jsx(s,{children:"sites.getById"}),", ",e.jsx(s,{children:"chargers.getFieldHubReports"}),", et"," ",e.jsx(s,{children:"tickets.getChargerCaseHistory"})," seulement si la fiche peut afficher les cas. Aucun changement serveur, aucune nouvelle requête."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Stockage"})," : chaque réponse est rangée sous"," ",e.jsx(s,{children:"q:<fonction>:<arguments stables>"})," (clés triées, valeurs undefined retirées) dans la forme que le formulaire lit. Les listes de sites s
96ont réduites aux sites de l'instantané, jamais tout le tenant, et les clés d'un instantané précédent sont élaguées. Le manifeste ",e.jsx(s,{children:"fieldMode:snapshot"})," garde l'âge et les comptes. Au plus 4 lectures sont en cours à la fois."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Données personnelles sur l'appareil"})," : parce que les formulaires en ont besoin, les réponses enregistrées incluent les utilisateurs assignables (noms et adresses courriel, pour le choix du technicien) et les contacts des sites de l'instantané. Elles restent sur l'appareil jusqu'à la prochaine préparation ou jusqu'à « Effacer les données de l'appareil ». Il n'y a pas de chiffrement sur l'appareil en v1 ; on compte sur le verrouillage de l'appareil (voir la conception)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Après un déploiement"})," : ",e.jsx(s,{children:"OfflineAssetsRewarm"})," ","(monté une fois dans ",e.jsx(s,{children:"App.tsx"}),") recharge les fichiers de l'application en arrière-plan sur un appareil déjà préparé. L'instantané de données garde son propre âge."]})]})}),e.jsx(t,{id:"offline-use-offline-query",title:"53. useOfflineQuery",children:e.jsxs(i,{children:[e.jsx(s,{children:"useOfflineQuery(query, args)"})," renvoie"," ",e.jsx(s,{children:"{ data, stale, savedAt? }"}),". Il renvoie les données Convex en direct dès qu'il y en a. Il ne lit l'instantané de la même fonction et des mêmes arguments, avec ",e.jsx(s,{children:"stale: true"}),", que si l'appareil est ",e.jsx(n,{children:"confirmé hors ligne"})," ","(",e.jsx(s,{children:"confirmedOffline"})," du runtime, voir §56) et qu'il n'y a pas de données en direct. Confirmé hors ligne veut dire que le navigateur dit hors ligne, ou que la connexion est coupée depuis plus de 3 s après sa dernière coupure. Le runtime dit hors ligne à chaque démarrage tant que le socket n'est pas connecté : sans ce délai, chaque chargement normal afficherait de vieilles données. Deux autres champs sont renvoyés."," ",e.jsx(s,{children:"snapshotPending"})," signale que la lecture sur l'appareil n'a pas encore répondu. ",e.jsx(s,{children:"missing"})," signale qu'elle est revenue vide ; c'est le seul état où un formulaire ou la fiche dit « non préparé ». Seuls l'utilisent le formulaire de rapport de service, l'assistant d'entretien (site, bornes, gabarits, catégories) et la fiche terrain de la borne (",e.jsx(s,{children:"useChargerFieldHub"}),"). ",e.jsx(s,{children:"OfflineSnapshotNotice"})," ","affiche l'âge de l'instantané (",e.jsx(s,{children:"formatDateTime"}),"), ou, hors ligne sans préparation, un renvoi vers ",e.jsx(a,{children:"/field-mode"}),"."]})}),e.jsxs(t,{id:"offline-identity",title:"54. Démarrage hors ligne : dernière identité connue",children:[e.jsxs(i,{children:["Tout attend ",e.jsx(s,{children:"users.queries.me"}),", qui a besoin du websocket."," ",e.jsx(s,{children:"AuthProvider"})," enregistre donc le dernier ",e.jsx(s,{children:"me"})," ","reçu en direct (",e.jsx(s,{children:"lib/offline/last-identity.ts"}),"). Au démarrage hors ligne (",e.jsx(s,{children:"navigator.onLine === false"}),", ou socket toujours coupé après 3 s, voir ",e.jsx(s,{children:"useDeviceOffline"}),"), il sert ce profil avec ",e.jsx(s,{children:"isOfflineIdentity: true"}),", à deux conditions :"]}),e.jsxs(l,{children:[e.jsxs(r,{children:["un jeton Convex Auth (",e.jsx(s,{children:"__convexAuthJWT_*"}),") est toujours dans le stockage ;"]}),e.jsxs(r,{children:["le ",e.jsx(s,{children:"sub"})," du jeton (",e.jsx(s,{children:"userId|sessionId"}),") désigne le même utilisateur."]})]}),e.jsxs(i,{children:["La déconnexion, et une authentification qui se stabilise sur « non connecté », effacent l'enregistrement. Le personnel de la plateforme n'est jamais conservé : son ",e.jsx(s,{children:"me"})," peut décrire un tenant emprunté. Une fois servi, le profil reste jusqu'à l'arrivée du profil en direct, pour qu'une reconnexion ne démonte pas le formulaire. Le runtime hors ligne en tire la portée du tenant d'origine, et rien n'est envoyé au serveur sur cette seule base."]}
96),e.jsx(d,{tone:"info",title:"Appareils réels",children:e.jsx(i,{children:"Le panneau Navigateur de l'application ralentit les onglets cachés. Le test d'acceptation reste un essai en mode avion sur iOS Safari et Android Chrome."})})]}),e.jsxs(t,{id:"offline-pending-sync",title:"55. « À synchroniser », conflits et garde de déconnexion",children:[e.jsx(i,{children:"CHARGEOPS-44 rend la file visible et ferme les dernières façons de perdre ou d'écraser du travail."}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Écritures versionnées"})," : ",e.jsx(s,{children:"updateDraft"})," sur"," ",e.jsx(s,{children:"serviceReports"})," et ",e.jsx(s,{children:"maintenanceReports"})," accepte un ",e.jsx(s,{children:"baseUpdatedAt"})," facultatif. Si la ligne a changé depuis, rien n'est écrit et ",e.jsx(s,{children:"{ conflict: true, id, server }"})," ","revient. La vérification passe après les gardes de portée, d'auteur et de finalisation : un conflit ne livre jamais un rapport que l'appelant ne pourrait pas modifier. Avec ",e.jsx(s,{children:"baseUpdatedAt"})," ","ou ",e.jsx(s,{children:"returnVersion"}),", la réponse est"," ",e.jsx(s,{children:"{ conflict: false, id, updatedAt }"}),"; sans l'un ni l'autre, c'est l'identifiant seul, comme avant, car d'anciens paquets et des appareils hors ligne font tourner l'ancien code des heures après un déploiement. ",e.jsx(s,{children:"createDraft"})," accepte"," ",e.jsx(s,{children:"returnVersion"})," et donne ",e.jsx(s,{children:"createdAt"})," comme version, y compris à la relecture : une modification faite ailleurs après une création dont la réponse s'est perdue reste un conflit."," ",e.jsx(s,{children:"saveFinal"})," (service) accepte la même base."]}),e.jsxs(r,{children:[e.jsx(n,{children:"La version"})," : un rapport de service utilise"," ",e.jsx(s,{children:"updatedAt"})," (les photos n'y touchent pas). Un rapport de maintenance utilise le nouveau champ facultatif"," ",e.jsx(s,{children:"metaUpdatedAt"}),", à défaut ",e.jsx(s,{children:"createdAt"}),", posé seulement par ",e.jsx(s,{children:"createDraft"}),", ",e.jsx(s,{children:"updateDraft"}),","," ",e.jsx(s,{children:"updateTiming"}),", ",e.jsx(s,{children:"updateVisitCosts"})," (quand il change quelque chose), une amélioration du titre ou du résumé, et"," ",e.jsx(s,{children:"finalize"})," quand il change les sections du PDF ou pose l'heure de fin (sinon un appareil resté sur l'ancienne base réécrirait sa copie après une réouverture). Les sections se comparent avec leurs valeurs par défaut : un ancien rapport dont le choix enregistré n'a pas une clé plus récente, ou n'en a aucun, garde sa version à une finalisation sans changement (CHARGEOPS-371)."," ",e.jsx(s,{children:"updateVisitCosts"})," accepte les mêmes"," ",e.jsx(s,{children:"baseUpdatedAt"})," / ",e.jsx(s,{children:"returnVersion"}),"; le gestionnaire envoie les coûts contre la version rendue par la mise à jour et garde celle rendue par l'appel des coûts : sa propre écriture de coûts n'entre jamais en conflit avec elle-même. La copie du serveur en conflit porte les coûts, les ",e.jsx(s,{children:"sections"})," ","du PDF et ",e.jsx(s,{children:"chrgpkPerformed"})," (la section CHRGPK s'applique-t-elle), et les montants seulement pour qui a ",e.jsx(s,{children:"viewFinancials"}),". Son ",e.jsx(s,{children:"updatedAt"})," bouge à chaque écriture de liste de contrôle, de borne ou de photo : les propres modifications en ligne de l'assistant passeraient pour des conflits. ",e.jsx(s,{children:"getById"})," ","l'expose sous ",e.jsx(s,{children:"draftVersion"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Sur l'appareil"})," : les gestionnaires de synchronisation envoient le ",e.jsx(s,{children:"baseUpdatedAt"})," du brouillon, notent chaque version reçue avec ",e.jsx(s,{children:"setServerId"})," et, en cas de conflit, gardent la copie du serveur dans"," ",e.jsx(s,{children:"DraftRecord.conflict"})," (",e.jsx(s,{children:"lib/offline/conflicts.ts"}),"). Savoir si les deux copies diffèrent est une comparaison complète de tous les champs que l'appareil enverrait (",e.jsx(s,{children:"serviceCopyDiff"})," ","/ ",e.jsx(s,{children:"maintenanceCopyDiff"}),", HTML brut compris, le choix des sections du PDF aussi, montré sur une seule ligne « Sections du PDF » qui liste les sections actives de chaque côté — la section CHRGPK n'est comparée et listée que si l'inspection CHRGPK du rapport a été faite, comme dans le choix des sections); si elles sont identiques, le gestionnaire (et le formulaire, pour un conflit déjà enregistré) se rebase sans rien demander. La fenêtre range les champs sans ligne propre sur une ligne « Autres détails » et montre un long texte à partir de sa première différence. Sinon, ce brou
96illon n'est plus envoyé tant que la personne n'a pas choisi :"," ",e.jsx(s,{children:"keepDeviceVersion"})," se rebase sur la version du serveur et met un envoi en file (un écrasement voulu),"," ",e.jsx(s,{children:"acceptServerVersion"})," remplace le formulaire de l'appareil par la copie du serveur. Une création est toujours suivie d'une mise à jour versionnée. « Enregistrer le brouillon » et « Enregistrer final » du formulaire de service envoient aussi la base : celle de l'appareil, ou la version à l'ouverture du formulaire, jamais celle de l'abonnement en direct."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Pastille et écran"})," : ",e.jsx(s,{children:"useOfflineWork"})," lit les brouillons et la file de la personne connectée toutes les 2 s, toujours par sa portée ",e.jsx(s,{children:"tenantId:userId"}),", et"," ",e.jsx(s,{children:"lib/offline/pending.ts"})," les regroupe par rapport (conflit › à vérifier › en attente d'une mise à jour › en file › sur l'appareil). ",e.jsx(s,{children:"ConnectivityPill"})," est dans les en-têtes bureau et mobile d'",e.jsx(s,{children:"AppLayout"})," pour les auteurs de rapports; le portail ne la monte pas, les opérateurs de site n'écrivant pas de rapports. ",e.jsx(a,{children:"/pending-sync"})," affiche les raisons par clés de traduction (",e.jsx(s,{children:"describeItemError"}),"), jamais le message brut du serveur; une opération absente de"," ",e.jsx(s,{children:"KNOWN_OFFLINE_OPS"})," se lit « en attente d'une mise à jour de l'application ». ",e.jsx(s,{children:"lib/offline/discard.ts"})," est le seul code qui jette du travail non envoyé, et ses deux appels passent par une confirmation."," ",e.jsx(n,{children:"Sur téléphone"})," (S3 W1, CHARGEOPS-583) : chaque groupe devient une carte ",e.jsx(s,{children:"ResponsiveList"})," (titre, statut, quand/combien d'éléments) ; glisser propose Réessayer (groupe à vérifier) ; Supprimer est ",e.jsx(s,{children:"menuOnly"})," ","(« ⋯ » seulement). Les deux passent par les mêmes gestionnaires et la même confirmation que sur ordinateur. L'action « Éléments… » ouvre le détail par élément (Réessayer / Supprimer cet élément) dans une feuille — le composant"," ",e.jsx(s,{children:"Dialog"})," adaptatif, pas un nouveau composant — qui réutilise ",e.jsx(s,{children:"GroupItemsList"}),", la même liste que le bloc imbriqué d'ordinateur/tablette (aucune duplication de logique). La feuille est tenue par la clé du brouillon, pas par l'objet groupe : un Réessayer/Supprimer fait depuis la feuille se reflète tout de suite (et la feuille se ferme d'elle-même si le groupe disparaît)."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Déconnexion et purge"})," : ",e.jsx(s,{children:"useSignOutGuard"})," ","enveloppe les boutons de déconnexion de ",e.jsx(s,{children:"ProfileMenu"})," et"," ","du menu ☰ du téléphone (",e.jsx(s,{children:"NavMenu"}),") (AuthProvider est hors du runtime). Il ne supprime rien. « Se connecter » sur l'écran « À synchroniser » y revient après la connexion (",e.jsx(s,{children:"lib/offline/sign-in-return.ts"}),", seulement ",e.jsx(a,{children:"/pending-sync"}),", une fois)."," ",e.jsx(s,{children:"OfflineHousekeeping"})," (dans"," ",e.jsx(s,{children:"App.tsx"}),") retire, pour toute personne de l'appareil, les brouillons synchronisés depuis plus de 30 jours sans rien en file ni conflit ouvert, avec leurs photos. Le travail non synchronisé n'est"," ",e.jsx(n,{children:"jamais"})," purgé : rien n'est jeté sans confirmation explicite. Une session expirée retire le runner; la même personne qui se reconnecte obtient un nouveau runner qui vide la même file, et une autre personne ne la lit jamais."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Sans stockage sur l'appareil, appareil plein, rapport disparu"})," ","(suivi hors ligne B) : l'enregistrement direct de l'assistant de maintenance (IndexedDB indisponible, ou stockage plein en ligne) passe par le même ",e.jsx(s,{children:"sendMaintenanceDraft"})," que le gestionnaire, donc par la même règle de version : la base est le"," ",e.jsx(s,{children:"draftVersion"})," de l'hydratation, puis la version rendue par chacun de ses enregistrements (ou la base de la copie de l'appareil si elle est plus récente, après un repli en cours de visite). Les enregistrements directs sont enchaînés : un envoi encore en cours ne fait jamais partir le suivant avec une base périmée. Un refus est gardé dans ",e.jsx(s,{children:"directConflict"})," et passe par le même ",e.jsx(s,{children:"DraftConflictDialog"})," ; l'enregistrement automatique attend le choix, et des copies identiques sont simplement rebasées. La sélection en bloc et « Tout effacer » partagent ",e.jsx(s,{children:"runChargerOps"})," avec le toucher d'une borne : sur ",e.jsx(s,{children:"OfflineQuotaError"})," en ligne, l'opération refusée et toutes les suivantes partent directement à Convex après un ",e.jsx(s,{children:"syncNow"}),". Les deux formulaires de rapport lisent le rapport avec ",e.jsx(s,{children:"useReportQuery"})," ","(",e.jsx(s,{children:"hooks/use-report-query.ts"}),", sur ",e.jsx(s,{children:"useQueries"}
96)," ","pour qu'une lecture refusée revienne comme une valeur) : une"," ",e.jsx(s,{children:"ConvexError"})," autre que ",e.jsx(s,{children:"tenantSuspended"}),", un"," ",e.jsx(s,{children:"null"})," après que le rapport a été chargé, ou un"," ",e.jsx(s,{children:"null"})," encore là 2 s plus tard en ligne signifie « indisponible », et ",e.jsx(s,{children:"ReportUnavailable"})," remplace le formulaire — ni hydratation, ni écriture sur l'appareil. Une lecture hors ligne ne donne jamais null : les ouvertures hors ligne ne changent pas."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Enregistrement direct sur un appareil plein"})," ","(CHARGEOPS-275) : l'appareil ne peut noter ni la version rendue ni retirer la synchro déjà en file, qui rejouait plus tard l'ancienne copie sur son ancienne base — refusée sans rien écraser, mais levée en conflit inutile. Chaque enregistrement direct réussi (assistant de maintenance, et"," ",e.jsx(s,{children:"adoptDirectSave"})," du formulaire de service) laisse une note (",e.jsx(s,{children:"lib/offline/direct-saves.ts"}),","," ",e.jsx(s,{children:"noteDirectSave"}),") et tente"," ",e.jsx(s,{children:"settleDirectSave"})," tout de suite ; si l'appareil est encore plein, le gestionnaire de synchro la règle avant tout envoi. Une copie de l'appareil enregistrée au plus tard au moment de l'envoi direct est remplacée : copie envoyée, base = version rendue, synchronisée, et seules les opérations de synchro du brouillon sont retirées (photos, bornes et suppressions restent). Une copie plus récente est seulement rebasée et reste en file : elle part par-dessus, et une vraie modification du bureau depuis reste un conflit. Un conflit enregistré à une version au plus égale est retiré. La note vit en mémoire et dans"," ",e.jsx(s,{children:"localStorage"})," (un petit quota séparé qu'une IndexedDB pleine ne touche pas), sous"," ",e.jsx(s,{children:"co-offline-direct-save:<tenant>:<user>:<draft>"}),", donc elle survit à un onglet fermé (CHARGEOPS-369) : le balayage du démarrage suivant règle d'abord les notes de la personne connectée (",e.jsx(s,{children:"settleStoredDirectSaves"}),", arrêté à la première erreur de quota), et le gestionnaire de synchro en règle une s'il passe avant. Une note réglée est retirée des deux ; une note illisible est retirée ; une note dont la version est derrière la base de la copie de l'appareil est retirée sans toucher la copie (une base ne recule jamais). Si ",e.jsx(s,{children:"localStorage"})," est plein ou bloqué, la note reste en mémoire seulement, comme avant."]})]})]}),e.jsxs(t,{id:"offline-download-size-and-unsent-badge",title:"56. Taille du téléchargement et badge « modifications non envoyées » (suivi C)",children:[e.jsx(i,{children:"Deux ajouts additifs sur les CHARGEOPS-43 et -44 : la taille approximative du téléchargement avant même que tu appuies sur « Préparer pour le hors-ligne », et un second badge pour un rapport que la liste affiche déjà depuis le serveur mais dont la copie sur l'appareil a du travail non envoyé."}),e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Taille du téléchargement"})," : ",e.jsx(s,{children:"computeOfflineAssetsBytes(bundle, files)"})," ","(",e.jsx(s,{children:"scripts/sw-offline-assets.mjs"}),") additionne la taille en octets exactement des URL que ",e.jsx(s,{children:"collectOfflineAssets"})," a renvoyées, lue dans le même bundle Rollup (",e.jsx(s,{children:"code"}
96)," de chaque morceau, ",e.jsx(s,{children:"source"})," de chaque fichier statique) — aucun accès disque, aucune requête HEAD."," ",e.jsx(s,{children:"stampServiceWorker()"})," écrit ce total dans"," ",e.jsx(s,{children:"sw.js"})," à la place d'un second marqueur,"," ",e.jsx(s,{children:"/* @offline-assets-bytes */ 0"}),", à côté de"," ",e.jsx(s,{children:"OFFLINE_ASSETS"}),". Le worker le renvoie sous"," ",e.jsx(s,{children:"bytes"})," dans ",e.jsx(s,{children:"GET_OFFLINE_STATUS"})," — le même message qui portait déjà les comptes de fichiers — pour que la page Mode terrain (",e.jsx(s,{children:"lib/offline/sw-client.ts::OfflineAssetStatus"}),") l'affiche (",e.jsx(s,{children:"formatFileSize"}),") dès son montage, sans dépendre de ce qui a déjà été mis en cache."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Taille transférée (CHARGEOPS-277)"})," : ce total brut est ce que les fichiers occupent une fois stockés (le Cache API garde le corps décodé), pas ce qu'ils coûtent à télécharger : le CDN les envoie compressés, environ trois fois plus petits."," ",e.jsx(s,{children:"computeOfflineAssetsTransferBytes(bundle, files)"})," ","additionne, pour chaque fichier texte (",e.jsx(s,{children:"COMPRESSIBLE_ASSET_RE"})," : JS, CSS, SVG, JSON…) d'au moins 1 000 octets, sa taille Brotli à la qualité 3, et la taille brute de tout le reste (images, fichiers de moins de 1 000 octets). Mesuré en production le 2026-09-25 (188 fichiers, ",e.jsx(s,{children:"Accept-Encoding"})," d'un navigateur) : Vercel répond ",e.jsx(s,{children:"br"})," pour chaque fichier texte de 1 017 octets ou plus, ",e.jsx(s,{children:"identity"})," jusqu'à 957 octets, et la qualité 3 reproduit ses corps à 2 octets près ; l'estimation d'un build de production tombait à moins de 0,1 % du transfert réel. Le calcul prend environ 0,1 s. Les deux totaux sont calculés dans ",e.jsx(s,{children:"writeBundle"}),", pas"," ",e.jsx(s,{children:"generateBundle"})," : les plugins post de Vite réécrivent encore le code des morceaux après (les tables de préchargement, environ 28 Ko). ",e.jsx(s,{children:"sw.js"})," reçoit le second total à la place de ",e.jsx(s,{children:"/* @offline-assets-transfer-bytes */ 0"})," et le renvoie sous ",e.jsx(s,{children:"transferBytes"})," ; la page affiche « environ ","{download}"," (","{stored}"," une fois stockés sur cet appareil) », ou l'ancien libellé brut face à un worker qui ne le connaît pas encore."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Unités"})," : ",e.jsx(s,{children:"lib/format.ts::formatFileSize"})," ","est le seul formateur de tailles de fichiers de l'application, en multiples binaires (pas de 1024), comme toutes les limites de l'application (",e.jsx(s,{children:"MAX_PHOTO_BYTES = 20 * 1024 * 1024"})," est le « 20 Mo » de chaque refus du serveur, voir"," ",e.jsx(s,{children:"convex/lib/storage.ts"}),"), avec les libellés de la langue de l'application — ",e.jsx(s,{children:"o / Ko / Mo / Go"})," en français,"," ",e.jsx(s,{children:"B / KB / MB / GB"})," en anglais — et une espace insécable entre le nombre et l'unité. Il écrivait le « kB » SI pour un pas de 1024 ; les formateurs locaux du gestionnaire de fichiers, des sauvegardes, des RMA, de l'import de bornes et de l'extension Chrome passent maintenant par lui. Le plafond des cas fabricant (18 × 1024²) s'affiche donc exactement « 18 Mo »."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Badge « modifications non envoyées »"})," :"," ",e.jsx(s,{children:"lib/offline/pending.ts::unsentEditsServerIds(groups, kind?)"})," ","renvoie le ",e.jsx(s,{children:"serverId"})," de chaque brouillon qui en a un mais dont le ",e.jsx(s,{children:"syncedAt"})," reste indéfini — le rapport a atteint le serveur, mais cet appareil garde des modifications non confirmées synchronisées. C'est un signal différent de"," ",e.jsx(s,{children:"deviceOnly"})," (aucun ",e.jsx(s,{children:"serverId"})," du tout, le rapport que ",e.jsx(s,{children:"DeviceOnlyDrafts"})," affiche au-dessus de la liste). Les listes de rapports de service et d'entretien (",e.jsx(s,{children:"pages/service-reports.tsx"}),","," ",e.jsx(s,{children:"pages/maintenance-reports/list.tsx"}),", ses vues tableau et fiches) lisent ",e.jsx(s,{children:"useOfflineWork()"})," une seule fois au niveau de la page et badgent la ligne ou la fiche correspondante avec ",e.jsx(s,{children:"UnsentEditsBadge"})," (",e.jsx(s,{children:"components/offline/unsent-edits-badge.tsx"}),"), plutôt que d'ajouter une seconde carte au-dessus du tableau."]})]})]}),e.jsx(t,{id:"offline-outbox-robustness",title:"57. Robustesse de la file : balayage au démarrage, deux onglets, un seul signal hors ligne",children:e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"Balayage au démarrage"})," (",e.jsx(s,{children:"lib/offline/startup-sweep.ts"}),") :"," ",e.jsx(s,{children:"OfflineHousekeeping"})," lance ",e.jsx(s,{children:"sweepUnsentDrafts"})," une fois par base de l'appareil et par identité. Chaque brouillon de la personne connectée (",e.jsx(s,{children:"listDrafts"}),", donc jamais celui d'une autre) non envoyé, sans conflit ouvert et sans synchronisation en file ou en attente de vérification reçoit l'opération de synchronisation de son type par"," ",e.jsx(s,{children:"enqueueUnique"})," (",e.jsx(s,{children:"serviceReport.sync"}),","," ",e.jsx(s,{children:"maintenanceReport.sync"}),"), puis ",e.jsx(s,{children:"syncNow()"}),". Les brouillons de service suivent la règle du formulaire,"," ",e.jsx(s,{children:"serviceDraftWantsSync"})," (pas final, envoyable). Une copie liée à une ligne du serveur sans ",e.jsx(s,{children:"baseUpdatedAt"})," (écrite avant CHARGEOPS-44) est laissée au formulaire, qui la rebase à l'ouverture. Un onglet fermé avant la mise en file de sa synchronisation atteint donc le serveur au prochain démarrage. Une copie créée pour une photo ou une borne sur un rapport existant, sans rien de saisi, est marquée synchronisée dès sa création (l'assistant compare le formulaire à la copie du serveur chargée, le formulaire de service utilise"," ",e.jsx(s,{children:"serviceCopyDiff"})," contre la copie du serveur adoptée) : elle n'est jamais envoyée par-dessus des modifications faites ensuite au bureau."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Même rapport dans deux onglets"})," (",e.jsx(s,{children:"lib/offline/draft-presence.ts"}),") : chaque formulaire tient un verrou Web Lock ",e.jsx(n,{children:"partagé"})," ",e.jsx(s,{children:"chargeops-open:<clé>"})," par clé de présence (le brouillon de l'appareil et ",e.jsx(s,{children:"service:<id>"})," / ",e.jsx(s,{children:"maintenance:<id>"}),", car deux onglets sur un même rapport peuvent tenir des brouillons différents) et interroge ",e.jsx(s,{children:"navigator.locks.query()"})," toutes les 2 s. Une clé tenue par deux ",e.jsx(s,{children:"clientId"})," différents, à deux vérifications de suite (la seconde 250 ms après la première), affiche"," ",e.jsx(s,{children:"OpenElsewhereBanner"})," dans les deux onglets ;
96 un même onglet qui tient une clé deux fois ne compte pas. Ce n'est qu'un avertissement ; le navigateur libère les verrous quand un onglet se ferme ou plante. Sans Web Locks, rien ne s'affiche."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Événements du runner"})," : ",e.jsx(s,{children:"paused-auth"})," porte l'élément ; une suppression locale qui échoue après un gestionnaire réussi émet ",e.jsx(s,{children:"item-delete-failed"})," (l'élément est rejoué ; le runtime le journalise) ; ",e.jsx(s,{children:"retryItem(db, id, now)"})," inscrit l'heure au lieu de 0 et reste admissible tout de suite. ",e.jsx(s,{children:"deleteDraft"}),","," ",e.jsx(s,{children:"discardItem"})," et ",e.jsx(s,{children:"deletePhoto"})," passent par"," ",e.jsx(s,{children:"guardQuota"}),", comme la suppression du runner : un disque plein peut refuser même la validation d'une suppression."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Un seul signal « confirmé hors ligne »"})," :"," ",e.jsx(s,{children:"device-offline.ts::useConfirmedOffline"})," est la seule logique de délai (navigateur hors ligne, ou hors ligne depuis plus de 3 s après la dernière coupure). Le runtime l'expose sous ",e.jsx(s,{children:"confirmedOffline"})," ","(lu par ",e.jsx(s,{children:"useOfflineQuery"}),") et en dérive"," ",e.jsx(s,{children:"connectivityKnown"}),", avec le même délai au démarrage seulement qu'avant (pastille, Enregistrer final). ",e.jsx(s,{children:"useDeviceOffline"}),", dont AuthProvider a besoin au-dessus du runtime, est le même hook alimenté par la connexion Convex. Une confirmation n'est partagée entre instances que tant que l'une d'elles observe encore."]})]})}),e.jsx(t,{id:"offline-storage-full-banner",title:"58. Bandeau « appareil plein » pour toute l'application",children:e.jsxs(l,{children:[e.jsxs(r,{children:[e.jsx(n,{children:"État"})," (",e.jsx(s,{children:"lib/offline/errors.ts"}),", sans React) :"," ",e.jsx(s,{children:"guardQuota"})," met un drapeau de module à vrai sur une erreur de quota et le remet à faux dès qu'une écriture protégée réussit ; une autre erreur ne le change pas. ",e.jsx(s,{children:"subscribeOfflineStorageFull"})," ne prévient qu'à un changement (le chemin de succès coûte une comparaison) et"," ",e.jsx(s,{children:"isOfflineStorageFull"})," le lit ; ",e.jsx(s,{children:"onOfflineStorageError"})," ","reste appelé à chaque erreur de quota. Côté React :"," ",e.jsx(s,{children:"lib/offline/use-offline-storage-full.ts::useOfflineStorageFull"})," ","(",e.jsx(s,{children:"useSyncExternalStore"}),")."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Écritures protégées"})," : en plus des brouillons, de la file, des photos, des conflits, des refdata et des suppressions, passent désormais par ",e.jsx(s,{children:"guardQuota"})," la purge de ",e.jsx(s,{children:"OfflineHousekeeping"}),", l'élagage et « Effacer les données de l'appareil » du cliché (",e.jsx(s,{children:"snapshot.ts"}),") et la libération de la copie d'une photo après son rattachement dans les deux gestionnaires. Les erreurs se propagent comme avant ; l'appareil plein et le retour de l'espace sont vus d'où qu'ils viennent."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Bandeau"})," (",e.jsx(s,{children:"components/offline/storage-full-banner.tsx"}),") : monté une fois dans ",e.jsx(s,{children:"App.tsx"}),", à côté de"," ",e.jsx(s,{children:"OfflineHousekeeping"}),", et rendu par ",e.jsx(s,{children:"createPortal"})," dans"," ",e.jsx(s,{children:"document.body"})," (un élément ",e.jsx(s,{children:"fixed"})," sous un ancêtre filtré ou transformé perd son ancrage). ",e.jsx(s,{children:'role="alert"'}),","," ",e.jsx(s,{children:"co-card"}),", en bas, pleine largeur avec 16 px de marge sur téléphone, ",e.jsx(s,{children:"z-50"}),", et il ne couvre jamais ce qu'il ne doit pas (CHARGEOPS-368). Il se place au-dessus de la barre d'actions fixe de l'assistant de maintenance, qui publie sa hauteur dans ",e.jsx(s,{children:"--co-bottom-bar-h"})," (",e.jsx(s,{children:"hooks/use-bottom-bar-inset.ts"}),"), et au-dessus de la bannière d'installation PWA, qui publie sa hauteur plus un écart de 0,5 rem dans ",e.jsx(s,{children:"--co-install-banner-h"})," (",e.jsx(s,{children:"usePublishedHeight"}),") et se place elle-même au-dessus de cette barre ; les deux ",e.jsx(s,{children:"bottom"})," viennent de ",e.jsx(s,{children:"aboveBottomBar()"}),". Il se place aussi au-dessus du bouton de l'assistant ChargeOps AI (§14i) : son ",e.jsx(s,{children:"bottom"})," est"," ",e.jsx(s,{children:'aboveBottomBar("1rem", "max(var(--co-assistant-launcher-h, 0px), var(--co-install-banner-h, 0px))")'})," ","(",e.jsx(s,{children:"STORAGE_BANNER_BOTTOM"}),") — le maximum et non la somme, parce que la place du bouton inclut déjà celle de la bannière d'installation, qui le pousse aussi. Il porte ",e.jsx(s,{children:"co-card is-static is-floating"}),". Il s'efface pendant qu'une couche modale est ouverte, que la fenêtre se soit ouverte avant ou après le remplissage :"," ",e.jsx(s,{children:"hooks/use-modal-layer-open.ts"})," surveille le"," ",e.jsx(s,{children:"pointer-events: none"})," que Radix pose sur ",e.jsx(s,{children:"<body>"})," ","pendant qu'une fenêtre, une alerte, un panneau, un tiroir, un menu modal ou une liste déroulante est ouvert. Un bandeau réduit le reste après. « Synchroniser » appelle"," ",e.jsx(s,{children:"syncNow()"})," du runtime, masqué hors ligne ; « Mode terrain » mène à"," ",e.jsx(a,{children:"/field-mode"}),". ✕ le réduit en pastille jusqu'à la prochaine erreur de quota (",e.jsx(s,{children:"onOfflineStorageError"}),"). Même garde que la pastille de connectivité (auteur de rapports, base ",e.jsx(s,{children:"open"}),", identité connue) et jamais sous ",e.jsx(a,{children:"/portal"}),"."]}),e.jsxs(r,{children:[e.jsx(n,{children:"Formulaires"})," : l'assistant d'entretien, ses photos de bornes et le formulaire de service n'affichent plus de toast « stockage plein ». L'assistant bascule toujours sur ses enregistrements directs en ligne, et les autres toasts d'échec sur l'appareil restent."]})]})})]}),e.jsxs(o,{id:"annexes",title:"Annexes",children:[e.jsx(t,{id:"architecture-providers",title:"Architecture des providers (App.tsx)",children:e.jsx(c,{children:`<ErrorBoundary level="app">
97  <ConvexAuthProvider>
98    <AuthProvider>
99      <LanguageProvider>     ← useLanguage()
100        <ThemeProvider>      ← useTheme()
101          <PageMetaProvider> ← useSetPageMeta()
102            <TooltipProvider>
103              <LazyMotion features={domAnimation}>
104                <AnalyticsTracker />
105                <NavPreloader />
106                <ErrorBoundary level="component">
107                  <Router />  ← Suspense + lazy routes
108                </ErrorBoundary>
109                <Toaster />
110                <PWAInstallBanner />
111              </LazyMotion>`})}),e.jsx(t,{id:"hooks-ui-cles",title:"Hooks UI clés",children:e.jsx(h,{rows:[{key:e.jsx(s,{children:"useTheme()"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"theme"}),", ",e.jsx(s,{children:"setTheme"}),","," ",e.jsx(s,{children:"toggleTheme"})," (light/dark, persisté localStorage)."]})},{key:e.jsx(s,{children:"useLanguage()"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"language"}),", ",e.jsx(s,{children:"setLanguage"}),","," ",e.jsx(s,{children:"supportedLanguages"}),". Écrit côté serveur."]})},{key:e.jsx(s,{children:"useIsMobile()"}),value:"bool — viewport < 1024 px (largeur seule, plus de vérification tactile)."},{key:e.jsx(s,{children:"useSetPageMeta(title, subtitle?, showNavLinks?, fullWidth?)"}),value:"Configure le header AppLayout."},{key:e.jsx(s,{children:"usePageMeta()"}),value:"Lit le meta courant (utilisé par AppLayout)."},{key:e.jsx(s,{children:"useBranding()"}),value:e.jsxs(e.Fragment,{children:["Lit ",e.jsx(s,{children:"brandingSettings"})," + résout les logos selon brand/theme."]})},{key:e.jsx(s,{children:"useToast()"}),value:"API toast."},{key:e.jsx(s,{children:"usePWAInstall()"}),value:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"canInstall"}),", ",e.jsx(s,{children:"installed"}),","," ",e.jsx(s,{children:"promptInstall"}),"."]})},{key:e.jsx(s,{children:"useIsStandalone()"}),value:e.jsxs(e.Fragment,{children:["Vrai dans l'app installée (",e.jsx(s,{children:"display-mode: standalone"})," ","ou ",e.jsx(s,{children:"navigator.standalone"}),") ; voir 9b."]})},{key:e.jsx(s,{children:"useAuth()"}),value:e.jsxs(e.Fragment,{children:["User courant + ",e.jsx(s,{children:"logoutMutation"}),"."]})},{key:e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"useFeatureFlags()"})," /"," ",e.jsx(s,{children:"usePowerManagementEnabled()"})]}),value:"Feature flags."},{key:e.jsx(s,{children:"useColumnConfiguration()"}),value:"Colonnes personnalisées (per-user)."}]})}),e.jsx(t,{id:"recap-css-vars",title:"Récap CSS — variables principales",children:e.jsx(u,{headers:["Variable","Light","Dark"],rows:[[e.jsx(s,{children:"--background"}),e.jsx(s,{children:"hsl(0 0% 100%)"}),e.jsx(s,{children:"hsl(240 10% 3.9%)"})],[e.jsx(s,{children:"--foreground"}),e.jsx(s,{children:"hsl(20 14.3% 4.1%)"}),e.jsx(s,{children:"hsl(0 0% 98%)"})],[e.jsx(s,{children:"--primary"}),e.jsx(s,{children:"hsl(223 89% 55%)"}),e.jsx(s,{children:"hsl(223 89% 55%)"})],[e.jsx(s,{children:"--card"}),e.jsx(s,{children:"hsl(0 0% 100%)"}),e.jsx(s,{children:"hsl(240 10% 3.9%)"})],[e.jsx(s,{children:"--ny-card"}),e.jsx(s,{children:"#FFFFFF"}),"(gradient bleu translucide)"],[e.jsx(s,{children:"--ny-primary"}),e.jsx(s,{children:"#2563EB"}),e.jsx(s,{children:"#2563EB"})],[e.jsx(s,{children:"--ny-radius"}),e.jsx(s,{children:"12px"}),e.jsx(s,{children:"12px"})]]})}),e.jsx(t,{id:"tables-convex-referencees",title:"Tables Convex référencées",children:e.jsx(u,{headers:["Table","Section schema.ts","Index principaux"],rows:[[e.jsx(s,{children:"chargerBrands"}),"L412",e.jsx(s,{children:"by_name"})],[e.jsx(s,{children:"chargerModels"}),"L417",e.jsx(s,{children:"by_brandId"})],[e.jsx(s,{children:"simCarriers"}),"L427",e.jsx(s,{children:"by_name"})],[e.jsx(s,{children:"usefulLinks"}
111),"L750",e.jsx(s,{children:"by_displayOrder"})],[e.jsx(s,{children:"userCustomLinks"}),"L761",e.jsx(s,{children:"by_userId_displayOrder"})],[e.jsx(s,{children:"columnConfigurations"}),"L630",e.jsxs(e.Fragment,{children:[e.jsx(s,{children:"by_pageId_isDefault"}),","," ",e.jsx(s,{children:"by_userId_pageId"})]})],[e.jsx(s,{children:"systemNotifications"}),"L643",e.jsx(s,{children:"by_isActive"})],[e.jsx(s,{children:"brandingSettings"}),"L841","(singleton)"],[e.jsx(s,{children:"emailNotificationSettings"}),"L662","(singleton)"],[e.jsx(s,{children:"userAnalyticsSessions"}),"(analytics)","by user/timestamp"],[e.jsx(s,{children:"pageViews"}),"(analytics)","by session"]]})})]}),e.jsx(o,{id:"fin-document",title:"Fin du document",children:e.jsxs(i,{children:["Pour les autres aspects de l'application, voir les autres docs du dossier ",e.jsx(s,{children:"docs/fr/"}),"."]})})]})}export{q as default};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.