Parité de lecture Topic ↔ MP

La matrice qui empêche la surface de lecture des MP/DT de décrocher silencieusement de celle du Topic.


Pourquoi cette page

Entre la migration c3 de #351 (2026-06-20) et le cadrage de #1040 (2026-08-12), 74 commits ont touché feature/topic contre 7 feature/messages — sans qu’aucun document ne trace l’écart : chaque fonction de lecture était conçue, livrée et testée côté topic, et son absence côté MP n’était écrite nulle part. Cette page est la réponse structurelle : pour chaque fonction de la surface de lecture, elle dit si la fonction s’applique aux MP/DT, et dans quel état elle s’y trouve.

Règle d’entretien [enforced] : toute PR qui ajoute ou modifie une fonction de la surface de lecture (rendu d’un Post, gestes de page, préférences de lecture) ajoute ou met à jour sa ligne ici. Une fonction absente de cette matrice est un écart non tracé — précisément ce que cette page existe pour empêcher. Deux gardes machine portent la règle depuis #1045 :

  • garde A (chemins) — job repo-guards de la CI (scripts/check-reading-parity-touch.sh) : une PR qui touche feature/topic ou feature/messages (src/main), ou core/ui (post/list/pager), sans toucher cette page, est bloquée — sauf ligne Parity-Impact: none — <raison> dans le corps de la PR (échappatoire documentée dans le template de PR ; le corps est relu en direct, relancer le job suffit après édition) ;
  • garde B (symboles) — cas de DocsConsistencyTest (tourne aussi en local via /validate) : chaque symbole cité entre backticks par la colonne Réf. de la matrice et par la section « Anomalies Topic » doit encore être défini dans l’arbre source — déclaration Kotlin/Java, nom typé (x:), import, littéral de chaîne du code (hors arguments d’annotation), ressource name="…", ou fichier source du même nom. Les commentaires ne comptent jamais (une citation ne peut pas s’auto-valider contre de la prose), un fichier de test ne peut attester que d’un symbole se terminant lui-même par Test, et le membre d’un symbole qualifié (Post.postIndex) exige une déclaration ou un nom typé dans le même fichier que son porteur. Dans « Anomalies Topic », tout token entre backticks est volontairement une référence couplée à l’arbre source ; les noms d’API externes ou les détails d’assertion qui n’ont pas à survivre comme référence restent en prose. Aucun compteur de lignes : la matrice est faite pour grandir.

Les gardes vérifient le geste (A) et la référence (B), pas la véracité d’une ligne : une ligne fausse qui cite un symbole vivant leur échappe — c’est le rôle du réaudit substantiel (ligne « Dernière vérification » sous § Matrice).

Trois verdicts possibles :

  • oui, livré — la fonction est effective côté MP (souvent via un mécanisme partagé : RedfaceTheme, PostListScaffold, PostRenderer) ;
  • non par nature — la fonction ne peut pas ou ne doit pas s’appliquer aux MP, avec la raison (contrainte serveur, confidentialité) ;
  • oui mais absent — la fonction a du sens en MP et n’y est pas câblée, ou y est câblée sans que le serveur en ait jamais servi la donnée (« câblé, non prouvé live »). C’est le backlog du chantier #1040, qui compte une ligne : « Compteur de citations ». Les mesures live du 2026-08-25 (#1107) ont tranché trois des quatre dernières lignes qui le portaient ; la quatrième attend une seconde sonde sur citation ancienne et un contrôle positif d’âge connu, la latence de tracking n’étant pas exclue. Décompte de la matrice à ce jour : 33 « oui, livré » + 2 « non par nature » + 1 « oui mais absent » = 36 lignes.

Contexte d’architecture : le partage se fait au niveau de la primitive sans politique dans :core:uiReadingPostCard au lot 1, puis la machine de zoom, PageFab et PageNavigation au lot 6 —, pas des écrans ni des ViewModels. Les primitives de chrome ne reçoivent que des valeurs et des callbacks — aucun état de feature, aucune préférence, aucun accès au cycle de vie. Leur identité entre surfaces est l’exigence du contrat, et toute divergence serait un bug, jamais un choix. Restent feature-owned les décisions de quand, où et si les monter, ainsi que les clés de cycle de vie, le rafraîchissement et le swipe. La parité de lecture n’était que le premier cas de ce partage, pas sa frontière — cf. #1040 et ADR-018 décision 1 (qui reprend l’ADR-013 supersédée, amendée 2026-08-12).

Trois contrats de test décrivaient l’écart comme un design et gelaient autant de lignes « oui mais absent ». Leur réarbitrage a été rendu le 2026-08-12 (#1041) — propositions rédigées par Fable, décision prise par Sol contre le code, le producteur ne pouvant pas être son propre juge :

  • MessageCardShellSmokeTest (#884) — amendé maintenant (lot 0). Les deux assertions sont conservées : elles caractérisent le chemin par défaut (encarté) de PostCardShell. Seul le KDoc change, parce qu’il présentait la hairline du mode plat comme une affordance « topic-owned » que le MP « never opts into » — une caractérisation du défaut y était écrite en interdiction permanente. La parité pleine largeur reste un chemin opt-in du lot 2, qui devra apporter sa propre couverture (toggle à chaud, survie d’un repli déplié).
  • PostRendererHostMatrixTest de :feature:messages (#958) — amendé au lot 3, dans la même PR que le provider LocalPostImageActions côté MP. Assertions retournées (tap et appui long définis, cible PostImageTarget vérifiée) et harnais conservé, plus un cas « callback absent ⇒ image inerte » : la capacité vient de la présence du callback, pas de la surface hôte. Tant que le MP ne fournit rien, l’inertie que ce test épingle est réelle, pas décorative.
  • le KDoc de MessageCard (#351c) — amendé au lot 1, PR 2, c’est fait, avec le comportement : l’amender avant aurait fait mentir le code. Tout le discours négatif est tombé, pas seulement les phrases densité et sélection — « no footer » et « no multi-quote border » gelaient les lots citation. Deux phrases devenues fausses en chemin ont été corrigées dans la même PR : « never provides LocalPostImageActions » (la carte partagée fournit désormais toujours le local, avec null faute de callback ; l’inertie épinglée reste intacte) et « Read-only for the MVP » dans le KDoc de l’écran. Les contrats de prose collatéraux (KDoc de PostCardShell, commentaire du paramètre selectable de PostRenderer, commentaire de densité de ThreadMessages) sont corrigés dans cette même PR.

Un contrat ne se flippe jamais en silence : le test change dans la même PR que le comportement, sinon la CI dit — à raison — que le comportement promis a changé.

Matrice

Dernière vérification substantielle : 2026-08-14, commit d2ad6820. « Substantielle » = un réaudit complet de la matrice contre le code ; c’est le seul événement qui met à jour cette ligne — une PR qui corrige ou bascule des lignes au fil de l’eau ne bumpe ni la date ni le SHA. Les références sont des symboles, pas des numéros de ligne.

Fonction Réf. MP/DT ? Détail
Lien vers une alerte modération #1287, HfrInAppUriHandler + ModerationAlertLinkSheet oui, livré Un lien modo.php vers un post public ouvre la même info racine depuis Topic et MP/DT, sans quitter la page courante. Le contrat de consultation et les cas qui naviguent vers le post sont définis dans Navigation — Deep Linking.
Taille de police #287, RedfaceTheme oui, livré Typo scalée par le préréglage, fournie par le thème — effective partout.
Pliage des longues citations #332, LocalFoldLongQuotes oui, livré Fourni par RedfaceTheme, lu dans PostRenderer.QuoteBlock — effectif en MP.
Ascenseur intra-page #300/#351c, PostListScaffold + LazyListScrollbar oui, livré Arrive par le scaffold partagé, qui lit LocalShowScrollbar lui-même. Une recherche de symbole côté MP le rate — l’écart se mesure par lecture, pas au grep.
Profil d’affichage des médias (GIF S/M/L) #973, LocalMediaDisplayProfile oui, livré Fourni par RedfaceTheme, lu dans PostRenderer.BlockImage.
Largeur maximale des images #991, LocalPostImageMaxWidth oui, livré Voir note #991 ci-dessous.
Coins des images #985, LocalPostImageCorners oui, livré Fourni par RedfaceTheme, puis appliqué par le PostRenderer partagé aux conteneurs d’image de contenu, y compris leur état d’erreur ; la miniature hero du menu image reste hors de cette préférence.
Tons de surfaces de lecture et spoiler AMOLED #883/#978, ThemeColorPreferences, RedfaceTheme, POST_RENDERER_SPOILER_CONTAINER_TAG oui, livré Les surfaces configurables sont résolues à la racine par RedfaceTheme et donc reçues identiquement par Topic et MP/DT via le renderer partagé. En AMOLED, PostRenderer remonte le spoiler fermé sur un container visible au lieu du near-black historique ; même token, même test de rendu sémantique sur les deux surfaces.
Retry unitaire d’un média en erreur PainterAttempt (PostRenderer) oui, livré Le slot d’erreur + retry par image vit dans le renderer partagé. Le retry en masse au refresh explicite (#813/#960) est, lui, câblé côté topic seulement → « oui mais absent ».
Pull-to-refresh #335/#351a, pullToRefresh oui, livré Keep-content (la page reste affichée pendant le rechargement). Les deux lecteurs utilisent le montage bas niveau : le geste et son indicateur sont désarmés pendant le zoom, pas seulement le callback.
Swipe de page horizontal #282/#351b, threadPageSwipe oui, livré Géométrie, seuils, edge-hint et prédicat de dead-zone système partagés dans core.ui.pager.PageSwipe. Le MP adopte l’annulation multi-touch #936 et la dead-zone #752 (insets lus une fois au DOWN). Son commit suit deux transitions : cible chaude, scellée par le compte et la génération du cache RAM → latch fermé pendant tout le slide-out, puis sélection ; cible froide → retour à offset nul, puis sélection keep-content, ancienne page lisible sous l’indicateur. Le chargement lancé par la sélection passe isRefreshing à true avant toute émission de contenu : un re-key peut créer un latch neuf, mais le gate composite le garde inerte jusqu’à la fin du chargement. La transition terminale vers isRefreshing=false réarme le geste, y compris après un échec sans changement de page. Swipe et edge-hint sont en outre désarmés pendant un zoom ou une mutation de liste du zoom (geste, arrêt de fling, glide, settle), un drag d’ascenseur, un scroll natif en cours et la fenêtre d’atterrissage couverte par PrivateMessageListAlignment. La fonction était déjà « oui, livré » : cette PR retire seulement sa note « écart de durcissement ».
Reprise de la page de lecture #430, mp_read_positions (ADR-018 décision 2) oui, livré (contrat propre) Position locale (page par conversation, par compte, purgée au logout) : il n’existe aucune position de lecture serveur pour les MP (#361 Q3, dot binaire par conversation) — le contrat diffère du dernier-lu topic par nature, ce n’est pas une lacune.
Densité structurelle #287, LocalDisplayMetrics oui, livré (lot 1, PR 2) La carte MP lit le preset par ReadingPostCard : gouttières et inset haut réinjectés depuis LocalDisplayMetrics, mesurés sur device à 12 dp en Comfort contre 16 dp en dur avant. Le chrome de liste (contentPadding, espacement) reste feature-owned des deux côtés : le topic le code en dur dans TopicListLayout et le KDoc de DisplayMetrics exclut explicitement du preset les dimensions de chrome — aligner le MP dessus aurait été plus que la parité. La densité peut recomposer des valeurs, jamais remplacer la carte, ses slots ou la branche SelectionContainer.
Mode pleine largeur #884/#1050, PostCardShell(flat) oui, livré (lot 2, PR 1) La préférence globale topic_full_width_posts pilote aussi le MP, sans clé ni réglage séparé et sans refetch. En mode plat, la liste conserve les insets haut/bas 16/88 dp mais retire gouttières et espacement ; le filet ne ferme qu’une frontière message → message, jamais le dernier message.
Sélection / copie du texte #281, PostRenderer(selectable) oui, livré (lot 1, PR 2) Les deux surfaces montent leur corps par ReadingPostCard, qui code selectable = true en dur : la capacité n’est ni un paramètre ni dérivable, donc structurellement constante sur la durée de vie de la carte. C’est l’exigence de #946 — flipper selectable insère/retire le SelectionContainer à l’entrée de PostRenderer, ce qui recrée le sous-arbre du corps et jette l’état rememberSaveable des replis de citation. Épinglé par un test sur deux axes séparés (densité seule, présence de callback seule) et vérifié sur device (poignées + barre « Copier / Tout sélectionner »).
Signatures #330/#1050, Post.signature non par nature (contrainte serveur, mesurée le 2026-08-25) HFR ne sert aucune signature en cat=prive. Mesuré #1107 sur une conversation de test : 0 span.signature sur une page de 11 messages, contre 6 sur le contrôle public capturé dans la même session, à la même seconde, pour le même lecteur (scripts/capture-mp-quote-fixtures.sh, cas thread_multipage + control_topic). Les trois confondants exigés par le protocole de preuve d’absence tombent un par un : préférence d’affichage du lecteur — 6 signatures lui sont rendues dans cette même session ; auteur sans signature active — XaTriX et xatelitte en ont une, rendue publiquement, et tous deux sont auteurs dans la conversation mesurée ; case « signature » décochée sur le message — un message de contrôle a été posté par le formulaire complet, case cochée (signature=1), et ne rend aucune signature. Piège de méthode à conserver pour toute remesure : le formulaire de réponse rapide MP fige signature=0 en champ caché, seul l’éditeur complet porte la case — mesurer depuis une réponse rapide n’aurait rien prouvé. Le câblage reste en place et sans objet (préférence globale observée côté MP, transmise à ReadingPostCardPresentation.showSignature, rendu prêt si le parser recevait une signature) : il n’y a rien à livrer côté app, la donnée n’existe pas côté serveur. L’explication globale « HTML dégradé en cat=prive » est écartée par l’asymétrie interne à la page — cf. ligne « Compteur de citations ».
Ton du message #340, Post.msgIcon hors périmètre La lecture et le rendu de MsgIcon sont livrés uniquement pour les sujets. Le parser partagé peut reconnaître la même forme DOM si HFR la sert en cat=prive, mais ni le rendu MP ni la persistance mp_messages ne sont câblés sans fixture et contrat MP dédiés.
Marqueur EgoQuote #874 Q4 / #1028, LocalEgoQuotePseudo oui, livré (lot 2, PR 2) La liste MP dérive le pseudo canonique de session (deriveEgoCanonicalPseudo, promu de :feature:topic vers :core:domain à comportement constant) et le route par ReadingPostCardPresentation. Actif en 1:1 comme en DT (arbitrage du cadrage : le MP rend des cartes uniformes sans alignement positionnel — pas de gate isMultiRecipient). Préférence partagée avec le topic, indépendante d’EgoPost.
Marqueur EgoPost #874 P1 / #1028, egoPostHighlighted oui, livré (lot 2, PR 2) Résolu par la liste MP via isEgoPost (:core:domain) sur le pseudo de session exposé dans PrivateMessageThreadUiState (purgé au logout) — Post.isOwnPost délibérément ignoré : bit de cache non scopé au compte, et absent des profils affichoutils=0 (#545). Marqueur a11y « Votre message » en StateDescription feature-owned sur le nœud d’identité, jamais un heading (#884).
Marqueur Modération (rouge) #1112/#1159, ModerationHighlightColors + ReadingPostCard oui, livré La classe structurelle HFR messageModo, parsée dans Post.isModerationPost, active sans réglage la même carte RF1-fidèle en Topic et MP : bande d’identité rouge sombre, corps rouge, texte blanc, citations/spoilers/code rouges. La palette sémantique partagée garde des variantes dark/AMOLED distinctes ; EgoPost prime sur tout le marqueur et l’ancrage Topic prime seulement sur sa bande. #1112 a retiré le liseré 1 dp du marqueur (invisible sur la carte déjà rouge, redondant avec l’en-tête blanc « Modération ») : seule la sélection multi-citation dessine encore un liseré (2 dp primary).
Pinceau doré des créateurs #221/#1060, isRf2Creator + CreatorPseudoText oui, livré Détection statique canonique (:core:domain) et feuille de rendu dorée (:core:ui) partagées. Le topic et le MP rendent CreatorPseudoText quand isRf2Creator(author) ; hors créateur, le pseudo reste un Text neutre, dans le repli de PostIdentityHeader ou dans son slot lorsqu’un pill staff doit lui être juxtaposé. Le slot MP porte lui-même le tap profil et son unique heading() sur le vrai texte, conformément au contrat #884. Les tests de carte couvrent créateur doré/non-créateur neutre, exactement un heading dans les deux branches et le tap du pseudo doré ; Rf2CreatorsTest couvre casse, caractères de format et espaces insécables via canonicalizePseudo.
Pill de rôle staff #221, AuthorRolePill + resolveAuthorRolePill oui, livré Topic et MP chargent le même annuaire staff global best-effort, résolvent le rôle par pseudo canonique et rendent le même pill inline à côté du pseudo. Les cinq rôles staff restent distincts (Modérateur, Admin, SupAdmin, Dev, Architecte) ; MEMBER, les pseudos inconnus et les posts de modération n’affichent rien. Le chargement est décoratif, indépendant de celui des messages et relancé au refresh explicite sans refetch induit par le seul rendu du pill.
Menu contextuel de message #362/#1051/#1074/#1117, MessageMenuSheet + MessageMenuTrigger oui, livré Le de l’en-tête ouvre le sheet propre à :feature:messages : copie du texte complet (désactivée si la projection d’un message uniquement composé d’images est blanche), ajout/retrait du panier de citation multiple, profil et masquer/réafficher l’auteur. L’appui long de la surface de carte est retiré : le corps redevient réservé à la sélection de texte, sans feuille ouverte en parallèle. Le placeholder MP d’un auteur masqué gagne un par rapport à origin/dev pour accéder notamment à « Ne plus masquer » ; il n’expose aucune action de citation et « Copier le texte » reste désactivé jusqu’à « Afficher ». Le placeholder du sujet reste nu : cette asymétrie d’accès est assumée. La commande de liste noire réutilise le snapshot vivant du lot 2 : messages et citations se replient/se restaurent ensemble, sans refetch. Le permalien reste absent (aucun contrat testé de lien précis vers un message MP, plus prudence de confidentialité) ; la citation simple « Citer » vit dans le pied de carte, pas dans ce menu. Par symétrie, PostMenuSheet propose les mêmes familles d’actions sans partager l’implémentation feature.
Actions d’image (viewer, menu appui long) #182/#831/#958/#1051/#1096/#1279, LocalPostImageActions + PostMediaDiskCachePolicy oui, livré Le menu d’appui long est câblé sur les images inline et bloc des deux lecteurs, avec le même sheet et la même sauvegarde. « Afficher en plein écran » ouvre le viewer sur l’image rendue depuis ce menu (target.copy(linkUrl = null)). Contrat images v1.5 — #1279 : une image inline liée vers une cible image-like ouvre la visionneuse ; le rendu inline/bloc (§2) ne change pas, seule l’action du tap est alignée. Sur un bloc, le tap ouvre aussi le viewer pour une image nue. Un lien non-image/douteux garde le navigateur ; une image inline sans lien conserve uniquement l’appui long. Une image data: (inline comme bloc) n’ouvre ni la visionneuse ni le menu — son URL n’est pas éligible — mais gagne le tap navigateur de son lien quand elle est liée ; les surfaces sans hôte d’actions (signature, aperçu éditeur) restent totalement inertes, lien compris ; smileys et cc-images ne reçoivent aucune action d’image et conservent leurs liens textuels éventuels. Le contrat typé sépare source plein format, preview déjà rendue et URL externe. Le topic utilise Telephoto pour le sous-échantillonnage ; le MP conserve DISABLED sur la requête Coil et emploie le moteur de gestes Telephoto autour du painter mémoire-only, car l’intégration ZoomableAsyncImage 0.19 forcerait sinon une écriture disque pour ses tuiles. « Enregistrer l’image » reste offert en MP : après le miss disque attendu, le saver retélécharge les octets originaux via le client anonyme ; le GIF n’est jamais réencodé. Au logout / changement de compte, CacheInvalidator purge aussi les caches Coil globaux. Les matrices hôtes Topic/MP couvrent le routage viewer et sa politique disque ; PostImageViewerPolicyTest couvre la table d’URL, PostImageMenuSheetTest l’entrée plein écran et ImageViewerScreenTest le placeholder mémoire, les actions et la requête MP sans disque.
Citation simple #146/#1074, « Citer » par message oui, livré (GET mesuré en 1:1 + DT, aucun POST live) Le pied de chaque message visible et citable ouvre l’éditeur sur le formulaire typé cat=prive ; l’action est masquée si le rang ref manque ou si le message est sur liste noire. Le GET 1:1 a été mesuré le 2026-08-12 (#1041, fixtures private_message_quote_form.html + témoin private_message_reply_form.html), puis reproduit en DT le 2026-08-17 (private_message_dt_quote_form.html) : mêmes 20 champs cachés, numrep = le message cité, content_form prérempli [quotemsg=numrep,ref,userId], numreponse vide, aucun champ caché ref et aucun newdest. « Citer » a aussi ouvert l’éditeur DT prérempli sur appareil sans erreur. Aucun POST live n’a été émis ; MockWebServer prouve le corps produit par le client (numrep cité, numreponse vide, aucun ref), pas son acceptation par HFR. Le comportement de ReplyFormParser reste inchangé. Trou de couverture ouvert (#1110) : ce fail-closed masque « Citer » sur le premier message de toute page N > 1, en 1:1 comme en DT — la « Reprise du message précédent » que HFR insère en tête de page porte ref=0 (#986) et est écartée par l’unique filtre ref >= 1 de toPrivateMessageQuoteSelectionOrNull, alors que le message réel existe sur la page N−1 avec son vrai rang. Détail dans protocol-hfr.md § « MP/DT — citer un message ».
Citation multiple #291/#1074/#1102, QuoteScope.PrivateMessage + PrivateMessageReplyQuoteMaterializer + MessageQuoteActions oui, livré (affordance directe ; formulaire unitaire mesuré, enchaînement et POST non mesurés live) L’ajout/retrait a désormais deux points d’entrée, comme côté sujet : l’action directe quote+/quote- dans MessageQuoteActions, au pied de la carte à côté de « Citer », et l’entrée supplémentaire de MessageMenuSheet. messageQuoteAffordances dérive les deux actions du pied dans une seule branche selon les conditions réellement codées : canReply, !authorBlocked, selection != null et quote != null ; l’état sélectionné pilote le libellé, la bordure et la sémantique sans changement de hauteur. Le FAB « Citer N » ouvre l’éditeur (appui long : vider). Le panier reste dans :app, filtré par scope avant son handoff en mémoire. La matérialisation est séquentielle, une requête par QuoteSelection, avec les page et ref de son QuoteLocator ; aucun chemin MP n’emprunte l’accesseur MultiQuoteBasket.numreponses ni la règle topic qui annule ref — cet accesseur n’a d’ailleurs aucun call site de production et efface les locators (il ne retient que numreponse, jetant page et ref) : son retrait ou son annotation est suivi par #1105. Un préremplissage blanc fait échouer tout le chargement ; le POST conserve le premier formulaire et ses champs. La liste noire purge les sélections devenues masquées, y compris celles du même auteur prises sur une autre page ; un message explicitement révélé reste hors panier tant que l’auteur demeure bloqué. Les captures 1:1 et DT prouvent uniquement la forme du formulaire renvoyé pour une citation. Les tests locaux prouvent l’ordre des requêtes, la conservation de chaque locator, la concaténation des préremplissages et la forme du POST client ; aucune capture live ne prouve encore ce que HFR renvoie lors de plusieurs récupérations successives, ni qu’il accepte un POST contenant plusieurs blocs [quotemsg]. La borne de preuve est celle de la citation simple — contrat unitaire mesuré, étape terminale d’écriture non mesurée —, pas celle des lignes « câblé, non prouvé live » : celles-là attendent que HFR serve une donnée jamais observée en MP, alors qu’ici la fonction est utilisable et observable en production. Le verdict reste « oui, livré » : #1102 retire uniquement la note d’écart d’accès, il ne livre pas une nouvelle capacité serveur. Même trou de couverture que la citation simple (#1110) : toPrivateMessageQuoteSelectionOrNull reste l’unique filtre ref >= 1, donc la « Reprise du message précédent » (ref=0) des pages N > 1 n’est pas sélectionnable au panier, en 1:1 comme en DT.
Saut vers le message cité #625/#699/#782/#1074, onGoToCitedPost oui, livré (atterrissage MP page/message) La page et le numreponse cités sont extraits des liens statiques et dynamiques par le PostContentParser, y compris sur la fixture MP en cat=prive (numreponse vient de #t…, pas du paramètre de query). Le défaut de forme dynamique n’était pas propre aux MP : il touchait aussi les sujets en authentifié (#625) — DYNAMIC_CITATION_HREF_REGEX est consommé par parseQuote pour les deux surfaces, et le correctif #1092 a rétabli le saut pour tout utilisateur connecté, sujets compris. Le MP câble le geste à une cible pendante liée à la page, au compte et à la génération : même page sans recharge ; autre page sur la première émission rendue qui contient la cible (cache de session si présent, réseau sinon) ; page effectivement parsée adoptée si HFR rabat la demande ; cible absente au terminal rabattue en haut ; changement de page/compte invalidant. Le test de rendu couvre explicitement la priorité de l’atterrissage sur la carte ciblée face à la remise en haut. Le retour dédié #782 n’est pas empilé en MP : le bouton système conserve la sortie de conversation ; les ancres restaurent les retours ordinaires de pagination sans créer de pile de sauts cités. Le caveat des ancres obfusquées reste distinct.
Marqueur d’édition #483/#1051, Post.editedAt + HfrDateParser oui, livré (servi et parsable, mesuré le 2026-08-25) MessageCard fournit le marqueur inline « · édité » au slot dateTrailing de PostIdentityHeader, et MessageMenuSheet la ligne horodatée « Édité le … », strictement lorsque editedAt != null. HFR sert le marqueur en cat=prive, et dans la forme des sujets : mesuré #1107 sur une conversation de test dont un message a été réellement édité avant la capture1 div.edited servi, verdict de présence présent et non « présent mais vide », donc le trailer porte bien la forme « Message édité par … le … à … » qu’attend HfrDateParser.parseEditedAtOrNull et que le PostsParser partagé extrait déjà sur cat=prive. C’est la forme, pas la présence, qui était le point ouvert : une première capture n’avait mesuré qu’une absence triviale (aucun message édité sur la page), l’édition a donc été provoquée puis recapturée. Les deux rendus restent couverts par état synthétique.
Compteur de citations #239/#863/#1051, Post.citedCount oui mais absent (câblé, non prouvé live) Ce qui est mesuré (#1107, scripts/capture-mp-quote-fixtures.sh, cas thread_multipage + control_topic) : 0 compteur « cité N fois » servi sur la page de conversation de test, contre 2 sur le contrôle public capturé dans la même session — c’est un indice fort, pas une preuve. La citation sondée a été postée entre participants distincts depuis le lien de citation servi par HFR, et le message cité figure sur la page capturée : les confondants « aucune citation tracée » et « auto-citation » tombent. L’asymétrie est interne à la même page MP : sur le même HTML, à la même seconde, pour le même lecteur, le marqueur d’édition est servi et le compteur ne l’est pas — le serveur choisit fonction par fonction, ce qui écarte l’explication globale « HFR sert un HTML dégradé en cat=prive » et vaut aussi pour la ligne « Signatures » ; un contrôle public seul n’aurait pas permis cette conclusion. Ce que cette asymétrie n’écarte pas, et qui bloque le verdict : la latence de tracking. Le protocole de preuve d’absence exigeait deux sondes — une citation ancienne faite par le flux web, qui élimine l’asynchronie, et une citation fraîche — ; la mesure n’en porte qu’une, fraîche. Il n’existe par ailleurs aucun contrôle positif d’une citation fraîche incrémentant un compteur : les 2 compteurs du contrôle public sont d’âge inconnu, et la tentative de borner la fenêtre de propagation a échoué — sur le topic public, un message porte un compteur sans qu’aucun post des deux dernières pages ne le cite. Il manque donc, pour trancher : une seconde sonde sur citation ancienne, et un contrôle positif d’âge connu montrant qu’une citation entre participants distincts finit par incrémenter le compteur. Acquis de méthode à conserver : l’auto-citation n’incrémente pas le compteur (mesuré côté public), ce qui rend la preuve inatteignable en 1:1 mono-compte et impose une conversation à plusieurs (DT). Le câblage, lui, est en place : MessageCard fournit la pill « cité N fois » au slot badges de ReadingPostCard, MessageMenuSheet la ligne d’information, strictement lorsque citedCount > 0, et le PostsParser partagé extrait le compteur dès que HFR en sert un.
Profil au tap (avatar/pseudo) #208, onOpenProfile oui, livré (lot 1, PR 2) Câblé côté MP sur le ProfilePreviewSheet déjà existant, gaté par Post.profileId (épinglé sur fixture MP, #1041). Vérifié sur device depuis le pseudo et depuis l’avatar, cible tactile de 48 dp et rôle Button exposé. Contrainte d’API toujours valable : PostIdentityHeader pose heading() sur son pseudo de repli mais n’en ajoute aucun quand un slot pseudo est fourni. Le MP fournit désormais ce slot pour un créateur RF2 ou un staff afin de juxtaposer le pseudo et le pill ; le vrai nœud texte du slot conserve exactement un heading(), jamais zéro ni deux.
Double-tap pour rafraîchir #382/#1103, privateMessageDoubleTapRefresh oui, livré (lot 6, PR 5) L’action de recharger la page courante existait déjà des deux côtés : cette PR ajoute uniquement le geste MP et reprend le contrat topic avec un arbitre unique sur la vraie liste. Un double-tap sur la surface libre d’une MessageCard, d’une HiddenPostCard ou dans un interstice appelle exactement une fois ce rafraîchissement et produit HapticFeedbackType.Confirm. Sur les deux surfaces, le texte sélectionnable du corps ou d’une citation consomme au contraire le double-tap pour sélectionner un mot : il ne rafraîchit pas et ne produit aucune haptique Confirm de rafraîchissement. Les up consommés par MessageMenuTrigger, MessageQuoteActions (« Citer » et quote+/quote-), le pseudo et l’avatar distincts de PostIdentityHeader (avatar Role.Button, 48 dp), l’en-tête de citation câblé par onGoToCitedPost, les liens et les images annulent aussi le geste ; le placeholder masqué couvre séparément « Afficher » et son . Un drag au-delà du touch slop annule sans gêner le scroll ni le balayage de page. Depuis #1106, le zoom laisse passer les taps enfants ; la garde explicite sur l’état zoomé porte donc seule la suspension du double-tap refresh. Le callback est celui du tirer-pour-rafraîchir : le même état isRefreshing alimente le même PullToRefreshDefaults.Indicator monté avec Modifier.pullToRefresh. TopicDoubleTapRefreshTest et PrivateMessageThreadContentTest montent les vraies cartes et prouvent ce contrat sur texte sélectionnable ; la suite MP couvre séparément les autres cibles, les drags, le zoom, l’haptique, l’indicateur partagé et l’appui long d’image. La preuve est entièrement comportementale, sans nouveau contrat serveur. Cette PR 5, dernière des six PR du lot, clôt le lot 6.
Zoom pincé #182/#1040/#1106, PinchZoomState + pinchZoom + pinchZoomTransform oui, livré (lot 6, PR 1 ; interactions enfants rétablies #1106) État, geste, transformation et calculs vivent dans :core:ui. Topic et MP montent la même machine ; chaque feature garde sa clé page/lecteur, son swipe, son pull-to-refresh et son chip 1×. La suite :core:ui épingle le hit-testing de la couche transformée ; les suites Topic épinglent calculs, gestes, repli de citation, coexistence multi-touch, taps/appuis longs propagés en zoomé et pan consommé après touch slop ; les tests MP prouvent le pincement effectif, le reset à la clé de page, la suspension du swipe/PTR/double-tap et le fonctionnement d’une cible enfant en zoomé. Le défilement natif de la liste est désarmé pendant le zoom — PostListScaffold expose userScrollEnabled, et l’axe vertical est alors piloté programmatiquement par la machine de zoom au lieu du geste système. Depuis #1106, les taps et appuis longs ne sont plus rendus inertes : liens, images et sélection de texte restent ciblables dans la couche graphicsLayer; un mouvement au-delà du touch slop engage en revanche le pan zoomé et consomme le geste. Les captures Roborazzi avant/après à 1× et à transformation fixe sont une preuve pixel du rendu extrait, pas une preuve gestuelle.
Liste noire #509/#1050/#1074, BlacklistRepository + LocalBlockedQuoteAuthors oui, livré Filtre vivant côté MP, en 1:1 comme en DT : le message reste dans la liste mais sa carte devient un placeholder « Afficher », replié de nouveau au changement de page. La même émission alimente le masque des messages et celui des citations, sans refetch. Depuis le lot 4, elle purge aussi du panier multi-quote les messages qui deviennent masqués ; la révélation locale ne réactive pas les actions de citation tant que l’auteur reste bloqué. Aucun gate isMultiRecipient : PrivateMessageThread.isMultiRecipient = false n’exclut pas un MultiMP sur la page courante et le hint d’inbox est absent des deep links ; le comportement reste donc déterministe quel que soit le chemin d’entrée. L’édition de la liste depuis MessageMenuSheet est livrée par le lot 3.
Ancres de scroll par page (session) #307/#895 F3, pageAnchors, PrivateMessagePageLanding, PrivateMessageListAlignment oui, livré (lot 6, PR 2) Le MP capture index + offset à l’arrêt d’un scroll utilisateur, sous verrou d’alignement contenu/position et hors drag d’ascenseur ou geste/settle de zoom. Le ViewModel retenu conserve une ancre RAM par page visitée et arbitre un seul atterrissage : message cité explicite > ancre restaurée > haut. Le saut cité ne supprime pas l’ancre existante lorsqu’il est armé : il prend priorité sur elle pour l’atterrissage. Une fois stabilisé à l’écran, son viewport devient volontairement la nouvelle ancre de la page — le dernier viewport lu — et donc son prochain point de reprise. La première émission rendue de la cible (cache de session si présent, réseau sinon) porte l’atterrissage ; la revalidation ne rescrolle pas. Portée session d’affichage uniquement : aucune ancre métier n’est persistée et aucun SavedStateHandle ne la porte ; le saver intégré de LazyListState peut restaurer son index + offset après mort de process, mais loadInitial publie Top depuis la map RAM vide et cet atterrissage écrase l’état restauré — la non-restauration est donc effective, pas structurelle. Purge complète au logout et au changement de compte ; le prefetch ne crée aucune ancre. Le composable partagé ScrollToTopOnPageChange de :core:ui reste sans site d’appel de production hors de son propre test ; son retrait ou son remontage explicitement justifié est suivi par #1104, car la garde B prouve l’existence d’un symbole cité, pas son montage.
Pagination riche (picker, premier/dernier, snapshots RAM, stale-while-switching, chrome) #895 étape 4, PageFab, PageNavigation, PrivateMessageThreadSessionCache, PrivateMessageSubmitResult oui, livré (lot 6, PR 3) Cette PR livre le picker borné, les raccourcis premier/dernier et remplace le pager de fin de liste par la pilule permanente et, sous la préférence #383, le cluster de FAB, via les primitives partagées PageFab et PageNavigation. La page indiquée était déjà la page parsée réellement rendue : cette PR ne change que son accès dans le chrome. Elle ne revendique ni les snapshots RAM ni le stale-while-switching, déjà livrés par le lot 5 avec PrivateMessageThreadSessionCache. Les ancres par page et la session ViewModel conservée après envoi viennent du lot 6 PR 2 ; le prefetch a, lui, acquis sa preuve live multipage le 2026-08-25 (ligne dédiée).
Prefetch anonyme de la page suivante règle prefetch, @AnonymousClient non par nature cat=prive répond 403 en anonyme : le prefetch anonyme qui donne au topic son swipe instantané est structurellement impossible en MP.
Prefetch authentifié borné (N±1, conversation ouverte) ADR-018 décision 7 oui, livré (preuve live multipage acquise le 2026-08-25) Le code borne le groupe à N−1/N+1 après la lecture réseau terminale de N, le gate à une composition RESUMED, l’annule à la pause/dispose, au changement de page ou de compte, et peuple le cache RAM avec les mêmes contrôles cible + sceau. Le point d’entrée dédié prefetchPrivateMessageThread est interdit hors PrivateMessageThreadViewModel par une garde Konsist à scan non vide ; MessagesViewModelTest épingle zéro prefetch depuis la liste. La capture multipage qui manquait a été exécutée (#1107, scripts/capture-mp-quote-fixtures.sh cas thread_multipage) : une conversation de 4 pages, une seule session authentifiée, ordre N=2, N−1=1, N+1=3, hors-borne=5. Les six contrôles de cohérence passent — conversation unique ; ordre rendu conforme ; jeux d’ancres réelles disjoints entre les trois pages ; reprise de N = dernière ancre de N−1 avec ref=0 ; reprise de N+1 = dernière ancre de N avec ref=0 ; rabattement serveur mesuré, la demande page=5 étant servie comme page=4, prouvé par l’URL effective. C’est exactement ce que la fixture monopage ne pouvait pas montrer : les deux voisins et le rabattement. Le contrat ref=0 de la « Reprise du message précédent » est du coup mesuré en cat=prive, plus supposé (cf. protocol-hfr.md § « MP/DT — citer un message » et #1110).
Cache RAM de session (retours de page instantanés) ADR-018 décision 2 oui, livré (lot 5, PR 1) PrivateMessageThreadSessionCache : LRU globale de cinq pages, clé par compte canonique/conversation/page, purge logout et bascule de compte par CacheInvalidator. PrivateMessageThreadPage.Source distingue SESSION_CACHE de NETWORK ; le hit s’affiche immédiatement et peut porter l’unique atterrissage visuel de la page, tandis que la revalidation réseau reste obligatoire et porte seule les écritures de domaine. Preuves : PrivateMessageThreadSessionCacheTest, DefaultMessagesRepositoryTest, PrivateMessageThreadViewModelTest et garde confidentialité ArchitectureKonsistTest.
Cache Room du contenu #1097, ADR-018 décision 3, PrivateMessageContentCache + RoomPrivateMessageThreadDiskCache oui, livré (lot 7, PR 3) Opt-in explicite, défaut OFF, unique et global à l’application : le libellé du réglage dit qu’il vaut pour tous les comptes utilisés sur cet appareil, et la donnée reste indexée par compte canonique dans mp_thread_pages/mp_messages. L’ordre de lecture est RAM → Room si ON → réseau obligatoire : un hit disque s’affiche mais reste provisoirePrivateMessageThreadPage.Source le distingue, il ne déclenche aucun effet terminal, ne porte aucune écriture de domaine et n’alimente pas la RAM ; seule une réponse réseau visible, terminale, encore conforme à la cible et au sceau compte/génération peuple les caches. Le prefetch N±1 reste RAM-only même toggle ON : prefetchPrivateMessageThread ne persiste jamais, donc une page jamais affichée ne s’écrit jamais sur le disque. Cinq pages par compte au plus, éviction LRU : aucune promesse hors-ligne, c’est un cache, pas un mode déconnecté. Désactiver purge immédiatement après confirmation explicite, dans cet ordre — OFF persisté, génération RAM invalidée synchroniquement, purge Room sérialisée de tous les comptes. La déconnexion et la bascule de compte purgent, elles, le seul compte sortant (clearForUser), réglage ON comme OFF : seul le passage à OFF purge tous les comptes. Ces trois événements de confidentialité finissent par un scrub des octets (PrivateContentDatabaseScrubber : checkpoint WAL en mode TRUNCATE, VACUUM, puis un second checkpoint TRUNCATE — en WAL le VACUUM commite son image propre dans le journal, donc sans ce second checkpoint redface.db garderait les octets purgés), placé en dernier dans CacheInvalidator pour couvrir aussi les lignes privées supprimées avant lui ; les résidus laissés dans les pages libres par les évictions LRU pendant que l’opt-in est ON sont explicitement acceptés (ADR-018 décision 6). Chemins d’échec : une écriture de réglage refusée reste ON sans rien purger ; une purge échouée reste OFF en verrouillant lectures et écritures disque, affiche une erreur réessayable et reste marquée pendante jusqu’à sa réconciliation, au démarrage suivant, par CacheInvalidator monté depuis RedfaceApplication — seule une purge pendante y déclenche un scrub, un défaut OFF sans purge en attente n’en déclenche aucun ; jamais de retour silencieux à ON. Preuve de rémanence par énumération et non par affirmation : PrivateContentDatabaseScrubberTest écrit une sentinelle, vérifie qu’elle atteint réellement SQLite, purge, puis scanne les octets de redface.db et de son journal WAL — sur deux scénarios, car un seul ne prouverait qu’une moitié. Sans checkpoint intermédiaire la sentinelle ne vit avant scrub que dans le WAL ; avec checkpoint intermédiaire et secure_delete désactivé, elle est d’abord constatée dans redface.db puis doit en disparaître. Contre-épreuve exécutée : ce second scénario échoue si l’on retire le checkpoint post-VACUUM. Le préalable #1096 reste acquis côté médias et n’est pas rouvert : activer le cache texte ne réactive aucun cache disque Coil. Cette PR 3 clôt le lot 7 et, avec lui, le chantier #1040.

Note #991 — largeur maximale des images : LocalPostImageMaxWidth est fourni par RedfaceTheme et lu par PostRenderer pour les trois chemins d’image de contenu : inline mesuré, bloc mesuré et slot bloc froid. Les surfaces Topic et MP/DT passent par le même renderer partagé et reçoivent donc la même valeur, indépendamment du mode pleine largeur. Le chemin inline réserve aussi le padding horizontal 4 dp par côté du placeholder : en colonne étroite, 99 % et 100 % peuvent se rejoindre côté inline, tandis que les deux chemins bloc continuent de les distinguer.

Anomalies Topic révélées par l’audit

Cette section ne reçoit que d’anciennes lignes de la matrice dont l’audit a réfuté l’effectivité côté topic — jamais des fonctions projetées. Les trois verdicts présupposent une fonction effective côté topic ; quand cette prémisse tombe, la ligne sort de la table : il n’y a rien à porter en MP, l’écart relève du backlog topic, pas de celui de #1040. L’entrée reste ici pour que la fonction ne redevienne pas un écart non tracé ; le jour où le topic livre la fonction, sa ligne retourne dans la matrice.

Index du message (Post.postIndex) — retirée de la matrice le 2026-08-14. La ligne affirmait « affiché via le menu contextuel côté topic » : réfuté. Le champ existe mais n’est jamais peuplé — son unique producteur, PostsParser, le fixe à null (épinglé par TopicPageParserTest) ; mappers (TopicMappers) et Room (PostEntity) ne font que le transporter. #1055 retient un nettoyage sans migration : le rendu mort de TopicPostIdentityHeader et sa ressource ont été supprimés, tandis que le champ et la colonne Room v1 restent réservés pour compatibilité de schéma. Le menu contextuel (#362) continue d’afficher numreponse (topic_post_menu_number), pas l’index. Le test qui épingle ce null reste la garde du contrat parser ; le champ ne doit pas être peuplé ou réaffiché sans caractériser son sens entre pages, préférences HFR et cache à partir de fixtures réelles.

Ce qui reste hors matrice, par surface

Chrome de l’écran, pagination visuelle, sondages, drapeaux, recherche intra-topic (transsearch, authentifiée par conception — son applicabilité à cat=prive n’a jamais été caractérisée), roster des participants (#612), gestion des membres DT (#606) : propres à chaque surface, ils ne relèvent pas de la carte de lecture partagée. Les décisions délibérées de confidentialité (#316 routes opaques, pas de persistance par défaut) ne sont pas des écarts de parité — ne pas chercher à les effacer.


Haut de page

Redface 2 — Specs v0.12.0 — Un projet communautaire pour Hardware.fr

This site uses Just the Docs, a documentation theme for Jekyll.