Files
coach-ios/docs/AUDIT-2026-09-22.md
Sylvain Bettinelli a29569c05c Les push natives ne sont branchées nulle part, et le README affirme l'inverse
Vérifié dans les deux dépôts : web/push_notify.py envoie via ntfy, en se
servant du token du device comme nom de topic. Aucun enregistrement de token
APNs côté serveur (recherche registerToken / /api/devices / device_token :
zéro résultat), et le web n'appelle jamais PushNotifications de Capacitor
(zéro résultat). La moitié native est prête — plugin installé, aps-environment
déclaré, remote-notification dans UIBackgroundModes — et personne ne la
branche. Pour recevoir un rappel, il faut installer l'app ntfy et s'abonner à
un topic.

Deux conséquences. Le README liste « ✅ Push notifications natives » comme
PREMIER argument de sa section anti-rejet Guideline 4.2 : l'argument ne tient
pas si un examinateur teste les rappels, et 4.2 est justement le risque de
cette architecture. Et une capability déclarée mais inutilisée tombe sous
2.3.1(a) (« hidden, dormant, or undocumented features »).

En contrepartie, le risque APNs que l'audit supposait en § 5.1 (token
production envoyé au sandbox, notification jetée silencieusement) est sans
objet : il n'y a pas d'APNs. Un item de moins, un plus sérieux à la place.

La décision est produit avant d'être technique : brancher la chaîne APNs, ou
assumer ntfy et retirer plugin + entitlement + la ligne du README. L'état
actuel est le seul qui cumule les inconvénients des deux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 06:58:17 +00:00

525 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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é] La chaîne de notifications push natives n'existe pas de bout en bout
Trois constats, chacun vérifié par recherche dans les deux dépôts :
1. `web/push_notify.py:47-52` — le canal est **ntfy**. `platform == "web"`
rend `False` (« Web Push : à implémenter (V2) »).
2. **Aucun enregistrement de token APNs côté serveur.** Recherche de
`registerToken` / `/api/devices` / `device_token` dans `web/` : **zéro
résultat**.
3. **Le web n'appelle jamais `PushNotifications`** de Capacitor. Recherche dans
`web/**/*.js` et `web/**/*.html` : **zéro résultat**.
Côté natif, tout est pourtant en place : `@capacitor/push-notifications` est
installé, `aps-environment` est déclaré, `UIBackgroundModes` contient
`remote-notification`. **La moitié native est prête et personne ne la branche.**
Pour recevoir un rappel, l'utilisateur doit installer l'app **ntfy** et
s'abonner à un topic — ce n'est pas une notification de l'app.
Deux conséquences :
- **Le README se trompe.** Il liste « ✅ Push notifications natives (rappel
séances) — implémenté côté iOS » **dans la section anti-rejet Guideline
4.2**. C'est le premier argument de la liste, et il ne tient pas : un
examinateur qui teste les rappels ne verra rien. Or 4.2 est précisément le
risque de cette architecture wrapper (§5.3).
- **Une capability déclarée et inutilisée** est au mieux inerte. Apple 2.3.1(a) :
« Don't include any hidden, dormant, or undocumented features in your app ».
**Décision à prendre**, et elle est produit avant d'être technique : soit
brancher la chaîne APNs (demande de permission + récupération du token côté
web, endpoint d'enregistrement, envoi via APNs côté serveur), soit assumer ntfy
et **retirer le plugin, l'entitlement et la ligne du README**. La situation
actuelle — déclarer une capability qu'on n'utilise pas et l'annoncer comme
implémentée — est la seule qui cumule les inconvénients des deux.
### 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.
- **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. **Trancher la question des push (§5.1bis)** — brancher APNs, ou assumer ntfy
et retirer plugin + entitlement + la ligne du README. C'est une décision
produit, et elle conditionne l'argument anti-4.2. 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.*