Trois surfaces implémentent cet écran : terrain Angular (substitut mobile), iOS natif (AVFoundation), Android natif (ML Kit). Ce qui suit sépare ce qui doit être identique partout de ce que chaque plateforme décide seule. Les libertés sont volontaires : forcer une convention native à ressembler au Web coûte plus cher que la divergence.
| Point | Imposé aux 3 surfaces | Libre par plateforme |
|---|---|---|
| Place dans la navigation | Le scanner est l'écran d'accueil de l'onglet « Dispatchings », pas un 5ᵉ onglet. Les 4 onglets fixes d'US-085 ne bougent pas. RG-04 : quand les deux modes sont actifs, c'est le scanner qui s'ouvre. | — |
| Trois moments, un écran | Viseur → feuille de résultat → formulaire, sans changement d'écran. Fermer la feuille rend le viseur actif : on rescanne sans revenir en arrière. | La mécanique de la feuille : ModalBottomSheet (Android), superposition ZStack (iOS), .scrim/.sheet déjà présent dans terrain/src/styles.scss. |
| Le bandeau hors ligne | Le voile de la feuille démarre sous le bandeau « Mode hors ligne ». Ce bandeau est permanent par décision de projet : aucune feuille, aucune modale ne le masque. Il couvre le contenu et la barre du bas, rien de plus. | — Conséquence iOS : la feuille est une superposition dans la vue, pas une présentation .sheet — celle-ci couvrirait tout l'écran. |
| Bouton de repli | Libellé « Saisir manuellement », toujours visible sans défilement, et jamais en bouton primaire — RG-04 dit « visible mais non mis en avant ». Il ouvre le formulaire de dispatching immédiat existant, inchangé. | Sa position. Terrain et Android : sous le viseur, contour, pleine largeur. iOS : autorisé en bouton de barre de navigation à droite — c'est la convention de l'OS, et elle satisfait « visible, non mis en avant ». |
| Cadre de visée | Fenêtre carrée centrée, 4 équerres, voile sur le pourtour. Équerres en --kf-light + ombre portée --kf-dark — pas --kf-primary (voir §Contraste). |
Le rendu de la ligne de balayage (animation, pulsation, ou rien). Décoratif, et neutralisé sous prefers-reduced-motion. |
| Torche | — | Optionnelle. Dessinée en haut à droite du viseur si la surface la propose. Aucun lot n'est bloqué par son absence ; ne pas la considérer comme un critère d'acceptation. |
| Fiche du lot | En ligne : les 7 informations dans cet ordre — libellé, code QR, catégorie, poids, DLC, grade, statut. Hors ligne : catégorie (en ligne de titre), code QR, poids, badge Fiche partielle. Ni libellé, ni DLC, ni grade — voir §Hors ligne.Dans les deux cas, « Disponible : X kg » / « Solde estimé : X kg » ferme la fiche : c'est lui qui guide la saisie, il ne descend jamais sous la ligne de flottaison. |
La typographie du code QR (police à chasse fixe recommandée, pas exigée). |
| Scans récents | 3 entrées maximum, en mémoire de session, sous le viseur et après le bouton de repli. Une entrée = catégorie + heure + fin du code, plus une action ↻ qui relance la résolution du code. Aucun chiffre, aucun lien vers un écran d'historique, aucun état vide : à zéro entrée, la section n'est pas rendue. |
Le rendu de la ligne (séparateur, pastille, densité) suit les listes existantes de chaque surface. Pas la persistance : elle est interdite partout — aucun localStorage, UserDefaults, Room ou DataStore. |
| Formulaire | Même ossature numérotée que le dispatching immédiat existant. La catégorie devient le pas 1 verrouillé ; les pas 2 à 4 (site, quantité, type) et les champs commentaire / photos sont ceux du formulaire actuel, sans redessin. | Le clavier décimal (inputmode="decimal" / .decimalPad / numberDecimal) et le séparateur affiché — virgule en français sur les trois. |
| Permission caméra | Le bloc de permission occupe la place du viseur, même gabarit, même arrondi. Deux textes distincts selon que la permission n'a pas encore été demandée ou qu'elle a été refusée. Dans les deux cas, « Saisir manuellement » reste atteignable. | Ce que fait le bouton principal. iOS : AVCaptureDevice.requestAccess puis, si refus, UIApplication.openSettingsURLString. Android : demande runtime puis, si shouldShowRequestPermissionRationale est faux, intent vers les réglages de l'app. Web : getUserMedia ne peut rien rouvrir — le bouton disparaît et le texte explique où changer l'autorisation dans le navigateur. |
| Écriture | Tutoiement, français de Martinique, phrases courtes. Les libellés exacts de la maquette sont à reprendre tels quels : c'est ce qui empêche les trois surfaces de diverger sur la même erreur. | — |
Un état manquant est un état improvisé, et trois surfaces improvisent de trois manières. Chaque ligne renvoie à l'écran correspondant du sélecteur ci-dessus.
| État | Traitement visuel | Pourquoi ce traitement |
|---|---|---|
Viseur#screen=viseur | Viseur plein cadre | État nominal. La consigne tient en cinq mots et vit sur un voile opaque, jamais sur le flux caméra dont la luminance est inconnue. |
QR illisible#screen=illisible | Pastille ambre dans le viseur | Ce n'est pas un écran : la caméra continue de chercher. Un QR mal cadré ou étranger au produit ne mérite pas d'interrompre le geste — juste de dire pourquoi rien ne se passe. |
Permission à demander#screen=permission | Bloc sombre + CTA primaire | Ce n'est pas une erreur, c'est une demande. Le motif est donné avant le clic (« pour lire l'étiquette du lot ») — le taux d'acceptation en dépend. |
Permission refusée#screen=permission-refus | Bloc sombre + repli promu | Le refus est légitime. On ne culpabilise pas : la saisie manuelle devient le bouton primaire, et « Ouvrir les réglages » passe en second. Voir la ligne « Permission caméra » ci-dessus pour la divergence par plateforme. |
Résultat#screen=resultat | Feuille + fiche | Moment 2. La feuille couvre 88 % de la hauteur au maximum : le viseur reste visible en haut, ce qui dit « tu peux fermer et rescanner ». |
Formulaire#screen=formulaire | Feuille défilée | Moment 3. Catégorie verrouillée en badge b-gray, comme le type de dispatching verrouillé du formulaire actuel — même grammaire, aucun vocabulaire nouveau. |
Hors ligne#screen=hors-ligne | Bandeau ambre + fiche partielle + solde ambre | Le bandeau « Mode hors ligne » est une décision figée du projet : il reste. Le solde passe de b-green à b-amber « Solde estimé » — teinte douce, parce que la valeur se corrigera d'elle-même à la synchro. La fiche n'a plus de libellé (D-04) : la catégorie prend la ligne de titre, et l'absence est nommée sous le poids. Voir §Hors ligne. |
QR inconnu du cache#screen=inconnu | Feuille + état vide neutre | Aucune couleur d'alarme, pas même l'ambre : rien n'est cassé, le référentiel local est seulement en retard. Le repli manuel est le bouton primaire, parce que c'est le seul chemin qui avance. |
Lot indisponible#screen=indisponible | Fiche grisée + aplat ardoise | Le scan a réussi, l'action est refusée. Un seul écran pour toutes les causes de refus — seul le texte de l'alerte change. Aplat parce que la décision est prise ailleurs et que rien n'évoluera seul depuis le terrain ; ardoise et non terracotta parce que ce n'est pas une panne. Voir §Grammaire des badges. |
Envoi en cours#screen=envoi | Bouton « Envoi… » désactivé | Repris à l'identique du submitting() existant. Pas de voile bloquant : l'utilisateur doit pouvoir relire ce qu'il vient de saisir. |
Succès#screen=succes | Feuille fermée + toast | La feuille se referme et le viseur redevient actif : c'est ce qui rend le scan plus rapide que la saisie. Hors ligne, le toast dit « synchro différée » plutôt que « enregistré ». |
Échec#screen=echec | Alerte terracotta dans la feuille | Terracotta ici est justifié : c'est bien une panne. La saisie est conservée, jamais vidée — le bouton reste « Réessayer », pas « Recommencer ». |
La règle transverse du projet : teinte douce = le temps ou un geste ordinaire fera évoluer l'état ; aplat = rien ne bougera sans qu'un humain décide. Second axe : le terracotta est réservé à la panne, l'ardoise à la coupure voulue. Appliquée à cet écran :
| Badge | Poids | Lecture |
|---|---|---|
| Disponible : 8,2 kg | Teinte douce | Le solde bouge tout seul dès qu'un autre dispatching passe. Vert = il reste de quoi saisir. |
| Solde estimé : 8,2 kg | Teinte douce | Hors ligne. Ambre = la valeur se corrigera d'elle-même à la synchro ; ce n'est pas une alerte. |
| Solde épuisé | Teinte douce | Zéro disponible. Reste doux : un retour de stock le rouvre sans intervention. |
| Fiche partielle | Teinte douce | Hors ligne. La fiche se complétera d'elle-même à la synchro — la grammaire du projet appliquée à une donnée manquante, pas à un état. Surtout pas un aplat : rien n'a été décidé par un humain, et surtout pas terracotta — il ne manque rien qui soit cassé. |
| Dispatché Composté Épuisé | Aplat ardoise | Les trois valeurs de StatutLot autres que DISPONIBLE — DISPATCHE, COMPOSTE, EPUISE (api/prisma/schema.prisma:90-95). La décision est prise ailleurs, rien n'évoluera seul, et ce n'est pas une panne — d'où l'ardoise et non le terracotta. Le libellé se peuple avec le statut renvoyé par l'API : aucune surface n'écrit cette liste en dur (N-01). Nouveau jeton --kf-slate, voir §Jetons. |
| verrouillé | Teinte douce | La catégorie vient du lot. Le mot et le style sont ceux du type de dispatching verrouillé déjà en place : rien de neuf à apprendre. |
| Grade A | Teinte douce | Attribut du lot, pas un état. Style existant, repris tel quel. |
⚠️ « Épuisé » et « Solde épuisé » ne sont pas le même objet, et ils ne portent pas le même poids. Épuisé est le statut du lot : aplat, le lot est sorti et ne revient pas. Solde épuisé est le solde de la catégorie : teinte douce, il se rouvre au prochain retour de stock. Deux objets, deux couleurs, deux lectures — ne pas les fondre en un seul composant. Le mot « épuisé » entre sur cet écran avec la correction N-01 ; c'est le seul endroit de la maquette où il désigne deux choses.
| Jeton | Valeur | Statut |
|---|---|---|
--kf-scanner-bg-1 | mix(dark 80 %, white) | Réveillé. Déjà présent dans terrain/src/styles.scss, commenté « bloc mort depuis l'abandon du QR (US-082) ». Rien à ajouter côté terrain : décommenter le commentaire, pas la valeur. |
--kf-scanner-bg-2 | var(--kf-dark) | Réveillé. Idem. |
--kf-slate | #45474a | Nouveau. Aplat des statuts verrouillés. Constant sur les 7 palettes, comme les 3 sémantiques — un aplat teinté changerait de sens d'un thème à l'autre. |
--kf-on-slate | #ffffff | Nouveau. Encre posée sur l'ardoise. 9,32:1. |
Aucune autre couleur n'est introduite. La consigne du viseur est le seul texte posé sur un fond non thémable (rgba(0,0,0,.62)) : c'est délibéré, le flux caméra n'a pas de luminance connue, donc un voile opaque est la seule garantie de lisibilité. Tout le reste vit sur les jetons.
102 paires calculées (WCAG 2.1, seuil AA 4,5:1 pour le texte, 3:1 pour un contour porteur de sens). Quatre échecs, dont trois qui condamnent le dessin d'origine.
| Paire | Mesure | Conséquence |
|---|---|---|
Équerres --kf-primary sur le voile --kf-dark(le dessin de frontend-maquettes.html) |
2,84 · 2,62 · 2,84 Hibiscus · Indigo · Bougainvillier (min. 3:1) |
Écart assumé n°1. La maquette d'avant US-082 posait le cadre en #4a7c4a sur #111 : lisible sur une seule palette, illisible sur trois depuis US-078. Les équerres passent à --kf-light — mesuré 12,03 à 13,58:1 sur les 7 — avec une ombre portée --kf-dark pour tenir aussi sur un flux caméra clair. |
--kf-light sur --kf-dark(équerres et ligne retenues) |
12,03 → 13,58 | Retenu. Marge large sur les 7 palettes. |
--kf-primary-ink sur --kf-bg, sur --kf-neutral-50, sur --kf-primary-soft |
4,66 → 8,71 | Toutes les encres primaires de l'écran passent. Aucun color: ne prend --kf-primary. |
--kf-neutral-600 sur --kf-neutral-50 et sur --kf-bg |
4,84 → 5,06 | AA tout juste atteint. Couvre aussi le panneau « Scans récents » : heure, fin du code et glyphe ↻ sur --kf-neutral-100 mesurent 4,84. Ne pas éclaircir les textes secondaires de cet écran sans reprendre la mesure. |
--kf-warning-ink / --kf-warning-soft--kf-danger-ink / --kf-danger-soft |
5,62 · 5,53 | Bandeau hors ligne, alerte « QR inconnu », alerte d'échec : toutes conformes. |
--kf-on-slate sur --kf-slate, et l'ardoise sur les 7 fonds |
9,32 · 8,46 → 8,64 | Le nouveau badge tient partout, texte comme contour. |
--kf-neutral-border sur les fonds(contour des champs, WCAG 1.4.11) |
3,03 → 3,25 | Écart assumé n°2. Les contours de champ et le bouton de repli prennent --kf-neutral-border, pas --kf-border (≤ 1,43:1, filet décoratif). Le bouton « Saisir manuellement » doit rester perceptible : c'est le seul chemin quand la caméra est refusée. |
--kf-primary-contrast sur --kf-primary(libellé du bouton primaire) |
4,29 — Vétiver 4,52 → 8,54 — les 6 autres |
Défaut préexistant du socle, hors périmètre de cette US. Déjà signalé dans shared/src/theme/palettes.ts (« VETIVER n'atteint AA avec aucune des trois candidates »). Il touche tous les boutons primaires de l'application, pas cet écran. Aucun message de cet écran ne repose sur ce seul contraste : le texte utile est toujours répété hors du bouton. À traiter en avenant US-078, pas ici. |
Réintégré par l'arbitrage PO v2 (QO-06). ^^Tamara l'a retrouvé dans les quatre maquettes d'avant US-082 et l'a chiffré à ~0,1 pt/surface : le retirer coûtait plus cher que le garder. Il revient recadré — les maquettes d'origine y montraient une fiche de lot, un modèle qui n'existe plus.
| Point | Avant US-082 (les 4 maquettes) | Ici |
|---|---|---|
| Ce que montre une entrée | Identifiant + poids du lot + heure, et sur le web une carte « Dernier scan » complète avec DLC et stockage | Catégorie + heure + fin du code. Le poids du lot est une propriété du modèle lot, disparu avec US-082 ; le solde, lui, serait périmé à la seconde. Aucun chiffre. |
| Ce que fait un appui | Chevron › → ouvre l'écran de résultat mémorisé | ↻ → relance la résolution du code, exactement comme si la caméra venait de le lire. L'entrée retient un code, pas un lot. |
| Nombre | 3, plus une carte « Dernier scan » séparée | 3, sans carte séparée. La plus ancienne sort sans un mot. |
| Durée de vie | Non tranchée | La session. Un tableau en mémoire — ni localStorage, ni IndexedDB, ni UserDefaults, ni Room, ni DataStore. Au redémarrage de l'application : liste vide. |
| À zéro entrée | — | La section n'est pas rendue. Pas d'état vide : un état vide est ce qui fait croire à un registre qu'on aurait vidé. |
| Sortie vers un historique | Onglet « Historique scans » dans la barre latérale | Aucune. Pas de « Voir tout », pas d'écran de destination, pas de compteur. |
| Place à l'écran | Sous le viseur | Sous le viseur, après le bouton de repli — qui reste visible sans défiler (point 4). Le panneau est le dernier élément et le seul autorisé à passer sous le pli. Il n'entre jamais dans la feuille : le solde y est prioritaire (point 6). |
| Quand une entrée arrive | — | À la résolution réussie, pas à la validation : on doit pouvoir rappeler un lot ouvert puis refermé. Un code déjà présent remonte en tête au lieu d'être dupliqué. |
Le panneau apparaît sur #screen=viseur, #screen=illisible et #screen=succes. Il est absent de #screen=permission et #screen=permission-refus : sans caméra, la session n'a pu résoudre aucun code — et c'est la même règle, pas une exception.
Décision PO D-04 : le cache embarqué des lots ne porte que id + qrCode + categorieId + poids. Ce n'est pas une contrainte iOS, c'est une contrainte de dessin — elle change ce que l'écran peut dire, sur les trois surfaces.
Le fait. Un libellé de lot s'écrit [Catégorie] — [Partenaire] — T-2026-001 (reception.service.ts:158) : il porte le nom du partenaire. Le cache est écrit en clair sur l'appareil — UserDefaults côté iOS (DispatchReferentialCache.swift:31-33), IndexedDB côté terrain, un fichier SQLite côté Android. Le projet a un audit sécurité ouvert (#94) portant exactement ce constat sur le carnet d'adresses des partenaires. Mettre le carnet d'adresses dans le cache d'un scanner pendant qu'on le retire d'ailleurs n'a pas de sens.
| Champ | En ligne | Hors ligne | Ce que la maquette en fait |
|---|---|---|---|
| Libellé du lot | Affiché | Absent | La ligne de titre est reprise par la catégorie. Elle ne reste jamais vide. |
| Code QR | Affiché | Affiché | Inchangé. C'est ce qu'on dicte au dépôt. |
| Catégorie | Affichée | Affichée | Passe en ligne de titre + pastille. C'est elle qui porte la reconnaissance. |
| Poids du lot | Affiché | Affiché | Inchangé. |
| DLC · Grade · Statut | Affichés | Absents | Retirés. Le statut est implicite : seuls les lots DISPONIBLE entrent dans le cache — l'alerte le dit (« il était disponible à la dernière synchro »). |
| Solde | Disponible | Estimé | Calculé localement, bandeau ambre. Inchangé par D-04. |
Le risque de dessin, et sa réponse. Un lot qu'on ne sait pas nommer ne doit pas avoir l'air d'un lot qu'on n'a pas trouvé. Quatre choses séparent #screen=hors-ligne de #screen=inconnu, et elles se lisent avant le texte :
| Hors ligne — reconnu | QR inconnu — pas trouvé | |
|---|---|---|
| Gabarit | Une fiche, encadrée, teintée primaire | Un état vide centré, icône 🔍, aucune fiche |
| Solde | Bandeau ambre « Solde estimé : 8,2 kg » | Aucun |
| Suite | Le formulaire s'ouvre en dessous, on saisit | Rien à saisir — « Saisir manuellement » devient primaire |
| Premier mot | « Lot reconnu hors ligne » | « Ce lot n'est pas dans l'appareil » |
Et l'absence est nommée, sous le poids : « hors ligne, l'appareil ne garde que la catégorie et le poids — pas le libellé du lot ni le nom du partenaire. Ils reviennent dès la synchro. » Un champ simplement manquant se lit comme une panne ; une absence expliquée se lit comme une limite connue. C'est la seule ligne de texte qui empêche ce cas de remonter en ticket.
Arbitrage : les 3 surfaces s'alignent. Pas de divergence. D-04 est motivée par iOS, mais la raison n'est pas iOS : IndexedDB et un SQLite Room non chiffré sont lisibles exactement comme UserDefaults sur un appareil sauvegardé ou déverrouillé. Trois raisons de dessin s'y ajoutent, et elles suffiraient seules :
① Une maquette, trois surfaces. Deux gabarits de fiche hors ligne, c'est deux jeux d'états à dessiner, à implémenter et à recetter — et une recette qui doit retenir « sur iOS l'absence est normale, sur Android c'est un défaut ». C'est la fabrique à faux bugs.
② Le champ n'a aucun usage ici. Hors ligne, le nom du partenaire ne borne rien, ne choisit rien, ne valide rien. Le code QR, la catégorie et le poids identifient le lot de bout en bout. On retirerait une décoration, pas une information.
③ On peut ajouter un champ plus tard, on ne peut pas le retirer. Aligner aujourd'hui ne coûte rien ; diverger aujourd'hui, c'est devoir un jour enlever à deux surfaces sur trois un champ que les collecteurs auront appris à lire.
⚠️ Le piège d'implémentation, pour les trois lots. Le réflexe est d'écrire dans le cache l'objet reçu de l'API — il porte le libellé, donc le nom du partenaire, et D-04 devient inerte sans que rien ne le signale : l'écran hors ligne aura même l'air plus riche. Le cache se remplit d'une projection explicite à 4 champs, jamais d'un encode(réponse). Le contrôle qui le prouve n'est pas une capture d'écran : c'est lire le magasin — UserDefaults, l'objet IndexedDB, la table Room — et vérifier qu'aucun nom de partenaire n'y figure.
L'onglet « Historique scans » de la barre latérale — présent dans frontend-maquettes.html, disparu avec la refonte à 4 onglets fixes d'US-085. Il ne revient pas, et c'est lui qui garde le panneau « Scans récents » volatile : sans écran de destination, trois lignes en mémoire ne peuvent pas se prendre pour un historique.
L'état « Module non activé » (RG-03, quatrième combinaison) — hors du périmètre arbitré. Il ne concerne pas le scanner mais l'onglet qui le contient, et le traitement existe déjà pour les trois autres clés de fonctionnalité. Les trois surfaces le reprennent tel quel.
Un écran « lot sans catégorie » — correction : le cas existait sur develop (F-02) et D-01 en tarit la source, mais sans réécrire les lignes déjà en base — une base de dev ou une fixture en contient encore. Il n'y a toujours rien de neuf à dessiner, pour une autre raison que celle écrite ici au départ : ce n'est pas un écran de plus, c'est #screen=indisponible avec son repli neutre (voir ses annotations). Il n'existe pas non plus de code LOT_SANS_CATEGORIE : le refus est un 422 LOT_INDISPONIBLE, et le champ du corps qui sépare les causes est un champ de journal — ni dessiné, ni testé côté front.
L'impression d'étiquette — correction : elle n'est pas une US séparée. L'écran est vivant en production (F-12, openQrPopup() dans stock-list.component.ts:297-303). Rien à remettre en surface, rien à ouvrir. Ce qui reste vrai est une consigne d'exploitation, pas un écran : DISPATCHING_QR ne s'active chez un établissement qu'après l'impression de ses étiquettes.