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>
28 KiB
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 --allsurcoach-ios.local.jsonetios/App/App/CoachAuth.swiftest vide. Le.gitignoreles 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 200sur/privacy, liée depuissettings.html:796etlanding.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 IDTZ2PVTRKNY, 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.swiftest 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
@MainActormanquant, 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
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 :
- Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
- Sur l'iPhone,
LiveStore.ingestposelastSampleAt = Date()etstatus = .live. Le watchdog ne basculera jamais en.lost: il reçoit des samples frais. - 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
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
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 :
web/push_notify.py:47-52— le canal est ntfy.platform == "web"rendFalse(« Web Push : à implémenter (V2) »).- Aucun enregistrement de token APNs côté serveur. Recherche de
registerToken//api/devices/device_tokendansweb/: zéro résultat. - Le web n'appelle jamais
PushNotificationsde Capacitor. Recherche dansweb/**/*.jsetweb/**/*.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 | [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 :
« 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, etWKAppBoundDomainsest 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 deWKAppBoundDomains+ flagfalse= comportement pré-iOS 14, sans restriction), et passer àtruecasseraitevaluateJavaScriptetmessageHandlers, 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+/profilepour le corps ;/calendar+/week+/dashboards+/reviewspour 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 — horodater la FC et afficher « — » au-delà d'un seuil d'âge, des deux côtés du pont. Correctif petit et local.
- §6.2 — remonter le plancher typographique : interdire < 0,7 rem, bannir
--text-tertiarysous 0,78 rem. En priorité.ap-tile__pill(l'intensité de séance en 8 px).
Ensuite, la perte de données :
- §2.1 + §2.2 ensemble — même scénario, même correctif structurel : boucler
sur le lot, tenir un
beginBackgroundTask, lire le statut HTTP commeCoachRoutineBridgele fait déjà. - §2.3 + §2.4 — deux faces de la même omission dans le cycle de vie de la séance watchOS.
- §2.5 — une ligne.
Puis le produit :
- §6.1 — un bouton « Démarrer » ≥ 44 px sur la tuile du jour, avec la zone cible lisible à côté.
- §6.5 — allumer
drinks-quicketmeal-capture. Coût nul. - §6.3 — porter les cibles primaires à 44 px, et écarter l'étoile du
+.
Avant soumission :
- 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-environmentdans le Summary de l'archive (§5.1). - 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 » :
- 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.