US-099 · Dispatching par QR code
^^Juliette — viseur + résultat + formulaire · terrain / iOS / Android
État
Palette

Ce que la maquette fixe, et ce qu'elle laisse libre

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.

PointImposé aux 3 surfacesLibre 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-darkpas --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.

Les 12 états, et pourquoi chacun existe

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.

ÉtatTraitement visuelPourquoi 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 viseurCe 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 primaireCe 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 promuLe 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 + ficheMoment 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éeMoment 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 ambreLe 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 neutreAucune 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 ardoiseLe 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 + toastLa 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 feuilleTerracotta ici est justifié : c'est bien une panne. La saisie est conservée, jamais vidée — le bouton reste « Réessayer », pas « Recommencer ».

Grammaire des badges — ce que dit chaque poids

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 :

BadgePoidsLecture
Disponible : 8,2 kgTeinte douceLe solde bouge tout seul dès qu'un autre dispatching passe. Vert = il reste de quoi saisir.
Solde estimé : 8,2 kgTeinte douceHors ligne. Ambre = la valeur se corrigera d'elle-même à la synchro ; ce n'est pas une alerte.
Solde épuiséTeinte douceZéro disponible. Reste doux : un retour de stock le rouvre sans intervention.
Fiche partielleTeinte douceHors 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 ardoiseLes trois valeurs de StatutLot autres que DISPONIBLEDISPATCHE, 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 douceLa 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 ATeinte douceAttribut 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.

Jetons de couleur — 2 réveillés, 2 nouveaux, 0 en dur

JetonValeurStatut
--kf-scanner-bg-1mix(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-2var(--kf-dark)Réveillé. Idem.
--kf-slate#45474aNouveau. 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#ffffffNouveau. 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.

Contraste — mesuré sur les 7 palettes, pas supposé

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.

PaireMesureConsé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.

Scans récents — ce qui revient, et ce qui ne revient surtout pas

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.

PointAvant US-082 (les 4 maquettes)Ici
Ce que montre une entréeIdentifiant + poids du lot + heure, et sur le web une carte « Dernier scan » complète avec DLC et stockageCaté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 appuiChevron → 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.
Nombre3, plus une carte « Dernier scan » séparée3, sans carte séparée. La plus ancienne sort sans un mot.
Durée de vieNon tranchéeLa 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éeLa 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 historiqueOnglet « Historique scans » dans la barre latéraleAucune. Pas de « Voir tout », pas d'écran de destination, pas de compteur.
Place à l'écranSous le viseurSous 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.

Hors ligne — le jeu de champs réduit, et pourquoi il vaut pour les 3 surfaces

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.

ChampEn ligneHors ligneCe que la maquette en fait
Libellé du lotAffichéAbsentLa ligne de titre est reprise par la catégorie. Elle ne reste jamais vide.
Code QRAffichéAffichéInchangé. C'est ce qu'on dicte au dépôt.
CatégorieAffichéeAffichéePasse en ligne de titre + pastille. C'est elle qui porte la reconnaissance.
Poids du lotAffichéAffichéInchangé.
DLC · Grade · StatutAffichésAbsentsRetirés. Le statut est implicite : seuls les lots DISPONIBLE entrent dans le cache — l'alerte le dit (« il était disponible à la dernière synchro »).
SoldeDisponibleEstimé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 — reconnuQR inconnu — pas trouvé
GabaritUne fiche, encadrée, teintée primaireUn état vide centré, icône 🔍, aucune fiche
SoldeBandeau ambre « Solde estimé : 8,2 kg »Aucun
SuiteLe formulaire s'ouvre en dessous, on saisitRien à 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 magasinUserDefaults, l'objet IndexedDB, la table Room — et vérifier qu'aucun nom de partenaire n'y figure.

Ce qui n'est pas dans cette maquette

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'étiquettecorrection : 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.