# Rapport — v3.12.0

**Lot du socle : se livre aux DEUX installations (IRADA et RUWAD), à l'identique.**
**Aucune migration. Aucune commande de reprise. Aucune dépendance composer nouvelle.**

---

## Pourquoi ce lot

Six demandes de l'exploitant, deux terrains.

**Le blog d'abord.** Un tableau saisi dans l'éditeur d'article disparaissait à l'enregistrement :
l'exploitant composait sa grille, cliquait « Enregistrer », et retrouvait un texte à plat. Ce
n'était pas une panne — c'était l'assainisseur HTML qui faisait son travail à la lettre. Sa liste
blanche, écrite en Phase 2 pour bloquer les injections, ne mentionnait tout simplement pas les
balises de tableau : HTMLPurifier les découpait, en silence, sans le moindre message. À côté, la
barre d'outils restait pauvre, et l'arabe s'écrivait de gauche à droite.

**L'application de gestion ensuite.** « Elle est lente à chaque changement d'écran. » Pas le
tableau de bord seulement : *chaque* écran. C'est du coût par requête, et il fallait le mesurer
avant d'y toucher.

Et deux points de vitrine : des logos partenaires trop petits et en noir et blanc, et un second
compte Instagram à afficher.

---

## §A — Les tableaux s'affichent, et s'éditent

### Ce qui n'allait pas

`App\Support\HtmlSanitizer` porte une liste blanche (`ALLOWED`) appliquée **deux fois** : à
l'enregistrement (`dehydrateStateUsing`) et à l'affichage. Elle ne contenait ni `table`, ni `tr`,
ni `td`. Un tableau n'avait donc aucune chance : il était détruit **au save**, avant même d'être
stocké.

### Ce qui a été fait

La liste blanche s'élargit à `table, caption, colgroup, col, thead, tbody, tfoot, tr, th, td`,
avec `colspan`, `rowspan` et `scope`. Elle accueille aussi `hr`, `code`, `pre`, `s` et `del`,
qu'exige la barre d'outils enrichie du §B.

L'outil **Tableau** de Filament 5.6 est explicitement présent dans la barre : création, ajout et
suppression de lignes et de colonnes, ligne d'en-tête, suppression du tableau.

Sur la vitrine, `.prose-site table` prend une mise en forme sobre — bordures, en-têtes sur fond
clair, lignes paires légèrement teintées, légende en italique — et **défile horizontalement
dans sa propre boîte** quand il déborde (`display: block; width: max-content; min-width: 100%;
overflow-x: auto`). La page, elle, ne part jamais de travers.

> ⚠️ **Les tableaux des anciens articles sont perdus, et doivent être re-saisis.** Ils ont été
> découpés **à l'enregistrement**, à l'époque : le corps stocké en base ne contient plus les
> balises, il n'y a rien à récupérer. Les tableaux saisis à partir de maintenant, eux, sont
> conservés.

---

## §B — De quoi mettre en page

La barre d'outils est définie **une fois**, dans `CmsForm::BARRE_OUTILS`, et sert les quatre corps
riches de la vitrine (articles, services, sections de page, description publique d'un marché) :

| Groupe | Outils |
|---|---|
| Caractère | gras, italique, souligné, barré |
| Titres | H2, H3, H4 |
| Alignement | gauche, centre, droite, justifié |
| Blocs | liste à puces, liste numérotée, citation |
| Technique | code en ligne, bloc de code, filet horizontal |
| Insertion | lien, image / pièce jointe, **tableau** |
| Historique | annuler, rétablir |

**La règle qui gouverne cette liste :** *un bouton n'a le droit d'y figurer que si son résultat
traverse `HtmlSanitizer`.* Un bouton dont le balisage serait découpé à l'enregistrement est un
piège : l'exploitant s'en sert, puis perd son travail en enregistrant — c'est exactement ce qui
arrivait aux tableaux. Indice, exposant, surlignage, couleur de texte, grille et **blocs
personnalisés** sont donc volontairement absents, et un test le vérifie.

**Sur les blocs personnalisés (`customBlocks`) en particulier.** Filament 5.6 les expose, et le
cahier demandait d'ajouter un encadré de mise en avant « si la version les expose ». Ils n'ont pas
été retenus, pour une raison technique nette : un bloc personnalisé ne se stocke pas comme du HTML
mais comme un jeton opaque — `<div data-type="customBlock" data-id="…" data-config="…">` — que
seul `RichContentRenderer` sait rendre. Or la vitrine n'utilise pas ce moteur : elle affiche du
HTML assaini. Un encadré aurait donc été **invisible en ligne** (le jeton étant retiré par le
sanitizer), et son contenu perdu. Le cahier prévoyait ce cas : « sinon s'en tenir à la barre
enrichie ».

### L'alignement, et la garde qui ne bouge pas

L'alignement de Tiptap voyage en attribut `style` (`style="text-align: center"`). Ouvrir `style`
en grand aurait rouvert un vecteur d'injection. L'ouverture est donc étroite, sur deux verrous
superposés :

1. `style` n'est autorisé que sur les éléments qui peuvent s'aligner — `p`, `h2`, `h3`, `h4`,
   `th`, `td` ;
2. `CSS.AllowedProperties` ne laisse passer **qu'une seule propriété**, `text-align`, dont
   HTMLPurifier borne lui-même les valeurs à `left | right | center | justify`.

Tout le reste d'une feuille de style en ligne — `position`, `background`, `display`, `opacity`,
`url(...)`, `expression(...)` — est jeté, au même titre que `<script>`, `<style>`, `<iframe>`, les
attributs `on*` et les URL `javascript:` / `data:`. `HtmlSanitizerTest` le prouve dans les deux
sens : ce qui doit passer passe, ce qui doit tomber tombe, **y compris à l'intérieur d'un tableau**.

---

## §C — L'arabe de droite à gauche

**En édition.** Les champs arabes du back-office portent `dir="rtl"` : corps d'article, titre,
extrait, sur-titre et titre de section, méta SEO. Le sens est posé sur la zone de saisie
elle-même (`extraInputAttributes`), donc l'alignement à droite fonctionne réellement dans
l'éditeur, et pas seulement à l'affichage. Le français reste en LTR, inchangé.

**À l'affichage.** Le gabarit de la vitrine posait déjà `dir` sur `<html>`. L'article le porte
désormais **aussi**, explicitement, sur son entête et sur son corps
(`<div class="prose-site" dir="rtl">`) : un article reste lisible même sorti de son gabarit
— aperçu, impression, flux. La feuille de style aligne à droite `.prose-site` sous `[dir="rtl"]`.

---

## §D — La vitesse de l'admin

### La mesure, avant de toucher à quoi que ce soit

Un banc de mesure a rendu chaque écran type avec de la matière (25 marchés, 30 tiers, 40
écritures, 25 factures, 20 bulletins de paie, 15 cautions…) en comptant les requêtes SQL.

| Écran | Avant | Après |
|---|---:|---:|
| Tableau de bord | 1 | 1 |
| Liste — Marchés | **105** | **6** |
| Liste — Paie | **30** | **10** |
| Liste — Cautions | **19** | **5** |
| Liste — Écritures | 13 | 13 |
| Liste — Devis | 9 | 9 |
| Liste — Notes de frais | 9 | 9 |
| Liste — Feuilles de temps | 9 | 9 |
| Liste — Factures | 7 | 7 |
| Liste — Tiers | 5 | 4 |
| Liste — Appels d'offres | 5 | 5 |
| Liste — Salariés | 4 | 4 |
| **Total, 11 listes** | **215** | **81** |

**−62 % de requêtes SQL** sur les onze listes mesurées, et **−94 % sur la liste des marchés**,
qui est l'écran le plus consulté de l'application.

### Les quatre causes traitées

**1. Le mode SPA était éteint.** Chaque clic dans le menu rejouait le document entier : ~1,44 Mo de
CSS et de JS Filament, plus 238 Ko de Livewire, re-téléchargés ou relus et ré-analysés **à chaque
changement d'écran**. Sur un mutualisé, cela se sent à chaque clic. `->spa()` est activé sur le
panneau d'administration : Livewire ne rapporte plus que le corps de la page suivante et le
remplace, les scripts déjà chargés le restent.

Le préchargement au survol (`hasPrefetching`) reste **éteint** : IRADA tourne sur un serveur sujet
aux saturations MySQL, on n'y va pas chercher des pages que personne n'a demandées.

Et surtout : **tout ce qui n'est pas une page reste hors du mécanisme.** Bulletins de paie,
rapports de marché, relevés de compte courant, notes d'analyse, attestations, justificatifs,
sauvegardes, lecteur de documents — vingt-sept motifs déclarés dans `App\Support\NavigationSpa`.
Un PDF injecté dans le DOM, au mieux ne fait rien, au pire vide l'écran.

`NavigationSpaTest` tient les deux bouts : **aucun motif ne recouvre l'adresse d'un écran** du
panneau, et **chaque route de téléchargement est bien couverte** par un motif. Renommer une de ces
routes fait tomber le test, plutôt qu'un PDF en clientèle.

**2. Trois N+1, tous de la même famille.** Une valeur calculée par ligne qui repartait en base
alors que la donnée était déjà en mémoire :

- `Project::collectedAmountTtc()` / `collectedRevenueHt()` / `invoicedRevenueHt()` lançaient un
  `SUM` SQL par marché affiché, alors que la liste des marchés éveille déjà `milestones` pour
  toutes ses lignes. La relation chargée fait désormais foi quand elle existe (le résultat est
  identique : `with('milestones')` charge la relation entière, sans filtre) ;
- `Payroll::$paid_amount` faisait de même sur `payments` ;
- la colonne « Lié à » des cautions lisait `project` **puis** `tender` sans que ni l'un ni l'autre
  soit éveillé, et l'action « Créer compte investisseur » demandait, ligne par ligne, si ce tiers a
  déjà un compte. Deux `with()` ont réglé les deux.

**3. Le menu et les permissions : rien à corriger.** Le tableau de bord ne coûte **qu'une seule
requête**, menu compris. La construction des ~56 entrées de navigation, avec l'évaluation de
`canAccess()` sur chacune, ne touche pas la base : le cache de permissions Spatie est actif
(24 h, magasin par défaut), les rôles de l'utilisateur sont chargés une fois par requête, et le
cache des réglages (`SettingsManager`) est mémorisé dans la requête **et** persistant. Rien à
gagner ici — c'était déjà en place.

**4. Les widgets : rien à corriger non plus.** Les dix-sept widgets sont **paresseux** — c'est le
défaut de Filament (`CanBeLazy::$isLazy = true`) et aucun ne l'annule. La mesure le confirme : une
requête pour le tableau de bord entier. Ils ne se déclenchent qu'une fois la page affichée, et
jamais sur un autre écran. Leurs caches courts (5 min, invalidés à chaque écriture au journal)
restent en place.

**5. Les caches de production** (`config:cache`, `route:cache`, `view:cache`) sont posés par le
déploiement et rien de ce lot ne les empêche.

### Le test qui empêche le retour en arrière

`CoutParEcranTest` n'affirme aucun chiffre — il varierait avec Filament, avec les filtres, avec le
moteur. Il affirme la seule chose qui ne bouge pas : **ouvrir une liste de trois fois plus de
lignes ne doit pas coûter une requête de plus.** Sept écrans y passent. Un N+1 réintroduit fait
tomber le test, quelle que soit la version de Filament.

> **Une part de la lenteur reste serveur.** Sur IRADA (saturn), les saturations MySQL ne se
> corrigent pas depuis le code. Ces optimisations aident partout, et profitent aussi à RUWAD (mars).

---

## §E — Les logos partenaires : plus grands, en couleur

Les logos étaient désaturés au repos (`filter: grayscale(1)`, `opacity: .65`) et ne reprenaient
leurs couleurs **qu'au survol**. Sur mobile, où il n'y a pas de survol, aucun partenaire n'était
donc jamais vu en couleur — l'inverse de ce qu'on veut d'une section « Ils nous font confiance ».

Plus aucun filtre, ni au repos ni ailleurs. La carte passe de **180×110** à **260×150** px
(200×120 sous 640 px de large), et `object-fit: contain` garde les proportions : un logo large ne
s'écrase pas dans un carré. Un test lit la feuille de style et refuse tout retour de `grayscale`,
`filter` ou `opacity` sur `.partner-card img`.

---

## §F — Un second compte Instagram

Nouveau réglage **`cms.social_instagram_2`**, facultatif, éditable dans **Contenu → Vitrine** à
côté des autres réseaux. `App\Support\Site\ReseauxSociaux::instagram()` renvoie les adresses
renseignées, dans l'ordre — zéro, une ou deux — et le pied de page, la page Contact et le
`sameAs` du JSON-LD s'y alignent. **Vide, le comportement est rigoureusement inchangé.**

Les **valeurs** restent du contenu, propre à chaque société, saisi dans l'admin. Le semis IRADA
(`SemerVitrineIrada`) voit ses valeurs par défaut mises à jour — `irada_venture` n'existe plus,
remplacé par `irada_incubateur` et `irada_prod` — mais **le déploiement ne rejoue pas ce semis** :
en production, c'est l'admin qui fait foi, et c'est là que l'exploitant saisira les deux adresses.

---

## Recette

Tout est vert. Ce qui est vérifié par un test :

| Exigence | Où |
|---|---|
| Un `<table>` traverse le sanitizer (save + affichage) et arrive sur la page | `HtmlSanitizerTest`, `EditeurRicheTest`, `VitrineV3120Test` |
| `<script>`, `on*`, `style` non whitelisté toujours retirés — y compris dans un tableau | `HtmlSanitizerTest` |
| `text-align` passe ; `position`, `background`, `display`, `opacity`, `javascript:` tombent | `HtmlSanitizerTest` |
| La barre propose tableau, titres, alignements, listes, code, filet, liens, images, historique | `EditeurRicheTest` |
| Aucun bouton dont le résultat serait découpé à l'enregistrement | `EditeurRicheTest` |
| `body_ar` porte `dir="rtl"` en édition ; le FR reste LTR | `EditeurRicheTest` |
| L'article en locale `ar` rend `dir="rtl"` ; en `fr`, `dir="ltr"` | `VitrineV3120Test` |
| Libellés d'outils en français, aucune clé brute à l'écran | `EditeurRicheTest` |
| Le mode SPA est actif, sans préchargement | `NavigationSpaTest` |
| Aucune exception SPA ne recouvre un écran du panneau | `NavigationSpaTest` |
| Chaque route de téléchargement est exclue du mode SPA | `NavigationSpaTest` |
| Le coût d'une liste ne croît pas avec le nombre de lignes (7 écrans) | `CoutParEcranTest` |
| Deux liens Instagram si le second est renseigné, un seul sinon | `VitrineV3120Test` |
| Aucun filtre de désaturation sur les logos partenaires ; cartes agrandies | `VitrineV3120Test` |
| Non-régression blog FR, vitrine, XSS, jours fériés, fonctionnement interne | suite complète |

---

## Déploiement

**Rien de particulier.** Pas de migration, pas de commande à lancer, aucun réglage à poser pour que
la version fonctionne.

Deux gestes après mise à jour, tous deux facultatifs :

1. **Contenu → Vitrine** : saisir le second Instagram (IRADA :
   `https://www.instagram.com/irada_prod/`, le premier devenant
   `https://www.instagram.com/irada_incubateur/`).
2. **Les tableaux des anciens articles sont à re-saisir** — ils n'existent plus en base (§A).

Un rechargement complet du navigateur (Cmd/Ctrl + Maj + R) après la mise à jour est recommandé la
première fois : le mode SPA change la façon dont les scripts du panneau sont chargés.
