From b1e7cf6d47c34f64474a6a395196c11a0a6596d7 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 22 Sep 2026 06:55:20 +0000 Subject: [PATCH] =?UTF-8?q?Audit=20complet=20de=20l'app=20mobile=20:=20nat?= =?UTF-8?q?if,=20conformit=C3=A9=20Apple,=20ergonomie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois axes, chaque constat marqué [vérifié] / [rapporté] / [doc] — la distinction n'est pas cosmétique : un constat non revérifié relayé comme un fait est la dérive que la règle 3 interdit. Le plus grave (§1.1, vérifié ligne à ligne) : WorkoutManager.swift:199 garde la dernière FC connue quand le capteur décroche, et :218 la réémet avec un horodatage NEUF. Le watchdog de LiveStore ne peut donc jamais basculer en .lost — il reçoit des samples frais. Au poignet, une FC morte s'affiche stable, avec sa zone colorée, indéfiniment. Pour un post-infarctus sous bêtabloquants qui lit ce chiffre pour décider d'une pente, ce n'est pas un bug d'affichage. Quatre affirmations d'audits antérieurs corrigées, toutes vérifiées : - la politique de confidentialité EXISTE (HTTP 200) — le TODO du README est périmé, et « missing privacy policy » circulait encore ; - la suppression de compte EXISTE (5.1.1(v), motif de rejet fréquent) ; - 88 tests Swift verts, pas 57 ; - npm audit réellement propre, et aucun secret jamais commité (git log vide). aps-environment = development n'est pas le problème qu'on croyait : Apple documente que la valeur vient du provisioning profile et que TestFlight utilise production. Le vrai risque est côté backend, sur l'endpoint APNs. Côté produit : le mot « Démarrer » n'existe nulle part pour une séance sportive, et widgets/today-session.html:6 documente un CTA que le markup ne contient pas. L'app ne permet pas de démarrer une séance. Ce qui n'a pas été audité est dit en § 7 — aucun build Xcode possible ici, CoachPolarBLE non revu, backend non audité au-delà de l'ergonomie. Co-Authored-By: Claude Opus 5 (1M context) --- docs/AUDIT-2026-09-22.md | 483 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 docs/AUDIT-2026-09-22.md diff --git a/docs/AUDIT-2026-09-22.md b/docs/AUDIT-2026-09-22.md new file mode 100644 index 0000000..4ab0390 --- /dev/null +++ b/docs/AUDIT-2026-09-22.md @@ -0,0 +1,483 @@ +# Audit complet — app mobile Coach Hypnotruck + +**Date** : 2026-09-22 · **Portée** : `coach-ios` (natif iOS/watchOS) + `coach_sportif/web` +(l'UI réelle, servie dans la WKWebView) · **Branche auditée** : `feat/watch-outdoor` @ `e6cdc67` + +## Comment lire ce document + +Chaque constat porte son statut de vérification. La distinction n'est pas +cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un +fait est exactement la dérive que la règle 3 interdit. + +| Marque | Signification | +|---|---| +| **[vérifié]** | Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit | +| **[rapporté]** | Remonté par une revue automatisée, **non revérifié** — à confirmer avant d'agir | +| **[doc]** | Adossé à une source Apple citée verbatim, URL donnée | + +⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un +état. `git log` fait foi pour ce qui a bougé depuis. + +--- + +## 1. Ce qui va bien, et qu'il ne faut pas casser + +Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine. + +- **[vérifié] Aucun secret n'a jamais été commité.** `git log --all` sur + `coach-ios.local.json` et `ios/App/App/CoachAuth.swift` est vide. Le + `.gitignore` les couvre (lignes 23-24). +- **[vérifié] `npm audit` : 0 vulnérabilité.** Capacitor 8.3.1 homogène sur + tous les paquets. +- **[vérifié] 88 tests Swift verts sur Linux** (10 DayRollover + 7 FormeBand + + 17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le + chiffre de 57 qui circulait est faux. +- **[vérifié] La politique de confidentialité existe** — `HTTP 200` sur + `/privacy`, liée depuis `settings.html:796` et `landing.html:207`. Le TODO du + README (« à héberger ») est **périmé**, de même que la ligne « missing + privacy policy » de tout audit antérieur. +- **[vérifié] La suppression de compte existe** (`settings.html:797` → + `POST /account/delete`, `routers/auth.py:313`). C'est l'exigence 5.1.1(v), + motif de rejet fréquent : elle est déjà satisfaite. +- **[vérifié] Universal Links opérationnels** — AASA servi en + `application/json`, Team ID `TZ2PVTRKNY`, routes `/live` `/routine` `/forme` + `/calendar`. +- **[vérifié] Purpose strings HealthKit précises et en français**, nommant les + types de données et leur finalité. Conforme à la lettre de 5.1.1(ii). +- **[rapporté] `CoachWorkoutKit.swift` est le fichier le plus sain du chemin + santé** : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran, + relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé. +- **[rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles** dans le + code Swift. Aucun `@MainActor` manquant, aucun cycle de rétention. +- **[vérifié] La barre d'onglets web est le seul composant d'UI protégé par un + test** (`tests/test_bottom_tabs.py`) — et c'est le seul qui n'a pas dérivé. + Le lien de cause à effet mérite d'être noté. + +--- + +## 2. Gravité 1 — sécurité des données de santé + +### 1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire + +`ios/App/CoachWatch/WorkoutManager.swift:199` et `:217-224` + +```swift +if let hr { heartRate = hr } // 199 — hr nil → l'ancienne valeur reste +... +ConnectivityManager.shared.sendSample([ + "heartRate": heartRate, // 218 — la propriété stockée, PAS `hr` + ... + "ts": now.timeIntervalSince1970 // 223 — horodatage NEUF +]) +``` + +`didCollectDataOf` se déclenche dès qu'*un* type est collecté. Quand le capteur +optique décroche — bracelet desserré, froid, arythmie : **la FA thermoablée est +précisément le terrain à risque** — les calories continuent d'arriver, `hr` vaut +`nil`, et la ligne 218 réémet la dernière FC connue avec un horodatage frais. + +Conséquence en trois étages : +1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment. +2. Sur l'iPhone, `LiveStore.ingest` pose `lastSampleAt = Date()` et + `status = .live`. Le watchdog **ne basculera jamais en `.lost`** : il reçoit + des samples frais. +3. Aucun log, aucun compteur. + +**C'est le défaut le plus grave de l'audit.** Un utilisateur post-infarctus sous +bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC +réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information +absente du modèle. `heartRate` a besoin d'un `heartRateAt: Date` posé uniquement +quand `hr != nil`, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle +sait déjà le faire, `LiveStore.heartRateText:152`), et `sendSample` d'un champ +d'âge pour que l'iPhone applique la même règle. + +### 1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre + +`ios/App/App/CoachWidgetBridge.swift:183` — `try? session.updateApplicationContext(...)` + +`try?` avale l'échec (payload trop gros, session non activée, montre non +appairée). Ce snapshot transporte les bornes de zones affichées pendant +l'effort. Deux fichiers plus loin, `CoachRoutineBridge.pushToWatch:92-101` +appelle **la même API** avec `do/catch`, log et `call.reject`. + +Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est +celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant : +`HeartRateZones` ne porte aucune date de validité, donc après une révision des +bornes la montre peut afficher les anciennes indéfiniment. C'est le motif +« zones datées » déjà tranché côté serveur (`app.current_zones()`), non +répercuté au poignet. + +### 1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques + +`ios/App/App/CoachSleep.swift:92-96` — l'en-tête du fichier (ligne 30) promet +`available = false` si non autorisé ; le code ne teste jamais l'autorisation et +rend `{available: true, samples: []}`. + +Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre +des nuits vides et `sleep_score.py` calcule une régularité sur des trous. Rien +ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ». +HealthKit ne distingue effectivement pas refusé de vide en lecture, mais +`authorizationStatus(for:)` couvre `.notDetermined`, qui est le cas fréquent +(réinstallation, permission jamais accordée). Même famille : +`CoachHealthRoute.swift:173` et `:277`. + +--- + +## 3. Gravité 2 — perte silencieuse de données + +Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de +séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était +vrai de son point de vue. Les trois défauts ci-dessous ont la même forme. + +### 2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement + +`ios/App/App/CoachWorkoutObserver.swift:297-319`. L'ancre est sauvegardée +(l. 298) **avant** que seul `latest` (l. 315) soit traité. Or +`scheduleNotification` est le seul appelant de `reportToRoutineIfStrength` et +`reportPlanOrigin`. Renfo le matin + vélo le soir, iPhone resté à la maison : +au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre +ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying +latest » — exact, et parfaitement muet sur ce qui vient d'être jeté. + +**Correction** : boucler sur `workouts` pour les rapports serveur ; garder +`latest` uniquement pour la notification locale. + +### 2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable + +Même fichier, `:266-268`, `:352-382`, `:398-438`. Le `defer { completion() }` +s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur +`URLSession.shared`, sans `beginBackgroundTask`. iOS est libre de suspendre. + +**Aggravant, indépendamment vérifiable** : le statut HTTP n'est jamais lu +(l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas +d'`error` — et `plan-origin envoyé` est journalisé comme un succès. Le bon +pattern existe déjà à deux fichiers de là (`CoachRoutineBridge.postDay:154-159` +vérifie `(200..<300).contains(http.statusCode)`). + +### 2.3 [rapporté] Une séance entière peut disparaître sans un seul log + +`ios/App/CoachWatch/WorkoutManager.swift:310-324`. `try? await +builder.endCollection(...)` puis `try? await builder.finishWorkout()` : si le +premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin +finit sur `discardRoute()` qui logue la perte de la *trace* mais pas de la +*séance*. `statusMessage` n'est pas touché : l'utilisateur voit l'écran de fin +normal après 2 h de sortie. + +### 2.4 [rapporté] `didFailWithError` ne clôt rien et laisse le GPS allumé + +Même fichier, `:377-389`. Comparé au chemin `.ended`, il manque +`stopTracking()`, `endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`. +Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le +GPS continue de tourner après la fin apparente de la séance. + +### 2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace *antérieurs* + +`ios/App/CoachWatch/LocationTracker.swift:147` — `pendingRoute.removeAll()`. + +Le commentaire vise les points *postérieurs* à la pause. Mais `pendingRoute` ne +contient que des points **déjà acceptés par le filtre et déjà comptés dans la +distance affichée**, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est +une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la +distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE → +jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché. + +**Correction** : `await flushPendingRoute()` avant de vider, ou ne pas vider — +le point de reprise est de toute façon filtré par `implausibleSpeed`. + +### 2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS + +`ios/App/App/CoachHealthRoute.swift:182-203` : `all` et `pluginErr` sont mutés +depuis les callbacks de N requêtes livrées sur des files HealthKit +indépendantes, sans verrou — alors que `getEffortScores:282` et +`getWorkoutDetails:379` du **même fichier** utilisent un `NSLock` pour ce motif +exact. Déclencheur : un workout portant plusieurs `HKWorkoutRoute` (multisport, +reprise après interruption). + +--- + +## 4. Gravité 3 — justesse et cohérence + +| # | Constat | Emplacement | +|---|---|---| +| 3.1 | **[rapporté]** `humidity_pct` vaut 0 ou 1, jamais 65 — `HKUnit.percent()` est une fraction (1.0 = 100 %), le `× 100` manque | `CoachHealthRoute.swift:415-417` | +| 3.2 | **[rapporté]** Une séance `traditionalStrengthTraining` est POSTée en `functionalStrengthTraining` (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer | `CoachWorkoutObserver.swift:352-364` | +| 3.3 | **[rapporté]** `value > 0` confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée | `CoachHealthRoute.swift:449-462` | +| 3.4 | **[rapporté]** `startObserving` répond `observing: true` même sur refus (le `success` est ignoré, et Apple le met à `true` dès l'affichage de la feuille). `getStatus` montre trois voyants verts et zéro donnée | `CoachWorkoutObserver.swift:218-228` | +| 3.5 | **[rapporté]** La garde anti-doublon `lastNotifiedKey` est écrite (l. 317) et **jamais lue**. Le commentaire affirme une protection absente | `CoachWorkoutObserver.swift:60-63` | +| 3.6 | **[rapporté]** Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le *nom* du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée | `AppDelegate.swift:31-34`, `MainViewController.swift:63-65` | +| 3.7 | **[vérifié]** `STYLE_GUIDE.md:233` annonce `--text-secondary` à 9,7:1 ; le calcul donne **6,36:1** sur noir, 5,95:1 sur `.ap-card` | `docs/STYLE_GUIDE.md` | + +Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la +règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère +de remplacement, pas le mécanisme. + +--- + +## 5. Conformité App Store — sources Apple + +Méthode : `developer.apple.com` uniquement, verbatim + URL. La distinction +exige / recommande / **ne dit pas** est le cœur de cette section. + +### 5.1 [doc] `aps-environment = development` — non-problème, sous réserve d'une vérification + +`App.entitlements` porte `development`. La +[doc Apple](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment) +dit verbatim : + +> « **Xcode sets the value of the entitlement based on your app's current +> provisioning profile.** […] Production provisioning profile and [prerelease +> versions and beta testers] use `production`. » + +TestFlight utilise donc `production`, et [TN2265](https://developer.apple.com/library/archive/technotes/tn2265/_index.html) +ajoute que Xcode **re-signe** à la soumission avec le profil de distribution. + +**Ce que la doc ne dit pas** : si Xcode réécrit le *fichier source* +`.entitlements`. N'éditez pas le fichier sur la foi d'un blog. Apple publie une +procédure de vérification, et c'est la seule qui conclut : + +> Organizer → archive → Distribute/Validate → feuille Summary → dépliant +> **Binary and Entitlements** → lire la valeur réelle dans le binaire signé. + +**Le vrai risque est ailleurs, et il est côté backend** : un token production +envoyé vers `api.sandbox.push.apple.com` (ou l'inverse) est traité comme +invalide et **la notification est jetée silencieusement** (TN2265). À vérifier +dans `web/push_notify.py`. + +### 5.2 [doc] Ce qu'Apple exige, et où on en est + +| Exigence | Source | État | +|---|---|---| +| Privacy policy URL dans App Store Connect | 5.1.1(i) + ASC Help, « required for all apps » | à renseigner à la soumission | +| Privacy policy accessible **dans** l'app | 5.1.1(i), « in an easily accessible manner » | **[vérifié] fait** — `settings.html:796` | +| Privacy policy pour toute app HealthKit | [doc HealthKit](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) | **[vérifié] fait** | +| Contenu : collecte + usages + tiers + **rétention/suppression et révocation** | 5.1.1(i) | à relire — c'est le point que les policies génériques oublient | +| Suppression de compte dans l'app | 5.1.1(v) | **[vérifié] fait** — `POST /account/delete` | +| Purpose strings précises | 5.1.1(ii) + doc HealthKit | **[vérifié] fait** | +| Nutrition labels renseignés | « required to submit » | **à faire** : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte) | +| Aucune donnée HealthKit en pub/marketing | 5.1.3(i), 5.1.2(vi), 2.5.18 | conforme | +| Aucune donnée de santé dans iCloud | 5.1.3(ii) | **[vérifié]** aucun entitlement iCloud | +| Notes for Review spécifiques + compte de démo **avec backend allumé** | 2.3.1(a), 2.1(a) | à préparer | + +⚠️ **Piège propre à l'architecture webview**, [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) : + +> « **If you are using a webview for app functionality, it should be treated the +> same way as native functionality in your app** » + +Tout ce que fait `coach.hypnotruck.ch` dans la WKWebView compte comme du natif +pour les nutrition labels **et** pour l'ATT. Si le site embarque le moindre +analytics tiers, il doit être déclaré. + +### 5.3 [doc] Ce que la doc Apple **ne tranche pas** — à connaître, pas à corriger + +- **Guideline 4.2 n'a aucun critère mesurable.** « Useful, unique, app-like » + n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets + + Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) : + **décrire nommément les fonctions natives dans les Notes for Review**. +- **2.5.2** (« may not download […] code which introduces or changes features ») + n'a pas d'exception publiée pour une UI web distante chargée via `server.url`. + Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il + est structurel, pas corrigeable. +- **« Third party »** n'est pas défini : la doc ne dit pas si le backend du + développeur en est un. Aucune exigence technique publiée sur le stockage + (chiffrement, juridiction, rétention). +- **`limitsNavigationsToAppBoundDomains: false`** : la page Apple ne décrit + **aucune** conséquence, et `WKAppBoundDomains` est absente de la référence + Info.plist. Aucun lien publié entre app-bound domains et données de santé. + La config actuelle est cohérente (pas de `WKAppBoundDomains` + flag `false` = + comportement pré-iOS 14, sans restriction), et passer à `true` casserait + `evaluateJavaScript` et `messageHandlers`, dont Capacitor a besoin. + **Ne pas y toucher pour des raisons de conformité.** +- **Aucune clé Info.plist de privacy policy n'existe.** Si une source en + réclame une, c'est inventé. + +### 5.4 [doc] Signal de positionnement + +5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un +« should » (pas « must »). Mais 1.4.1 prévient : + +> « **Medical apps that could provide inaccurate data or information […] may be +> reviewed with greater scrutiny.** » + +Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de +scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne +déclenchent pas la même lecture. + +--- + +## 6. Ergonomie — l'app est-elle utilisable **par un sportif** ? + +C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel +d'architecture — l'UI réelle vit dans `coach_sportif/web/`, le wrapper ne fait +que l'afficher. Les constats ci-dessous portent donc sur le web. + +### 6.1 [vérifié] Le geste central du sportif n'existe pas + +Le mot « Démarrer » n'apparaît que **deux fois** dans tout `web/templates/`, et +les deux fois pour la routine anti-douleurs (`routine.html:27,42`). La troisième +occurrence est un commentaire qui décrit une fonctionnalité absente : +`widgets/today-session.html:6` annonce un CTA « Démarrer » / « Voir détails » +que le markup ne contient pas. + +| Action | Taps | +|---|---| +| Voir la séance du jour | 0 (mais ~420 px sous le haut de page) | +| Voir ses étapes et sa zone cardiaque cible | 2 | +| Envoyer la séance à la Watch | 3 + scroll dans la modale | +| **Démarrer la séance** | **impossible** — `/live` est passif, et hors onglets par défaut | + +Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien +sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central. + +### 6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible + +17 règles CSS descendent à ≤ 0,62 rem, la plupart en `--text-tertiary` : + +| Sélecteur | Taille | Ce que ça porte | +|---|---|---| +| `.ap-tile__pill` (`style.css:7053`) | **0,5 rem = 8 px** | **l'intensité de la séance**, « ADAPTÉ IA », « FAIT ✓ » | +| `.ap-tile__meta` (`:7068`) | 0,62 rem ≈ 9,9 px | durée de la séance, km, FC | +| `.entry-src`, `.entry-ecart` | 0,58 rem ≈ 9,3 px | provenance / écart d'une mesure | + +**L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px, +en gris, sur noir.** Au total, 127 règles combinent `--text-tertiary` avec une +police < 0,78 rem. + +**Ratios réels [vérifié par calcul]**, fonction validée sur le témoin WCAG +officiel (`#767676`/blanc = 4,54) : + +| Token | sur `#000` | sur `.ap-card` | sur `--bg-tertiary` | +|---|---|---|---| +| `--text-secondary` | **6,36** (le guide annonce 9,7 — faux) | 5,95 | 5,27 | +| `--text-tertiary` | 5,42 | 5,22 | **4,68** (limite AA) | + +Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le +croit. Et le problème n'est pas le ratio : c'est la taille. **Aucune +échappatoire** : pas de mode clair (`_layout.html:20` force +`background:#000 !important`, et les tokens clairs de `critical.css:153-166` +sont du code mort), pas de Dynamic Type (`-webkit-text-size-adjust: 100%` + +`rem` sur racine fixe), aucun réglage de taille dans `/settings`. + +Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande +surface — qui est noyé par la lumière réfléchie. + +### 6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes + +Les composants génériques respectent le 44×44 du style guide (`.ap-icon-btn`, +`.tab-item`, `.more-item`, `.coach-fab`). Mais : + +| Élément | Taille | Rôle | +|---|---|---| +| `.qr-action` | ~**29 × 35** | ajout rapide d'un favori — le geste le plus fréquent | +| `.hero-tab` | ~**23 px** | sélecteur primaire de `/meals` | +| `.ap-home-hero__cta-mini` | **32 × 32** | **le CTA du hero d'accueil** | +| `.meal-section__add-btn` | **34 × 34** | porte d'entrée de la saisie de repas | +| `.ap-icon-btn.--sm` | **36 × 36** | navigation jour de `/meals` — usage **primaire**, ce que §2 du guide interdit | + +Saisir un repas connu = 3 taps, **dont 2 sous le seuil**. Pire : dans +`.quick-row`, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents, +même taille ~29 px : avec des doigts moites ou des gants, on met en favori au +lieu d'ajouter, **sans retour d'erreur** puisque les deux « marchent ». + +Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose +« un garde-fou contre la récidive → un test, pas une phrase ». Les règles +d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive. + +### 6.4 [rapporté] Densité et navigation + +- **Accueil : 28 chiffres, dont un seul concerne la séance à faire** (sa durée, + en 9,9 px gris). Les 27 autres sont du constat sur le passé. +- **`/workout` : 20 tuiles toutes visibles par défaut**, 100-130 valeurs avec + les tableaux. `/sleep` : 8 tuiles. +- **19 sections de navigation, 4 visibles**, avec des doublons sémantiques + réels : `/mesures` + `/body` + `/profile` pour le corps ; `/calendar` + + `/week` + `/dashboards` + `/reviews` pour la planification. +- **La place la plus accessible au pouce** — le FAB 56 px, fixe, sur toutes les + pages — **est occupée par le chat IA**, pas par « logger » ou « démarrer ». + +### 6.5 Le meilleur travail d'ergonomie de l'app est éteint + +`drinks-quick.html` et `meal-capture.html` ont des boutons `min-height: 44px`, +un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note +vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »). +**Ils ne sont ni dans le layout par défaut, ni en production.** Les allumer dans +`_default_home_layout()` est un gain immédiat pour un coût nul. + +### 6.6 La tension honnêteté / ergonomie, à trancher + +Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à +interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est +suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la +fonde. + +Ce disclaimer est **juste** : c'est la règle 3ter, et sur une app de santé il +n'est pas négociable. Mais son coût est payé au pire endroit. La question de +conception est : *comment rester honnête sans occuper le premier écran ?* Le +composant existe déjà — `
`, dont §3c du guide +décrit les deux règles. + +--- + +## 7. Ce qui n'a pas été audité + +Par honnêteté sur la couverture : + +- **Aucun build Xcode** — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit + et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la + signature ou au comportement sur device n'a été vérifié autrement que par + lecture. +- **`CoachPolarBLE.swift`** (644 L, 86 commits de diagnostic entre le 11/08 et + le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt. +- **Android** : seul le manifest a été lu. Health Connect n'est pas intégré, et + la validation Play Console reste en attente. +- **Le backend** (`coach_sportif/web`) n'a été audité que sur l'ergonomie et + deux points de conformité. Ni sécurité serveur, ni justesse des formules. +- **`web/push_notify.py`** n'a pas été lu — c'est pourtant là que se joue le + risque APNs de §5.1. + +--- + +## 8. Ordre de traitement proposé + +**D'abord, parce que la conséquence est une décision d'intensité prise sur un +chiffre mort :** + +1. **§1.1 — horodater la FC** et afficher « — » au-delà d'un seuil d'âge, des + deux côtés du pont. Correctif petit et local. +2. **§6.2 — remonter le plancher typographique** : interdire < 0,7 rem, bannir + `--text-tertiary` sous 0,78 rem. En priorité `.ap-tile__pill` (l'intensité de + séance en 8 px). + +**Ensuite, la perte de données :** + +3. **§2.1 + §2.2 ensemble** — même scénario, même correctif structurel : boucler + sur le lot, tenir un `beginBackgroundTask`, lire le statut HTTP comme + `CoachRoutineBridge` le fait déjà. +4. **§2.3 + §2.4** — deux faces de la même omission dans le cycle de vie de la + séance watchOS. +5. **§2.5** — une ligne. + +**Puis le produit :** + +6. **§6.1 — un bouton « Démarrer »** ≥ 44 px sur la tuile du jour, avec la zone + cible lisible à côté. +7. **§6.5 — allumer `drinks-quick` et `meal-capture`.** Coût nul. +8. **§6.3 — porter les cibles primaires à 44 px**, et écarter l'étoile du `+`. + +**Avant soumission :** + +9. Vérifier `aps-environment` dans le Summary de l'archive **et** l'endpoint + APNs du backend (§5.1). +10. Nutrition labels, Notes for Review nommant les fonctions natives, compte de + démo avec backend allumé (§5.2). + +**Garde-fous, parce que « un test, pas une phrase » :** + +11. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des + tokens. Ce sont les seules règles du projet non verrouillées, et ce sont + celles qui ont dérivé. + +--- + +*Audit mené par lecture du code et des sources Apple. Les constats **[rapporté]** +proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne : +les confirmer avant d'agir.*