Audit complet de l'app mobile : natif, conformité Apple, ergonomie

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-22 06:55:20 +00:00
parent e6cdc67451
commit b1e7cf6d47

483
docs/AUDIT-2026-09-22.md Normal file
View File

@@ -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à — `<details class="settings-fold">`, 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.*