# 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 risque APNs supposé est sans objet [vérifié le 2026-09-22]** : il n'y a pas d'APNs. `web/push_notify.py` envoie via **ntfy**, en utilisant le `token` du device comme *nom de topic*. L'en-tête du fichier annonce « V2+ : remplacer par APNs (iOS) + FCM (Android) » — ce n'est pas fait. Voir §5.1bis, qui est le vrai sujet. ### 5.1bis [vérifié] Les rappels sont natifs et fonctionnent ; c'est le chemin ntfy qui est résiduel ⚠️ **Correction d'une première version de cette section, qui affirmait que « la chaîne de notifications natives n'existe pas ». C'était faux**, et la méthode qui a produit l'erreur mérite d'être notée : j'avais cherché `PushNotifications` (le plugin *distant*) sans chercher `LocalNotifications`. Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir cherché le bon terme. **Ce qui marche, en natif** — `web/static/local-notifications.js` programme via `Capacitor.Plugins.LocalNotifications` : - rappel « Séance pas encore faite » (l. 97) ; - résumé du jour : sport + durée, « Jour OFF », « Peu récupéré » (l. 150-171) ; - hydratation à 10 h / 13 h / 16 h (l. 184) ; - « Heure de te coucher ? » à 23 h (l. 197). Ce sont de vraies notifications locales iOS, planifiées sur l'appareil, sans serveur. **Le README a donc raison sur le fond** (les rappels de séance sont natifs), mais il attribue la fonction au mauvais plugin : c'est `@capacitor/local-notifications` qui la rend, pas `@capacitor/push-notifications`. **Ce qui reste du chemin ntfy** — `web/push_notify.py` envoie via ntfy (le `token` du device sert de *nom de topic*), et il a deux appelants vivants : | Appelant | Ce qu'il expose | |---|---| | `web/routers/api_notifications.py:16` | `POST /register-device`, `GET /devices`, `DELETE /devices/{id}`, `POST /test` | | `tools/notify_morning.py:24` | notification matinale, déclenchée par cron | L'intention est que **plus rien ne passe par ntfy** (confirmé par Sylvain le 2026-09-22). Ce chemin est donc du code résiduel qui double une fonction déjà assurée en natif. ⚠️ **Ne pas supprimer avant d'avoir vérifié le cron sur nexus.** Si `notify_morning.py` y est encore planifié, retirer le module casserait le cron — et un cron cassé dans ce projet a déjà tenu quatre mois sans être vu. La vérification n'a **pas** pu être faite depuis le conteneur de dev (pas de résolution DNS pour `nexus`) : ``` ssh nexus "crontab -l | grep -i notify; systemctl list-timers --all | grep -i notify" ``` **Ce qui reste vrai de la remarque App Store** : `@capacitor/push-notifications` est installé et `aps-environment` déclaré, alors que **rien n'appelle ce plugin** (aucun enregistrement de token APNs côté serveur, aucun appel `PushNotifications` côté web). Une capability déclarée sans usage tombe sous 2.3.1(a) (« hidden, dormant, or undocumented features »). Les rappels étant couverts par les notifications locales, le plus simple est de **retirer le plugin distant et l'entitlement** — sauf si des push serveur sont prévues. ### 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. - **Le contenu de la politique de confidentialité** n'a pas été relu contre les trois exigences de 5.1.1(i) — son existence a été vérifiée, pas son contenu. *(`web/push_notify.py` a été lu après coup : voir §5.1bis, qui remplace le risque APNs initialement supposé.)* --- ## 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. **Nettoyer le résiduel de notifications (§5.1bis)** — vérifier le cron `notify_morning` sur nexus, puis retirer le chemin ntfy et le plugin `@capacitor/push-notifications` + son entitlement, que rien n'appelle. Corriger la ligne du README qui attribue les rappels au mauvais plugin. Puis vérifier `aps-environment` dans le Summary de l'archive (§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.*