capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)

Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-05-02 22:12:44 +02:00
parent 02ad0de258
commit b3417ad77b
31 changed files with 5370 additions and 12 deletions

View File

@@ -8,9 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `state.md` | State management, UI states, Zustand, listes paginées | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère |
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique |
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
| `tests.md` | Tests styles React Native, Jest node env | Tests de styles sans renderer JSX |
| `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |

View File

@@ -128,3 +128,166 @@ setItems((prev) => [...prev, created]); // pas de router.refresh()
```
**Pour les entités avec relations :** utiliser un helper `findItemById(tenantId, id)` appelé après la mutation pour retourner la forme complète avec les relations résolues.
---
<a id="pattern-app-input-outlined-material-dark"></a>
## Pattern : AppInput Outlined Material adapté thème dark
### Synthèse
- **Objectif** : homogénéiser tous les inputs de l'app avec un design "outlined Material" adapté à un thème dark custom (label flottant, encoche opaque calée sur la card parente).
- **Contexte** : projet Vue/React avec un thème dark où les inputs natifs cassent le design system (couleur d'encoche, débordement Safari iOS, fond input transparent).
- **Quand l'utiliser** : design system app-wide où tous les inputs doivent suivre la même grammaire visuelle.
- **Quand l'éviter** : design strictement neutre (inputs natifs OS-style) ou framework UI déjà opinioné (Vuetify, Material UI).
### Analyse
- **Avantages** :
- design cohérent sur tous les formulaires (login, profil, modales)
- encoche calée sur la **card parente**, pas sur le bg global → fusion visuelle propre
- `appearance: none` + `min-width: 0` + `min-height: 48px` corrigent les inputs date Safari iOS
- **Limites / vigilance** :
- les pièges (couleur d'encoche, débordement, fond transparent) sont non-évidents et coûtent du temps à chaque itération si non documentés
- `inheritAttrs: false` + séparation manuelle class/style obligatoires pour permettre le layout grid externe sans fuite des attrs HTML
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 Composition API — RL799_V2
### Composant central
```vue
<script setup lang="ts">
defineOptions({ inheritAttrs: false });
const props = defineProps<{
modelValue?: string | number;
label: string; // requis — pas d'input sans label
type?: string;
staticLabel?: boolean; // label toujours haut (utile pour selects)
}>();
const attrs = useAttrs();
// Séparation manuelle class/style — permet layout grid externe (--col-2)
// sans que les attrs HTML fuitent sur le wrapper
const wrapperClass = computed(() => attrs.class);
const wrapperStyle = computed(() => attrs.style);
const inputAttrs = computed(() => {
const { class: _c, style: _s, ...rest } = attrs;
return rest;
});
</script>
<template>
<label :class="['app-input', wrapperClass]" :style="wrapperStyle">
<input
:value="modelValue"
placeholder=" "
v-bind="inputAttrs"
class="app-input__control"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="app-input__label">{{ label }}</span>
</label>
</template>
<style scoped>
.app-input__control {
width: 100%;
min-width: 0; /* CRITIQUE Safari iOS — sinon débordement */
min-height: 48px; /* homogénéise date/datetime-local */
padding: 12px;
background: transparent; /* fusion totale avec la card parente */
border: 1px solid var(--color-border-base);
border-radius: 6px;
appearance: none; /* CRITIQUE iOS pour datetime-local */
-webkit-appearance: none;
}
/* Label flottant quand input rempli OU focus */
.app-input__control:focus + .app-input__label,
.app-input__control:not(:placeholder-shown) + .app-input__label {
top: 0;
font-size: 0.75rem;
/* Encoche opaque calée sur la CARD parente, pas le bg global */
background: var(--app-input-notch-bg, var(--color-surface-raised));
}
</style>
```
### Pièges documentés
1. **Encoche du mauvais bg** : utiliser `--color-bg-elevated` (canvas global) au lieu de `--color-surface-raised` (card) → patch coloré visible. Toujours caler sur la couleur de la card parente. Si l'input vit dans un contexte différent (modale, header), exposer `--app-input-notch-bg` en custom property pour override.
2. **Bordure traverse le label** : si le label flottant n'a pas de `background` opaque, la bordure passe derrière le texte. L'encoche n'est pas optionnelle.
3. **Fond transparent obligatoire** : si le fond de l'input est différent de la card, l'encoche révèle un patch. Solution : `background: transparent` → fusion totale.
4. **`placeholder=" "` imposé** : le sélecteur `:placeholder-shown` ne marche que si un placeholder existe. Un espace suffit, n'apparaît pas visuellement.
5. **`inheritAttrs: false` + séparation class/style** : sans ça, un parent qui pose `class="--col-2"` voit cette classe ET tous les attrs HTML atterrir sur le wrapper.
### Variantes
- **`AppSelect`** : même base, label toujours flottant haut. Chevron SVG `stroke="currentColor"` 1.5px.
- **`AppTextarea`** : label toujours flottant haut, pas de slot trailing.
---
<a id="pattern-fusion-dry-composants-jumeaux"></a>
## Pattern : Fusion DRY de composants jumeaux par prop discriminante
### Synthèse
- **Objectif** : factoriser deux composants partageant la même UI à 80 %+ avec des contextes d'appel différents, sans extraire un 3ᵉ composant `Body` qui multiplie les fichiers et les indirections.
- **Contexte** : ex `ConvocationResponseCard` (autonome, charge ses données) + `ConvocationResponseForm` (reçoit la convocation du parent) — même UI, deux modes de consommation.
- **Quand l'utiliser** : diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**.
- **Quand l'éviter** :
- cycles de vie ou stores différents (un consomme un store Pinia, l'autre est purement contrôlé) — le `computed` discriminant pollue tout le composant
- UI diverge à > 30 % (sections présentes dans l'un, absentes dans l'autre)
### Analyse
- **Avantages** :
- une seule source de vérité pour markup et styles
- tests structurels consolidés sur un seul fichier
- évolution UX synchronisée par construction
- **Limites / vigilance** :
- **anti-pattern à refuser** : extraire un 3ᵉ composant `Body` partagé entre les deux composants originaux. Multiplie les fichiers, ajoute une couche d'indirection (props drilling, events bubbling) sans réduire la complexité réelle
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 — RL799_V2 (530 lignes dupliquées → 310 lignes uniques)
### Implémentation
```vue
<script setup lang="ts">
const props = defineProps<{
// Mode A : Card autonome (consomme un dataset complet)
data?: ProchaineTenueData;
// Mode B : Form simple (reçoit juste l'objet à éditer)
convocation?: ProchaineTenueConvocation;
}>();
const isCardMode = computed(() => props.data !== undefined);
// Convocation dérivée selon le mode → le reste du composant manipule
// uniquement `convocation.value`, sans se soucier du mode
const convocation = computed(() => {
if (isCardMode.value) {
const primary = props.data!.primaryGrade;
return props.data!.gradeInfos[primary]?.convocation;
}
return props.convocation;
});
// Émission typée en union — chaque mode émet son type approprié
const emit = defineEmits<{
updated: [ProchaineTenueData | ConvocationResponseData];
}>();
</script>
```
### Critère de décision
Si le diff entre les deux composants tient en une dizaine de `computed`/`v-if` discriminés sur **un seul flag de mode**, fusionner. Si ça déborde, garder distincts.

View File

@@ -0,0 +1,377 @@
# Frontend — Patterns : Général
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
---
<a id="pattern-focus-visible-interne-overflow-clip"></a>
## Pattern : Focus visible interne pour champs sous overflow clip
### Synthèse
- **Objectif** : préserver le focus visible des champs `<input>` / `<select>` quand l'app applique `overflow-x: clip` sur ses conteneurs (PageShell, panel, layout) — le focus outline natif est dessiné **hors** de la box du champ et se fait clipper.
- **Contexte** : app mobile-first où `overflow-x: clip` (ou `hidden`) est fréquent sur les conteneurs pour empêcher le débordement horizontal.
- **Quand l'utiliser** : tout projet avec une chaîne de parents en `overflow: hidden|clip` autour des champs de saisie.
- **Quand l'éviter** : si la chaîne de parents n'a pas d'`overflow: hidden|clip` — l'outline natif suffit.
### Analyse
- **Avantages** :
- aucun pixel ne sort de la box, donc aucun clip possible
- `box-shadow: inset` + `border-color` est portable Chrome/Firefox/Safari
- **Limites / vigilance** :
- `outline-offset: -2px` marche sur Chrome mais le rendu varie : Firefox/Safari peuvent ignorer selon la combinaison `outline-style`
### Validation
- Validé le : 27-04-2026
- Contexte technique : CSS / mobile-first — RL799_V2
### Pattern correctif (par composant)
```css
.my-input:focus-visible,
.my-select:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
```
### Application globale au thème (recommandée pour mobile-first)
Plutôt que de répéter le pattern dans chaque composant, le pousser dans le fichier de thème global. Tous les inputs/selects/textareas en bénéficient automatiquement.
```css
/* Dans theme/<theme>.css ou globals.css, hors :root */
input[type='text']:focus-visible,
input[type='search']:focus-visible,
input[type='email']:focus-visible,
input[type='tel']:focus-visible,
input[type='url']:focus-visible,
input[type='number']:focus-visible,
input[type='password']:focus-visible,
input[type='date']:focus-visible,
input[type='datetime-local']:focus-visible,
input[type='time']:focus-visible,
input[type='month']:focus-visible,
input[type='week']:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
```
### Pourquoi sélecteurs d'attribut explicites et pas `input:focus-visible`
`input` couvre aussi `type='checkbox'`, `type='radio'`, `type='file'`, `type='range'`, `type='color'` qui ont leur propre design natif. La règle pourrait casser leur rendu (carrés or autour des cases à cocher, etc.). On liste explicitement les types texte/saisie.
### Conflits avec composants ayant leur propre `:focus`
Un composant qui a déjà `.my-input:focus { ... }` (sans `-visible`) gardera la priorité par spécificité (classe > tag). La règle globale ne joue que pour les composants qui n'ont rien défini → sûr à introduire en mid-projet.
---
<a id="pattern-restyle-input-date-sans-wrapper-js"></a>
## Pattern : Restyle global de `<input type="date">` sans wrapper JS
### Synthèse
- **Objectif** : aligner les inputs date HTML5 sur l'identité visuelle du thème via une règle CSS globale, sans wrapper JS custom.
- **Contexte** : projet avec un thème dark/light custom où les inputs date natifs (icône calendrier blanche, placeholder gris OS, popover light par défaut) cassent le design.
- **Quand l'utiliser** : 80 % des cas (audit log, formulaires admin, profils) où la datepicker custom serait du sur-engineering.
- **Quand l'éviter** :
- validation custom synchrone (range, dates blackout, format spécifique)
- format d'affichage différent (DD/MM vs MM/DD vs ISO)
- intégration profonde dans un design system
### Analyse
- **Avantages** :
- `color-scheme` + `accent-color` donnent un popover natif cohérent gratuitement
- filtre SVG pour teinter l'icône calendrier
- zéro JavaScript, zéro bundle additionnel
- **Limites / vigilance** :
- **Firefox** : `accent-color` respecté, mais pas de pseudo-element pour customiser le placeholder
- **Safari iOS** : popover sheet OS, peu personnalisable. Acceptable car cohérent avec le reste des UI iOS natives
- filtre `filter()` à calibrer pour matcher la couleur du thème — chaque thème nécessite son tuning
### Validation
- Validé le : 27-04-2026
- Contexte technique : CSS / inputs date HTML5 — RL799_V2
### Pattern minimal
```css
/* Toutes les variantes de pickers HTML5 */
input[type='date'],
input[type='datetime-local'],
input[type='time'],
input[type='month'],
input[type='week'] {
color-scheme: dark; /* ou 'light' selon le thème de l'app */
accent-color: var(--color-accent);
}
/* Place-holder OS (jj/mm/aaaa) — couleur soft */
input[type='date']::-webkit-datetime-edit-fields-wrapper,
input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper {
color: var(--color-text-soft);
}
/* Une fois saisie, couleur normale */
input[type='date']:not(:placeholder-shown)::-webkit-datetime-edit-fields-wrapper {
color: var(--color-text-primary);
}
/* Icône calendrier teintée (filtre SVG noir → couleur d'accent soft) */
input[type='date']::-webkit-calendar-picker-indicator,
input[type='datetime-local']::-webkit-calendar-picker-indicator,
input[type='time']::-webkit-calendar-picker-indicator,
input[type='month']::-webkit-calendar-picker-indicator,
input[type='week']::-webkit-calendar-picker-indicator {
/* Calibrer pour matcher la couleur du thème */
filter: invert(70%) sepia(40%) saturate(450%) hue-rotate(5deg) brightness(95%);
cursor: pointer;
opacity: 0.85;
transition: opacity 0.15s ease;
}
```
### Anti-patterns
- Construire un mini calendrier JS custom pour gagner 5 % d'esthétique → effort énorme (a11y clavier, focus management, mobile, edge cases), bénéfice marginal
- Hardcoder les couleurs `filter()` au lieu d'utiliser des tokens du thème
- Restyler sans `color-scheme: dark/light` → le popover natif reste en mode clair sur thème sombre
---
<a id="pattern-ui-journaux-audit-logs"></a>
## Pattern : UI pour journaux / audit logs / timelines
### Synthèse
- **Objectif** : passer d'un rendu naïf en cards uniformes "acteur · code · cible (uuid) · metadata" à une lecture rapide pour l'admin en surveillance, sans toucher au backend.
- **Contexte** : tout projet finit par afficher un journal d'événements (audit, activité, historique, timeline) avec metadata variable.
- **Quand l'utiliser** : journal avec ≥ 10 types d'actions et besoin de scanner rapidement.
- **Quand l'éviter** : log technique brut destiné aux devs (un `<pre>` peut suffire).
### Analyse
- **Avantages** :
- 5 patterns combinables qui améliorent radicalement la scanabilité
- aucun changement backend (le DTO reste plat)
- **Limites / vigilance** :
- reset de l'état d'expansion à chaque rechargement (l'expansion est éphémère, pas une préférence durable)
### Validation
- Validé le : 27-04-2026
- Contexte technique : Vue 3 / CSS — RL799_V2 (Journal d'audit admin, 45+ types d'actions)
### 1. `<optgroup>` dérivé du préfixe label
Quand l'API retourne un catalogue d'actions avec convention `Catégorie — Libellé` (ex : `Soirée — annulation`, `Tenue — création`), dériver les groupes côté front au lieu de modifier le DTO.
```typescript
const groupsFromLabels = computed(() => {
const groups = new Map<string, Entry[]>();
for (const opt of catalog.value) {
const sep = opt.label.indexOf(' — ');
const category = sep > 0 ? opt.label.slice(0, sep) : 'Divers';
groups.set(category, [...(groups.get(category) ?? []), opt]);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b, 'fr', { sensitivity: 'base' }));
});
```
```html
<select>
<option value="">Toutes les actions</option>
<optgroup v-for="[cat, opts] in groupsFromLabels" :label="cat" :key="cat">
<option v-for="o in opts" :value="o.value" :key="o.value">{{ o.label }}</option>
</optgroup>
</select>
```
Bénéfice : 1 seul clic pour filtrer (vs cascade 2 selects), accessible natif, zéro modif backend.
### 2. Code couleur sémantique par catégorie
Barre de couleur de 3 px à gauche de chaque card de la liste, mappée sur la catégorie de l'événement. Transforme un mur de cards uniformes en lecture instantanée.
```css
.log-item {
border-left: 3px solid var(--log-cat-color, var(--color-border));
}
.log-item--cat-soiree { --log-cat-color: var(--color-accent-primary); }
.log-item--cat-rgpd { --log-cat-color: var(--color-accent-danger); }
```
Pourquoi pas le fond complet : trop bruyant, perd la sobriété d'un journal admin. La barre latérale signale sans crier.
### 3. UUIDs rétrogradés en monospace soft
Les UUIDs / IDs techniques affichés en plein texte cassent la lecture humaine. Les détecter via regex et les rendre en font monospace + couleur soft + taille réduite, sans les masquer (utiles pour forensics).
```typescript
const UUID_RE = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
```
```css
.uuid {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78em;
color: var(--color-text-soft);
word-break: break-all;
}
```
### 4. Date relative + absolue en tooltip
Pour la lecture humaine, "il y a 5 min" / "hier" / "il y a 3 j" bat toujours "27 avril 2026 à 08:37". La date absolue reste accessible en `title` du `<time>` pour les forensics.
```typescript
const formatRelative = (iso: string) => {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'à l\'instant';
if (diff < 3600) return `il y a ${Math.round(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.round(diff / 3600)} h`;
if (diff < 172800) return 'hier';
if (diff < 604800) return `il y a ${Math.round(diff / 86400)} j`;
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
});
};
```
### 5. Détails techniques repliables
La metadata détaillée (clés/valeurs verboses, IDs internes, raisons null) sert à l'investigation, pas à la lecture courante. La masquer derrière un `Voir les détails / Masquer les détails`, et reset l'état d'expansion à chaque rechargement.
```typescript
const expanded = ref<Set<string>>(new Set());
const toggle = (id: string) => {
const next = new Set(expanded.value);
next.has(id) ? next.delete(id) : next.add(id);
expanded.value = next;
};
// Reset après chaque load
const loadList = async () => {
/* … */
expanded.value = new Set();
};
```
Le bouton "voir détails" n'apparaît **que si** la card a effectivement de la metadata. Un toggle vide pollue la grille visuelle.
### Anti-patterns à éviter
- Cards uniformes en couleur/border quel que soit le type d'événement → tue la scanabilité
- Date absolue toujours visible (`27 avril 2026 à 08:37`) sur des cards serrées → bruit cognitif inutile
- UUIDs en plein texte sans rétrogradation visuelle
- Cascade 2 selects quand un `<optgroup>` natif suffit
- Bouton "voir détails" affiché même sans détails à voir
- Persister l'expansion entre navigations / paginations → état orphelin
---
<a id="pattern-structuration-pages-admin"></a>
## Pattern : Structuration de pages admin (eyebrows + grille filtres + variante danger)
### Synthèse
- **Objectif** : poser une grammaire visuelle commune sur les écrans admin (filtres + liste/form + sections multiples) sans framework lourd.
- **Contexte** : module Admin avec plusieurs panels (Audit, Utilisateurs, Corbeille, etc.) qui partagent la même structure.
- **Quand l'utiliser** : ≥ 3 panels admin avec structure similaire.
- **Quand l'éviter** : page admin unique sans cohérence inter-panels à maintenir.
### Validation
- Validé le : 27-04-2026
- Contexte technique : Vue 3 / CSS — RL799_V2 (5 panels admin)
### 1. Eyebrows de section
Au lieu de titres H2/H3 trop forts, utiliser des "eyebrows" : mini-labels uppercase, letter-spacing élargi, taille `caption`, couleur d'accent.
```css
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: var(--font-size-caption);
color: var(--color-accent);
margin: 0;
}
```
```html
<p class="eyebrow">Filtres</p>
<div class="my-filters"><!----></div>
<p class="eyebrow">Résultats <span class="eyebrow-count">· {{ total }} entrées</span></p>
<div class="my-list"><!----></div>
```
Le compteur (`· N entrées`) est en `font-weight: normal`, sans uppercase, en `color-text-secondary` — typographie en cascade.
Bénéfice : structure visuelle sans concurrencer un éventuel titre de page. Cohérence inter-écrans dans tout un module si l'eyebrow est utilisé partout.
Quand ne pas afficher l'eyebrow `Résultats` : sur loading / error / empty state. Le `StateBlock` (ou équivalent) prend le relais.
### 2. Grille de filtres hiérarchique
Quand 3+ filtres dont l'un est dominant (typiquement une recherche), au lieu d'un flex-wrap chaotique, utiliser une grille avec le filtre primaire en pleine largeur :
```html
<div class="filters">
<label class="filters__label filters__label--full">Recherche
<input type="search">
</label>
<label class="filters__label">Statut <select><!----></select></label>
<label class="filters__label">Rôle <select><!----></select></label>
</div>
```
```css
.filters {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
gap: var(--space-2);
}
.filters__label--full {
grid-column: 1 / -1;
}
```
### 3. Variante `danger` pour actions destructives
Sur un écran qui mélange actions constructives et destructives (ex : saisie initiale + bypass admin) :
```css
.btn--danger {
border-color: color-mix(in srgb, var(--color-danger) 48%, transparent);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.card--danger {
border-left: 3px solid var(--color-danger);
}
```
`color-mix` produit un rouge **soft** (12-20 % saturation), pas un flash rouge violent.
**Anti-pattern** : `background: red` / `color: red` directs ou hex hardcodés (`#ef4444`). Toujours via tokens du thème.
### Anti-patterns à éviter
- Filtres en `flex-wrap` qui produisent 3 lignes asymétriques selon le viewport
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
- Action destructive en variant primary or → danger explicite

View File

@@ -198,3 +198,82 @@ Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de n
- Restaurer le focus au trigger à la fermeture.
---
<a id="pattern-factorisation-page-meta-mode"></a>
## Pattern : Factorisation page mode dynamique via `route.meta.mode` typé
### Synthèse
- **Objectif** : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à `route.name` (fragile) ou query string (manipulable).
- **Contexte** : projet Vue avec deux routes (ex : `/invitation` + `/reset-password`) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
- **Quand l'utiliser** : factorisation justifiée par un partage > 90 % de logique entre routes.
- **Quand l'éviter** : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.
### Analyse
- **Avantages** :
- explicite, déclaratif, type-checkable
- augmenter `RouteMeta` dans `vue-router` pour qu'un mode manquant produise une erreur build
- **Limites / vigilance** :
- wording centralisé en un seul `computed` — pas de `v-if` éparpillés dans le template
- endpoint dynamique en un seul `if/else` dans le handler
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vue 3 / vue-router — RL799_V2
### Implémentation
```typescript
// router/index.ts
{
path: '/invitation',
name: 'invitation',
component: ResetPasswordPage,
meta: { requiresGuest: true, mode: 'invitation' as const },
},
{
path: '/reset-password',
name: 'reset-password',
component: ResetPasswordPage,
meta: { requiresGuest: true, mode: 'reset' as const },
},
```
```typescript
// ResetPasswordPage.vue
const pageMode = computed<PageMode>(() => {
const meta = route.meta as { mode?: PageMode } | undefined;
return meta?.mode === 'invitation' ? 'invitation' : 'reset';
});
const wording = computed(() => {
if (pageMode.value === 'invitation') {
return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
}
return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
});
const handleSubmit = async () => {
if (pageMode.value === 'invitation') {
await consumeInvitationToken(token.value, newPassword.value);
} else {
await consumeResetToken(token.value, newPassword.value);
}
// Post-consume uniforme : redirige /login pour les deux modes
};
```
### Règles d'or
- `meta.mode` typé en literal union (`'invitation' | 'reset'`)
- A11y obligatoire : autofocus sur le champ password au mount post-validation, `<label for>` associés, `<AppMessage variant="error">` avec `role="alert"`, `<AppMessage variant="success">` avec `aria-live="polite"`
- Test obligatoire : monter la page avec chaque `meta.mode` et vérifier que le wording correspond
### Anti-patterns
- Détecter le mode via `route.name` (fragile, couplage implicite, signal d'intention faible)
- Détecter via query string (pollue URL, manipulable)
---

View File

@@ -208,3 +208,270 @@ Sans état transitoire formel, les guards lisent des valeurs incomplètes et dé
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
- Guards : `await hydrate()` avant toute décision d'accès.
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`.
---
<a id="pattern-refactor-monolithe-vue-sous-lots"></a>
## Pattern : Refactor monolithe Vue — sous-lots Go/No-Go + ordre topologique
### Synthèse
- **Objectif** : découper un composant Vue monolithique (> 1500 lignes script ou > 2000 lignes total) en composables + sous-composants livrés en commits successifs validés un à un.
- **Contexte** : page Vue avec plusieurs responsabilités métier mêlées, peu ou pas de `describe`, sans sous-découpage interne.
- **Quand l'utiliser** : fichier > 1500 lignes script, fenêtre de calme sans PR concurrente prévue, tests E2E robustes en place.
- **Quand l'éviter** : page < 1000 lignes, pas de tests E2E pour servir de filet, ou planning serré sans Go/No-Go possible entre commits.
### Analyse
- **Avantages** :
- aucune régression à chaque sous-lot validé indépendamment
- helpers réutilisables émergent naturellement
- reporter vitest / Playwright groupe les échecs par responsabilité
- **Limites / vigilance** :
- les `data-testid` E2E doivent être **copiés-collés exactement** dans les composants enfants (sinon les E2E rotent silencieusement)
- les bindings template doivent rester alignés avec les noms destructurés du composable
- le typecheck `tsc --noEmit` ne suffit pas — utiliser `vue-tsc` (cf. `frontend/risques/state.md` risque-templates-vue-references-orphelines)
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (4 pages refactorées, 17 commits, 644/644 tests verts)
### Stratégie en 3 étapes
1. **Audit complet préalable** : sections du template avec plages de lignes + rôle métier + `v-if` clé, blocs script regroupés par responsabilité avec évaluation `autonome` vs `couplé`, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS).
2. **Plan en sous-lots ordonnés par risque croissant** :
- L1 : composables purement autonomes (zéro dépendance interne)
- L2 : composants enfants auto-contenus (modales)
- L3 : composables avec couplage modéré (cache + watchers)
- L4 : composables avec couplage métier subtil (cascade, propagation)
- L5 : composant enfant complexe (D&D, drag-handle conditionnel)
3. **Go/No-Go explicite entre chaque lot** : 1 commit thématique par lot avec validation typecheck + tests + diff montré au pair avant push.
### Ordre topologique des dépendances dans le script post-refactor
Quand plusieurs composables se consomment mutuellement, respecter strictement l'ordre topologique (la déclaration de la donnée doit précéder son usage, sous peine de TDZ) :
```typescript
// 1. Data centrale de la page
const currentSoiree = ref<SoireeData | null>(null);
const error = ref('');
// 2. Composables qui ne consomment que la data centrale
const { lifecycle, isLiveView } = useSoireeLifecycle(currentSoiree, allTenuesCancelled);
// 3. Composables qui produisent des refs consommées par d'autres
const { responsesData } = useResponseTracking(currentSoiree, error);
// 4. Composables qui consomment les refs produites
const { pastActiveGrade } = useGradeSelection(soireeTenues, responsesData);
```
### Anti-patterns
- ❌ Grouper plusieurs préoccupations dans un même composable juste parce qu'elles sont voisines dans le script
- ❌ Sortir un composable qui consomme directement `process.cwd()` ou un store global sans le passer en argument (couplage caché)
- ❌ Extraire le CSS scoped vers le composant enfant **avant** de vérifier que toutes les classes y sont effectivement utilisées (certaines classes vivent en CSS global)
- ❌ Sauter le grep des références orphelines avant de supprimer un bloc
### Checklist
- [ ] `data-testid` copiés-collés exactement dans les composants enfants
- [ ] Bindings template alignés avec les noms destructurés
- [ ] Props/events des composants enfants alignés avec les usages
- [ ] `vue-tsc` (pas `tsc`) en vérification typecheck
- [ ] QA visuel obligatoire post-refactor (mount réel en browser)
---
<a id="pattern-convention-pages-module-scope"></a>
## Pattern : Convention `pages/<module>/{composables,components,utils,__tests__}/`
### Synthèse
- **Objectif** : structurer une app Vue qui dépasse 20 pages avec plusieurs domaines métier, en regroupant la logique extraite par module.
- **Contexte** : app Vue avec routing par page et logique extraite (composables, sous-composants, tests).
- **Quand l'utiliser** : page > 1000 lignes envisage le scope, > 1500 lignes le crée systématiquement.
- **Quand l'éviter** : page < 500 lignes sans logique extraite — laisser au niveau racine.
### Analyse
- **Avantages** :
- un module métier = un sous-dossier, navigation simplifiée
- l'alias `@/pages/<module>/<X>` rend les fichiers résilients aux déplacements
- les tests d'un module vivent avec lui (pas dans un dossier global qui mélange tout)
- **Limites / vigilance** :
- les tests scopés calculent leur `root` avec **4 niveaux** de remontée (`'../../../..'`), pas 3 — source d'erreur fréquente lors d'un déplacement
- le dossier `pages/__tests__/` global reste réservé aux tests transverses + tests des pages legacy
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Vite / Vitest — RL799_V2 (5 modules scopés, 35 fichiers extraits)
### Structure type
```
pages/<module>/
├── <Module>Page.vue # page principale (carcasse + template)
├── <Autre>Page.vue # autres pages du même module si existent
├── composables/ # logique métier extraite
│ ├── use<X>.ts
│ └── use<Y>.ts
├── components/ # sous-composants .vue scopés au module
├── utils/ # helpers purs (formatters, defensive wrappers)
├── styles.css # CSS partagé non-scoped (cf. pattern dédié)
└── __tests__/ # tests scopés au module
```
### Ce qui reste dans `pages/__tests__/` global
Trois cas légitimes uniquement :
1. **Tests transverses** qui couvrent plusieurs modules (`lifecycleUnification.test.mjs`)
2. **Tests d'infrastructure** non rattachés à un module métier (`OfflineIntegration.test.mjs` pour le SW PWA)
3. **Tests des pages encore à plat** (legacy non-encore scopées) — `LoginPage.test.mjs` reste à `pages/__tests__/` tant que `LoginPage.vue` est à `pages/`
### Calcul de `root` dans les tests scopés
```typescript
const here = dirname(fileURLToPath(import.meta.url));
// 4 niveaux : __tests__ → <module> → pages → src → frontend
const root = resolve(here, '../../../..');
```
Si le test crashe avec `ENOENT: no such file … '<frontend>/src/src/pages/...'`, c'est que le `root` n'a pas été ajusté.
### Imports : alias `@/` plutôt que relatif
Toujours utiliser l'alias `@/pages/<module>/<X>` plutôt que `./X` ou `../X`. Bénéfice : déplacer un fichier ne casse pas ses imports internes (juste les imports depuis l'extérieur, qu'on met à jour via sed bulk).
### Critère extraction composable vs composant
| Cas | Préférer composable | Préférer composant |
|-----|---------------------|---------------------|
| Logique pure (state + actions, pas de markup) | ✓ | |
| Modale auto-contenue, > 30 lignes template | | ✓ |
| Form > 50 lignes avec validation | | ✓ |
| Plusieurs refs/computeds entrelacés | ✓ | |
| CSS spécifique > 50 lignes | | ✓ (avec styles.css si partagé) |
Préférence générale : composables script-only quand possible (risque CSS nul, plus simple à tester).
---
<a id="pattern-styles-css-module-non-scoped"></a>
## Pattern : `styles.css` partagé non-scoped pour modules avec composants extraits
### Synthèse
- **Objectif** : partager des classes CSS entre la page parente et ses sous-composants extraits sans dupliquer le CSS dans chaque `<style scoped>` enfant.
- **Contexte** : refactor d'une page Vue monolithique en N composants enfants (modales, forms) qui partagent des classes communes.
- **Quand l'utiliser** : ≥ 2 composants enfants partagent des classes (modales, forms, badges du module).
- **Quand l'éviter** : composants enfants strictement indépendants, ou règle CSS utilisée nulle part ailleurs.
### Analyse
- **Avantages** :
- une seule définition par classe partagée
- dérive impossible (un changement profite à tous les composants du module)
- **Limites / vigilance** :
- tentation de tout sortir en non-scoped "au cas où" → refusé, pollue le namespace global
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vue 3 / scoped CSS — RL799_V2 (152 lignes CSS migrées dans `pages/venerable/styles.css`)
### Pattern
```vue
<!-- pages/<module>/<Module>Page.vue -->
<template>
<!-- -->
</template>
<!-- Styles partagés du module : importés en non-scoped pour atteindre
les composants enfants extraits (modales/forms) qui ne peuvent
pas hériter d'un `<style scoped>` parent -->
<style src="@/pages/<module>/styles.css"></style>
<style scoped>
/* Classes spécifiques au layout de la page parente uniquement */
</style>
```
Les composants enfants utilisent les classes sans rien importer :
```vue
<template>
<div class="vm-modal"> <!-- vient de styles.css -->
<h2 class="vm-modal__title"></h2>
<button class="primary"></button> <!-- vient du CSS global -->
</div>
</template>
```
### Quoi mettre dans `styles.css`
- Classes utilisées par > 1 composant enfant du module
- Classes utilisées par la page ET par un composant enfant
- Conventions/tokens visuels propres au module
### Quoi NE PAS y mettre
- Classes utilisées uniquement dans la page parente → `<style scoped>` de la page
- Classes utilisées uniquement dans un seul composant enfant → `<style scoped>` du composant
- Classes globales (`primary`, `ghost`, etc.) qui vivent déjà dans `style.css` global
---
<a id="pattern-annuaire-client-side-ttl-refresh"></a>
## Pattern : Annuaire client-side avec TTL + refresh + `lastFetchedAt`
### Synthèse
- **Objectif** : permettre un load complet en mémoire au mount avec filtre client (annuaire de N membres, catalog de produits) tout en évitant le refetch inutile entre ouvertures.
- **Contexte** : composable Vue qui charge un dataset moyen (< quelques milliers d'items) consommé par filtres client.
- **Quand l'utiliser** : modale ou page consultée fréquemment qui n'a pas besoin de pagination serveur.
- **Quand l'éviter** : dataset > 10k items (utiliser pagination/keyset), ou besoin de temps réel cross-clients (basculer SSE/WS).
### Analyse
- **Avantages** :
- cache TTL configurable (par défaut 5 min) → pas de refetch entre ouvertures
- `refresh()` méthode publique pour forcer après création/update
- `lastFetchedAt` exposé pour debug / UI ("annuaire mis à jour il y a X")
- **Limites / vigilance** :
- si user B crée une entrée pendant que user A a la modale ouverte, A ne voit pas l'entrée tant qu'il ne ferme/rouvre pas ou clique refresh
- TTL atténue mais ne résout pas — pour temps réel, basculer SSE/WS
### Validation
- Validé le : 01-05-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2
### Implémentation
```typescript
const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
const directory = ref<Entry[]>([]);
const lastFetchedAt = ref<Date | null>(null);
const isCacheStale = (): boolean => {
if (!lastFetchedAt.value) return true;
return Date.now() - lastFetchedAt.value.getTime() > ttlMs;
};
const loadDirectory = async (params: { force?: boolean } = {}) => {
if (!params.force && !isCacheStale() && directory.value.length > 0) return;
const data = await api.fetchDirectory();
directory.value = data;
lastFetchedAt.value = new Date();
};
const refresh = () => loadDirectory({ force: true });
return { directory, lastFetchedAt, loadDirectory, refresh };
};
```

View File

@@ -153,3 +153,368 @@ Les classes et la structure DOM changent fréquemment sans régression fonctionn
- Préférer `data-testid` paramétré par identifiant métier stable.
- Éviter `locator.first()` si l'ordre peut muter.
- Isoler les tests mutateurs avec stratégie de remise à l'état (snapshot/restore).
---
<a id="pattern-tests-statiques-vitest-smoke-checks"></a>
## Pattern : Tests statiques `readFileSync` annotés comme smoke checks
### Synthèse
- **Objectif** : utiliser les tests `readFileSync + includes()` pour leur force réelle (présence de signaux structurels) tout en évitant la fausse confiance qu'ils donnent sur le comportement runtime.
- **Contexte** : projets Vue où le pattern `readFileSync` est largement utilisé pour vérifier la présence de testid, rôle ARIA, appel de service.
- **Quand l'utiliser** : assertions sur **présence** d'un pattern (testid, import, regex interdite). À doubler par un mount pour les comportements interactifs.
- **Quand l'éviter** : composants interactifs (focus trap, submit delegation, navigation clavier) — utiliser un test mount.
### Analyse
- **Avantages** :
- rapide à écrire et lire, résistant aux refactors de structure
- bon pour vérifier la présence de comportements clés sans monter
- **Limites / vigilance** :
- **ne valide PAS** que le focus trap cycle correctement, que le composant émet les bons événements, que la navigation clavier Escape ferme la modal
- **ne distingue pas** si une référence est dans le `<script>` ou le `<template>` (variable supprimée du script mais référencée dans le template → string-match passe, crash runtime)
### Validation
- Validé le : 21-04-2026
- Contexte technique : Vue 3 / Vitest — RL799_V2
### Annotation honnête recommandée
```typescript
// Note honnête : ces assertions sont des smoke checks structurels, pas une
// vérification runtime du focus trap. Le comportement réel (Tab cycle, Escape,
// focus restauré) se valide manuellement en QA ou via un test happy-dom dédié.
test('AppDialog : pattern previousActiveElement présent', () => {
expect(content.includes('previousActiveElement')).toBeTruthy();
});
```
### Règle
Si un comportement runtime est critique (focus trap, submit delegation, validation conditionnelle), écrire un test happy-dom séparé avec `@vue/test-utils`. Le smoke check ne dispense pas du test comportemental — il documente juste la présence d'un signal.
---
<a id="pattern-asserter-classe-css-modifier-vs-texte"></a>
## Pattern : Asserter classe CSS modifier vs texte (E2E robuste aux refactors visuels)
### Synthèse
- **Objectif** : rendre les tests E2E robustes aux refactors visuels (texte → icône SVG, label v1 → label v2, i18n future).
- **Contexte** : tests E2E qui valident un état rendu d'un composant.
- **Quand l'utiliser** : composant utilisant une convention BEM stricte avec modifier sémantique (`.grade-badge--apprenti`, `.status-badge--published`).
- **Quand l'éviter** : framework CSS-in-JS qui génère des classes hashées (`_grade_x4f3z`) — la classe modifier n'est pas accessible.
### Analyse
- **Avantages** :
- la classe modifier porte la **sémantique** et change beaucoup moins souvent que le texte
- typiquement liée à la prop ou au state, pas au rendu
- **Limites / vigilance** :
- ne remplace pas la validation visuelle (snapshot, screenshot) si le besoin est de protéger le rendu pixel-perfect
- reste pertinent : validation de contenu utilisateur (notes, prix) où le rendu textuel **est** la spec
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright / Vue 3 — RL799_V2
### Exemple
```typescript
// ❌ Fragile : casse au refactor texte → SVG ou label v1 → v2
await expect(badge).toHaveText('A∴');
// ✅ Robuste : casse uniquement si le grade change ou si BEM est rompu
await expect(badge).toHaveClass(/grade-badge--apprenti/);
```
### Cas combinable
- Regex tolérante sur le texte (`/convoqu/i`) pour les cas où le texte est la seule prise (badges sans classe modifier)
- Validation visuelle (snapshot) en complément quand le rendu pixel-perfect est protégé
---
<a id="pattern-cleanup-e2e-best-effort"></a>
## Pattern : Cleanup E2E best-effort (try/catch + timeout court)
### Synthèse
- **Objectif** : empêcher qu'un cleanup post-test (réinitialisation d'un statut, suppression d'une fixture) fasse échouer un scénario métier qui a déjà passé.
- **Contexte** : tests Playwright avec `finally { await cleanup() }` qui font des PATCH/DELETE/POST sur l'API.
- **Quand l'utiliser** : tout cleanup post-test non critique pour les tests suivants (parce qu'on a un seed ou un autre cleanup global).
- **Quand l'éviter** : si le cleanup est critique pour l'isolation (UNIQUE constraint au prochain test) — monter le timeout à 60 s plutôt que silencer.
### Analyse
- **Avantages** :
- une lenteur transitoire de l'API de cleanup (audit log, notif fanout, lock DB) ne fait plus passer le test du vert au rouge
- la dette d'état après échec de cleanup est mineure (le test suivant restaure souvent ou un seed le fera)
- **Limites / vigilance** :
- setup pré-test (`beforeEach`) : un setup qui échoue **doit** faire échouer le test, sinon on teste un état inconnu
### Validation
- Validé le : 25-04-2026
- Contexte technique : Playwright — RL799_V2
### Pattern
```typescript
async function restoreEntry(page: Page, snap: Snapshot): Promise<void> {
try {
await page.request.patch(`/api/.../entries/${snap.entryId}/status`, {
data: { status: snap.status },
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[spec-name] restoreEntry échoué (cleanup best-effort):', err);
}
}
test('mon scénario métier', async ({ page }) => {
const snap = await snapshotEntry(page, USER_ID);
try {
// … scénario métier …
} finally {
await restoreEntry(page, snap); // best-effort
}
});
```
---
<a id="pattern-helpers-sw-purs-extraits"></a>
## Pattern : Helpers Service Worker purs extraits pour tests Vitest
### Synthèse
- **Objectif** : permettre des tests unitaires Vitest fiables sur la logique du Service Worker, sans monter de stubs élaborés pour `self` / `registration` / `caches` / `clients`.
- **Contexte** : SW custom (mode `injectManifest` ou similaire) qui contient de la logique non triviale (parsing payload push, validation linkUrl anti open-redirect, sélection client focused).
- **Quand l'utiliser** : dès que le SW dépasse 50 lignes ou contient une regex / une condition métier.
- **Quand l'éviter** : SW trivial (juste un `precacheAndRoute(self.__WB_MANIFEST)`).
### Analyse
- **Avantages** :
- tests Vitest standards, pas de mock `self`/`registration`
- logique partageable avec d'autres parties du frontend (regex linkUrl partagée serveur ↔ SW ↔ store)
- le `sw.ts` final reste lisible : précache + routes + thin event handlers
- **Limites / vigilance** :
- les listeners `addEventListener('push'|...)` restent non testés unitairement → couverture par E2E + checklist DevTools manuelle
- bien isoler les imports : pas de dépendance Vue/Pinia dans `sw-helpers.ts` (le SW n'a pas d'accès au DOM)
- regex dupliquée serveur ↔ SW : extraire dans `@<scope>/shared` quand possible (cf. `pattern-regex-critique-partagee-anti-divergence` dans `backend/patterns/contracts.md`)
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vite + vite-plugin-pwa `injectManifest` / Vitest — RL799_V2
### Implémentation
```typescript
// apps/frontend/src/sw-helpers.ts (testable pure)
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const isInternalPath = (url: string): boolean =>
INTERNAL_PATH_REGEX.test(url);
export const parsePushPayload = (
eventData: { json: () => unknown } | null | undefined,
): SwPushPayload => {
try {
const raw = eventData?.json() as Record<string, unknown> | null;
if (!raw || typeof raw.title !== 'string') throw new Error('invalid');
return {
title: raw.title.slice(0, 80),
body: typeof raw.body === 'string' ? raw.body.slice(0, 160) : undefined,
linkUrl: typeof raw.linkUrl === 'string' && isInternalPath(raw.linkUrl) ? raw.linkUrl : '/',
};
} catch {
return { title: 'AppDefault', body: 'Notification', linkUrl: '/' };
}
};
```
```typescript
// apps/frontend/src/sw.ts (thin wrappers)
/// <reference lib="webworker" />
import { isInternalPath, parsePushPayload, selectFocusedClient } from './sw-helpers';
self.addEventListener('push', (event) => {
event.waitUntil((async () => {
const payload = parsePushPayload(event.data ?? undefined);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const focused = selectFocusedClient(clients);
if (focused) {
focused.postMessage({ type: 'push:received', payload });
return;
}
await self.registration.showNotification(payload.title, {
body: payload.body,
data: { url: payload.linkUrl },
});
})());
});
```
### Checklist
- [ ] Toute logique conditionnelle / regex / parsing extraite dans `sw-helpers.ts`
- [ ] `sw.ts` ne contient que precache, routes runtime, thin `addEventListener` qui appellent les helpers
- [ ] Helpers testés avec Vitest standard (pas de mock `self`)
- [ ] Listeners SW couverts par E2E + checklist manuelle DevTools post-build
---
<a id="pattern-test-mount-mock-composable-controllable"></a>
## Pattern : Test mount + mock composable contrôlable
### Synthèse
- **Objectif** : tester un composant Vue qui consomme un composable réactif en contrôlant l'état du composable depuis le test, sans monter de fixtures lourdes.
- **Contexte** : composant `<script setup>` qui appelle `const x = useFooBar()` et utilise `x.isReady.value` / `x.action()` dans le template.
- **Quand l'utiliser** : composant interactif avec branches conditionnelles dépendant du composable (5 états du footer push, modes admin vs user).
- **Quand l'éviter** : composant trivial sans logique conditionnelle (wrapper de markup).
### Analyse
- **Avantages** :
- couvre la cohérence script ↔ template (un attribut renommé dans le composable casse le test)
- test indépendant de la VRAIE implémentation (pas de duplication des stubs DOM `Notification`, `serviceWorker`, etc.)
- chaque scénario UI testable isolément en mutant l'état du mock entre tests
- **Limites / vigilance** :
- le mock doit retourner exactement la même SHAPE que le composable réel — un test peut passer alors que le composable a évolué et casse en runtime
- mitigation : déclarer un type `ReturnType<typeof useFooBar>` ou `export type UseFooBar` exporté par le composable, et typer le mock dessus
- `vi.mock` est hoisté → définir l'état du mock dans `beforeEach`, pas en module-level
### Validation
- Validé le : 28-04-2026
- Contexte technique : Vitest + @vue/test-utils + Vue 3 — RL799_V2
### Implémentation
```typescript
// composable
export type UsePushNotifications = {
isSupported: ComputedRef<boolean>;
isSubscribed: Ref<boolean>;
subscribe: () => Promise<void>;
};
export const usePushNotifications = (): UsePushNotifications => { /* … */ };
```
```typescript
// Test du composant
import { computed, ref } from 'vue';
import type { UsePushNotifications } from '@/composables/usePushNotifications';
let mockState: { isSupported: boolean; isSubscribed: boolean };
const subscribeSpy = vi.fn();
vi.mock('@/composables/usePushNotifications', () => ({
usePushNotifications: (): UsePushNotifications => ({
isSupported: computed(() => mockState.isSupported),
isSubscribed: ref(mockState.isSubscribed),
subscribe: subscribeSpy,
}),
}));
import MyComponent from '@/components/MyComponent.vue';
beforeEach(() => {
subscribeSpy.mockReset();
mockState = { isSupported: true, isSubscribed: false };
});
test('CTA visible quand !isSubscribed', () => {
mount(MyComponent);
expect(document.querySelector('[data-testid="cta"]')).not.toBeNull();
});
```
### Checklist
- [ ] Composable expose un type `Use<Name>` réutilisable dans les tests
- [ ] Mock retourne une shape conforme à ce type
- [ ] État du mock défini dans `beforeEach` (réinitialisé entre tests)
- [ ] Tests utilisent `data-testid` (pas de couplage CSS class)
- [ ] Couvre TOUS les états observables (pas que le happy path)
---
<a id="pattern-assertions-html-react-email"></a>
## Pattern : Assertions sur le HTML rendu par React Email — pièges à éviter
### Synthèse
- **Objectif** : écrire des tests Vitest fiables sur le HTML produit par un template React Email sans buter sur les commentaires JSX, les glyphes Unicode bruts, et les `<link rel="preload">` injectés automatiquement.
- **Contexte** : tests qui vérifient la présence ou l'absence de patterns dans le HTML rendu (côté mail Resend ou côté PDF Puppeteer en `previewOnly`).
- **Quand l'utiliser** : assertions sur le HTML rendu par `@react-email/components`.
- **Quand l'éviter** : tests qui valident uniquement la présence d'un composant React (snapshot par exemple).
### Validation
- Validé le : 29-04-2026
- Contexte technique : Vitest / React Email — RL799_V2
### Piège 1 — Commentaires React entre fragments JSX
React Email insère `<!-- -->` entre 2 fragments `{var}` adjacents :
```html
<p>V∴M∴ <!-- -->Pierre Vénérable</p>
```
Une regex `/V∴M∴ Pierre/` échoue. Helper utile :
```typescript
const matchAroundComments = (_source: string, ...parts: string[]): RegExp => {
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(escaped.join('\\s*(?:<!--[^>]*-->\\s*)*'), 'i');
};
assert.match(html, matchAroundComments(html, 'V∴M∴ ', 'Pierre Vénérable'));
```
### Piège 2 — Caractères Unicode bruts dans les constantes shared
Une constante avec `` (U+2019) brut est rendue **telle quelle** dans le HTML par React Email (pas d'encodage). Une regex avec `'` ASCII ou `&apos;` échoue.
```typescript
assert.match(html, /Dans l(?:'||&#x27;|&apos;)attente/);
```
### Piège 3 — `<link rel="preload">` injecté automatiquement par `<Img>`
Le composant `<Img>` de `@react-email/components` injecte `<link rel="preload" as="image" href="…">` dans le `<head>` en mode mail (pas en mode `previewOnly`). Une assertion `expect(html).not.toMatch(/<link\s+rel/i)` échoue sur le mail.
Solution : autoriser les `<link rel>` qui pointent vers `appBaseUrl` (URL maîtrisée), bloquer les autres :
```typescript
const linkMatches = html.match(/<link\s+[^>]*href=["']([^"']+)["']/gi) ?? [];
for (const linkTag of linkMatches) {
const href = /href=["']([^"']+)["']/i.exec(linkTag)?.[1];
assert.ok(href.startsWith(appBaseUrl) || href.startsWith('data:'));
}
```
### Piège 4 — Assertions trop génériques sur des termes ambigus
Une regex `/V∴M∴/` pour vérifier "ligne signature V∴M∴ vacante absente" matche aussi le footer "Le V∴M∴ et les officiers..." qui contient le même token. Solution : cibler un pattern plus précis du contexte (la classe CSS du style `vmSignature` : `font-weight:600`).
### Garde-fou Puppeteer mode PDF
En mode `previewOnly`, le HTML doit être strictement offline-safe :
```typescript
assert.doesNotMatch(html, /<link\s+rel/i); // pas de preload (≠ mode mail)
assert.doesNotMatch(html, /<script\s+src/i);
assert.doesNotMatch(html, /<img[^>]+src=["'](?!data:)/i); // pas de http(s)
```

View File

@@ -319,3 +319,474 @@ Poser `role="menu"` / `role="menuitem"` implique obligatoirement :
- Contexte technique : PWA / install prompt — RL799_V2 18-04-2026
---
<a id="risque-cache-pwa-soft-delete-fuite"></a>
## Cache offline PWA + soft-delete — invalidation diff-based scopée
### Risques
- Une PWA qui cache des blobs en IndexedDB pour offline reading **ne reçoit aucun signal automatique** quand un document est soft-deleted côté serveur
- Le contenu reste lisible hors-ligne indéfiniment. Pour des données réglementaires ou sensibles, c'est un gap de sécurité non négligeable
### Symptômes
- Document soft-deleted en base, encore consultable offline par les utilisateurs qui l'ont mis en cache
- Aucun mécanisme automatique de purge
### Bonnes pratiques / mitigations
**Mitigation V1 (best-effort, diff-based)** au prochain `loadEntries` online :
1. Lire la liste serveur courante (filtrée `deletedAt IS NULL`)
2. Lire les IDs cachés localement **scopés au même périmètre** (type+grade) — sinon on supprime à tort un doc d'un autre onglet
3. Diff : `cached - server = soft-deleted``removeCachedDocument(id)` pour chaque
```typescript
const bustCachedIfMissing = async (candidateIds: string[], serverIds: Set<string>) => {
for (const id of candidateIds) {
if (!serverIds.has(id)) await removeCachedDocument(id);
}
};
```
**Mitigation V2 (push server-initiated)** : Service Worker abonné à un canal (postMessage / WebSocket / SSE), serveur publie `{ type: 'document-soft-deleted', id }` sur soft-delete, SW intercepte et fait `caches.delete()` immédiat. Coût : infra push + gestion connectivité partielle. À garder en backlog si le risque devient critique (audit GDPR).
**Tests recommandés** :
- doc caché + recharge avec serveur qui omet ce doc → assert `removeCachedDocument` appelé
- doc caché + serveur qui retourne le doc → assert pas d'effet (non-régression)
- doc caché pour scope `rituels` + recharge sur scope `mementos` qui omet ce doc → assert pas d'effet (scope isolation)
- Contexte technique : PWA / IndexedDB — RL799_V2 20-04-2026
---
<a id="risque-duplication-focus-parent-modal"></a>
## Duplication parent ↔ modal de la capture de focus
### Risques
- Quand un composant modal implémente correctement le pattern a11y `previousActiveElement` (capture à `onMounted`, restitution à `close`/`submit`), le composant parent **ne doit PAS** stocker un `lastTrigger` en parallèle
- Code mort avec commentaire trompeur : un lecteur qui cherche à comprendre le flux focus va s'y perdre
### Symptômes
- Le parent a une ref `lastFabTrigger` / `lastTrigger` écrite au clic du trigger
- Elle n'est **jamais lue** — le commentaire prétend "pour restitution du focus par la modal", mais la modal a son propre mécanisme
### Bonnes pratiques / mitigations
- **Une seule responsabilité** pour la restitution du focus : la modal
- Le parent se contente d'ouvrir la modal (`uploadModalOpen.value = true`)
- Le parent ne capture le focus QUE si la modal ne le fait pas (cas d'un overlay maison sans pattern `previousActiveElement`)
- **Repérage en code review** : `grep -n "lastTrigger\|previousActive" components/ pages/` → s'il y a des occurrences dans BOTH un parent et une modal du même flux, c'est le signal
- Contexte technique : Vue 3 / a11y modales — RL799_V2 20-04-2026
---
<a id="risque-touch-action-none-card-mobile"></a>
## `touch-action: none` sur card mobile bloque scroll vertical
### Risques
- Une card mobile gérant un swipe horizontal avec `touch-action: none` capture **tout** le toucher, laissant le JS gérer le scroll vertical
- Le JS détecte scroll vs swipe via un seuil mais doit **libérer** l'événement après l'avoir analysé → un délai imperceptible s'installe, le scroll vertical devient saccadé et souvent ignoré
- L'utilisateur trouve la liste "inscrollable" quand son pouce touche directement les cards
### Symptômes
- Poser le pouce sur une card puis scroller ne marche qu'une fois sur 20
- Scroll OK dans les marges vides à côté
- Aucune erreur console
### Bonnes pratiques / mitigations
```css
/* AVANT — bug : scroll vertical capturé */
.member-card {
touch-action: none;
}
/* APRÈS — scroll natif OK, swipe horizontal toujours fonctionnel */
.member-card {
touch-action: pan-y;
}
```
Combiné avec un handler JS qui `preventDefault` uniquement sur mouvement horizontal significatif (> 10 px) :
```typescript
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY)) {
event.preventDefault();
}
```
**Règle** :
- Card qui gère swipe horizontal ET scroll vertical : `touch-action: pan-y` (le navigateur gère nativement le scroll vertical, seul l'axe horizontal est laissé au JS)
- Card qui ne gère QUE du pan/zoom custom (rare) : `touch-action: none` peut se justifier
- Tous les autres cas : laisser la valeur par défaut (`auto`)
**Repérage en code review** : `grep -rn "touch-action: none" components/` → chaque occurrence est suspecte.
- Contexte technique : CSS / mobile — RL799_V2 21-04-2026
---
<a id="risque-button-wrapper-card-color-inherit"></a>
## `<button>` wrapper card — toujours `color: inherit` (reset user-agent)
### Risques
- Quand on utilise un `<button>` comme wrapper cliquable d'une card (pattern idiomatique pour l'a11y clavier), il hérite du `color` user-agent par défaut
- Sur certains setups (Safari/dark mode notamment), ce `color` peut être bleu — le texte enfant hérite et contamine titres et paragraphes qui devraient prendre la couleur du thème
### Symptômes
- Titre de card en bleu sur fond sombre alors que le thème prévoit de l'or
- Bug non visible en dev light mode sur Chrome — apparaît uniquement sur certains setups → difficile à reproduire
### Bonnes pratiques / mitigations
```css
.my-card-button {
/* reset user-agent */
color: inherit;
font-family: inherit;
border: 0;
background: transparent;
/* … */
}
```
**Règle** : tout `<button>` qui wrappe du contenu stylé par le thème (card, liste, ligne de tableau) doit reset `color`, `font-family`, `font-size`, `border`, `background`. C'est un bootstrap user-agent minimal à prévoir dans tout design system.
**Alternative** : `<div role="button" tabindex="0">` avec `@keydown.enter/space`. Plus verbeux, mais évite les resets. Pattern valide si l'équipe est à l'aise avec les implications a11y.
- Contexte technique : CSS / a11y — RL799_V2 21-04-2026
---
<a id="risque-erreur-silencieuse-4-etats"></a>
## Erreur silencieuse = blanc indistinguable de "aucune donnée" — 4 états distincts (loading / empty / error / forbidden)
### Risques
- Un composant qui affiche le résultat d'un fetch sans distinguer ses 4 états produit du **blanc** dans 3 cas sur 4 — l'utilisateur ne peut pas savoir si la donnée est en chargement, légitimement vide, en erreur réseau, ou refusée par RBAC
- Sur les flows critiques (rituel, opérations sensibles), un blanc silencieux est inacceptable : l'utilisateur prend des décisions sur la base de l'affichage
### Symptômes
- Plusieurs cards d'une vue qui auto-fetchent et tombent toutes en `[]` côté front quand l'API renvoie 403 — affichage indistinguable de "vide légitime"
- Toast générique "Une erreur est survenue" sans corrélation avec un retry actionnable
### Bonnes pratiques / mitigations
Tout composant qui fetch une ressource doit avoir **4 états distincts** dans son rendu :
1. **Loading** : skeleton, spinner, ou texte explicite (« Chargement… »)
2. **Empty (donnée légitimement vide)** : message explicite *« Aucune donnée enregistrée pour … »*
3. **Error (réseau / serveur)** : message + bouton retry. Ne jamais se contenter d'un blanc
4. **Forbidden (403)** : message explicite *« Vous n'avez pas accès à cette donnée »* + suggestion d'action (recharger / contacter admin)
Le frontend doit savoir **distinguer 403** des autres erreurs au niveau de son service HTTP, et propager l'info au composant. Ne pas traiter `!response.ok` en bloc avec un message générique.
```typescript
export const getXxx = async (id: string) => {
const response = await apiFetch(`/api/xxx/${id}`);
if (response.status === 403) throw new ForbiddenError(...);
if (response.status === 404) throw new NotFoundError(...);
if (!response.ok) throw new Error(await parseError(response));
return (await response.json()).data;
};
```
Le composant catche les types d'erreur et choisit le rendu approprié.
- Contexte technique : Vue 3 / fetch — RL799_V2 27-04-2026
---
<a id="risque-service-worker-non-secure-context"></a>
## Service Worker invisible en accès non-secure (HTTP via IP réseau)
### Risques
- Les Service Workers exigent un [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — HTTPS strict, OU URL `http://localhost:*` ou `http://127.0.0.1:*`
- Un accès via IP réseau en HTTP (Tailscale `100.x.x.x`, LAN `192.168.x.x`) est non-secure → `navigator.serviceWorker` est `undefined` → la PWA fonctionne en mode "navigateur classique" mais sans cache offline, sans push, sans badge, sans installation
- Les tests E2E en Tailscale loupent silencieusement les régressions SW
### Symptômes
- `navigator.serviceWorker.register('/sw.js')` lève `Cannot read properties of undefined (reading 'register')`
- DevTools > Application > Service Workers ne montre rien
- Tests "ça marche en LAN" qui ne reflètent pas la prod HTTPS
### Bonnes pratiques / mitigations
```javascript
// Détection
console.log(window.isSecureContext, location.protocol, location.hostname);
// true "https:" "..." → OK
// true "http:" "localhost" → OK
// false "http:" "192.168.1.42" → SW désactivé
```
Stratégies par contexte :
1. **Test local** : utiliser `http://localhost:<port>` strict (jamais l'IP, même en LAN)
2. **Test réseau / mobile** : reverse proxy HTTPS (Caddy/Traefik avec Let's Encrypt, ou `tailscale cert` pour le magicDNS Tailscale)
3. **Préview Vite** : `vite preview --https` avec un certificat auto-signé (acceptable en dev test)
**Préventif** :
- documenter dans le README projet que le SW exige HTTPS/localhost
- en CI E2E, toujours utiliser `localhost` (le webServer Playwright tourne sur `localhost` par défaut)
- ne PAS supposer qu'un test "ça marche en LAN" reflète la prod HTTPS
- Contexte technique : PWA / Service Worker — RL799_V2 28-04-2026
---
<a id="risque-vite-pwa-bascule-strategies-runtime-caching"></a>
## Vite-plugin-pwa : bascule `generateSW` → `injectManifest` rend `runtimeCaching` inerte
### Risques
- En mode `injectManifest`, Vite PWA n'injecte PAS de runtime workbox — il bundle le `src/sw.ts` fourni tel quel
- Toute la config `workbox.*` du `vite.config.ts` (sauf `globPatterns`/`globIgnores` déplacés sous `injectManifest.*`) est ignorée silencieusement, sans warning
- Régression directe : leak de cookies API en cache, contenu sensible en cache, 404 transformés en `index.html`
### Symptômes
- Après bascule, les routes runtime configurées dans `workbox.runtimeCaching` n'ont plus aucun effet
- Le build passe, aucune erreur visible, mais en DevTools > Application > Service Worker on ne voit pas les routes attendues
### Bonnes pratiques / mitigations
Détection : examiner `dist/sw.js` après build — chercher des strings clés (`/api/`, `uploads`, `manifest.webmanifest`, `addEventListener`). Si elles sont absentes du SW custom, c'est qu'on a perdu la protection.
**Mitigation** : RÉIMPLÉMENTER À LA MAIN dans `src/sw.ts` toutes les routes runtime, denylist, et cleanup :
```typescript
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkOnly, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
registerRoute(({ url }) => url.pathname.startsWith('/uploads/'), new NetworkOnly());
registerRoute(
({ url }) => url.pathname === '/manifest.webmanifest',
new NetworkFirst({
cacheName: 'manifest-cache',
plugins: [new ExpirationPlugin({ maxAgeSeconds: 300, maxEntries: 1 })],
}),
);
registerRoute(
new NavigationRoute(async () => { /* fallback handler */ }, {
denylist: [/^\/api\//, /^\/uploads\//, /^\/manifest\.webmanifest$/],
}),
);
```
**Vérifications obligatoires post-bascule** (DevTools sur build/preview) :
1. Application > Service Worker : `sw.js` activé
2. Network : `POST /api/auth/login` → pas de "(from ServiceWorker)", pas de cache
3. Network : GET `/uploads/foo.pdf` → réseau direct
4. Network mode Offline : navigation `/page` → fallback `index.html` ; `/api/foo` → erreur réseau (PAS index.html)
5. Déploiement v2 : ancien cache purgé après activation
`setCatchHandler` ne suffit PAS à remplacer `navigateFallback` — il ne se déclenche que si une route enregistrée throw. Pour le navigation fallback, utiliser `NavigationRoute` explicitement.
- Contexte technique : Vite / vite-plugin-pwa / Workbox — RL799_V2 28-04-2026
---
<a id="risque-ts-strict-uint8array-buffersource"></a>
## TS strict — `Uint8Array<ArrayBufferLike>` non assignable à `BufferSource`
### Risques
- TS 5.7+ avec lib DOM récente paramètre `Uint8Array` par défaut sur `ArrayBufferLike` (qui inclut `SharedArrayBuffer`)
- Beaucoup d'APIs DOM (Push API, WebCrypto certaines surfaces) attendent un `BufferSource` strict avec `buffer: ArrayBuffer` — d'où une erreur TS au build
### Symptômes
```
Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BufferSource'.
Types of property 'buffer' are incompatible.
Type 'ArrayBufferLike' is not assignable to type 'ArrayBuffer'.
Type 'SharedArrayBuffer' is missing the following properties from type 'ArrayBuffer'…
```
L'erreur est au build, pas au runtime (le code marche en JS).
### Bonnes pratiques / mitigations
Créer explicitement un `ArrayBuffer` strict, puis remplir via une vue `Uint8Array` :
```typescript
// ❌ Ne compile pas en TS strict
const urlBase64ToUint8Array = (s: string): Uint8Array => {
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
};
// ✅ Compile et fonctionne identiquement
const urlBase64ToArrayBuffer = (s: string): ArrayBuffer => {
const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
const buf = new ArrayBuffer(raw.length);
const view = new Uint8Array(buf);
for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
return buf;
};
```
**Alternative déconseillée** : `as ArrayBuffer` cast — masque le problème, peut rater une vraie incompatibilité si la lib DOM évolue.
- Contexte technique : TypeScript 5.x / lib DOM — RL799_V2 28-04-2026
---
<a id="risque-safe-areas-ios-viewport-fit-cover"></a>
## Safe-areas iOS — `viewport-fit=cover` indispensable
### Risques
- Sur iPhone Pro/Max (notch + home indicator + Dynamic Island), `padding-top: env(safe-area-inset-top)` ou `padding-bottom: env(safe-area-inset-bottom)` retourne **0** sans `<meta viewport content="… viewport-fit=cover">`
- Le développeur conclut à tort que le pattern ne fonctionne pas, alors qu'il manque juste l'opt-in
### Symptômes
- Header fixed rogné par le notch
- Nav bottom rognée par le home indicator
- `env(safe-area-inset-*)` retourne 0 sur iPhone Pro+
### Bonnes pratiques / mitigations
**Pattern complet (3 endroits) à appliquer ensemble** :
```html
<!-- 1) index.html — opt-in safe-areas -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
```css
/* 2) Header sticky : top + safe-area-inset-top */
.app-header {
position: fixed;
top: 0;
padding-top: env(safe-area-inset-top);
height: calc(var(--size-header-height) + env(safe-area-inset-top));
}
/* 3) Nav bottom : bottom + safe-area-inset-bottom + safe-area-inset-left/right */
.app-nav {
position: fixed;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
height: calc(var(--size-nav-height) + env(safe-area-inset-bottom));
}
/* FAB / menu flottant : bottom au-dessus de la nav + safe-area + offset */
.fab {
bottom: calc(var(--size-nav-height) + env(safe-area-inset-bottom) + 16px);
/* `max()` empêche les boutons d'être collés au bord rond du device */
right: max(16px, env(safe-area-inset-right));
}
```
**Règle de débogage** : si `env(safe-area-inset-bottom)` semble retourner 0 sur iPhone Pro+, **vérifier `<meta viewport>` AVANT de chercher ailleurs**. C'est presque toujours la cause.
`safe-area-inset-left` et `safe-area-inset-right` ne sont non-nuls qu'en mode paysage (notch latéral). Garder le `padding-left/right` quand même → no-op en portrait, fix en paysage.
- Contexte technique : CSS / iOS — RL799_V2 02-05-2026
---
<a id="risque-input-date-safari-ios-min-width"></a>
## `<input type="date">` Safari iOS — `appearance: none` + `min-width: 0` + `min-height` obligatoires
### Risques
- Sur Safari iOS (et Chrome iOS car webkit sous-jacent), un `<input type="date">` ou `datetime-local` :
- déborde de son conteneur sur la droite (largeur intrinsèque > 100 %)
- apparaît plus mince que les autres inputs (hauteur intrinsèque différente)
- affiche un styling natif iOS qui casse le design system
### Symptômes
- `width: 100%` ne suffit pas — la largeur intrinsèque écrase la contrainte
- Bug non reproductible sur Chrome desktop, visible uniquement sur iPhone réel ou Safari Responsive Design Mode
### Bonnes pratiques / mitigations
```css
input[type="date"],
input[type="datetime-local"],
input[type="time"] {
appearance: none; /* neutralise le styling natif */
-webkit-appearance: none; /* Safari iOS — ne PAS oublier */
min-width: 0; /* permet à width: 100% de gagner */
min-height: 48px; /* aligne avec les autres inputs */
}
```
**Les 4 propriétés sont nécessaires** :
- `appearance: none` seul : le styling natif disparaît mais la largeur intrinsèque reste → débordement
- `min-width: 0` seul : le styling natif reste, on a juste cassé sa hauteur
- `min-height: 48px` : nécessaire pour homogénéiser avec les inputs text classiques
- `-webkit-appearance: none` : redondant en théorie avec `appearance: none` mais nécessaire en pratique sur certaines versions Safari iOS
- Contexte technique : CSS / Safari iOS — RL799_V2 01-05-2026
---
<a id="risque-fieldset-legend-flex-grid"></a>
## `<fieldset>` / `<legend>` cassent un layout flex inline
### Risques
- Pour un champ "label + valeur inline" (ex : `Grade [GradeBadge]` sur la même ligne), le réflexe sémantique est `<fieldset>` + `<legend>`
- `<legend>` a un comportement natif particulier : interrompt visuellement le `border` du fieldset, son `display` est traité spécialement, il ne se comporte pas comme un enfant flex/grid normal
- Le legend prend toute la largeur, l'input passe en dessous, impossible de les aligner sans hacks `position: absolute`
### Symptômes
- `<fieldset style="display: flex">` avec `<legend>` + `<input>` qui ne s'alignent pas sur la même ligne
### Bonnes pratiques / mitigations
Pour un groupe de champs **avec layout custom** (flex/grid inline), utiliser `<div>` + `<span class="label">` + le champ :
```html
<!-- ❌ Layout cassé : legend ne se comporte pas comme un enfant flex -->
<fieldset class="field-inline">
<legend>Grade</legend>
<GradeBadge :grade="grade" />
</fieldset>
<!-- ✅ Layout custom OK : div + span — perte sémantique mineure
compensée par aria-labelledby si besoin -->
<div class="field-inline">
<span class="field-inline__label" id="grade-label">Grade</span>
<GradeBadge :grade="grade" aria-labelledby="grade-label" />
</div>
```
Garder `<fieldset>/<legend>` uniquement quand on accepte le rendu natif (groupe vertical avec border + legend qui chevauche le border supérieur — pattern formulaire admin classique).
**Trade-off à assumer** : `<fieldset>` apporte une sémantique a11y (groupe de champs liés). En remplaçant par `<div>`, on peut compenser via `role="group" aria-labelledby="..."` si le besoin d'a11y est fort. Pour un simple label+badge inline, c'est rarement nécessaire.
- Contexte technique : HTML / a11y / CSS — RL799_V2 02-05-2026
---

View File

@@ -382,3 +382,176 @@ followingsError: string | null; // erreur de fetchFollowings
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
---
<a id="risque-emit-vue-mutation-serveur-sans-listener"></a>
## `emit` Vue annonçant une mutation serveur sans listener parent → caches stale
### Risques
- Un composant enfant émet un événement (`emit('approved')`) après une mutation côté serveur, mais aucun parent n'écoute
- L'enfant met bien à jour son état local, mais les caches parents qui dérivent du même statut (badges accordion, verrous cascade, prop `previousAggregate` consommée par d'autres enfants) restent stale **indéfiniment**, jusqu'au prochain changement d'écran/route
- Bug invisible dans les tests structurels (`content.includes`) et passe inaperçu en revue parce que le code enfant est "correct"
### Symptômes
- Tout autre affichage du même statut dans le parent ou dans des sous-composants frères reste "Publiée" alors que la planche est "Approuvée" en DB
- L'UI ne se rafraîchit qu'au prochain reload manuel ou navigation
### Bonnes pratiques / mitigations
```bash
# Repérer les emits qui annoncent une mutation serveur
grep -rn "defineEmits" apps/frontend/src/components
# Pour chaque emit trouvé, chercher s'il a au moins un listener parent
grep -rn "@<eventName>=" apps/frontend/src/pages apps/frontend/src/components
```
Zéro listener = bug latent (sauf si l'emit est purement informatif — analytics, debug).
**Règle** : tout `emit` qui annonce une **mutation persistée serveur** (création, suppression, changement de statut, validation) doit avoir au moins un listener parent qui :
1. Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
2. Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (`previousAggregate`, dashboard data)
3. **Ne se contente PAS de l'optimistic update** de l'enfant — la source de vérité reste serveur, le cache parent doit refléter l'état serveur post-mutation
```vue
<!-- Enfant émet, parent ignore. Le badge de l'accordion reste stale -->
<PlancheTraceeCard :tenue-id="planche.tenueId" mode="previous" />
<!-- Listener explicite qui invalide les caches dérivés -->
<PlancheTraceeCard
:tenue-id="planche.tenueId"
mode="previous"
@approved="onPlancheApproved(planche.tenueId)"
/>
```
**Couverture** : test de mount complet via `@vue/test-utils` qui simule l'événement et vérifie que le rendu parent change. Les tests `readFileSync + content.includes('emit')` valident que le code enfant émet, pas que le parent écoute.
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-templates-vue-references-orphelines"></a>
## Templates Vue — références orphelines invisibles à `tsc --noEmit`
### Risques
- Une variable supprimée du `<script setup>` mais encore référencée dans le `<template>` ne génère pas d'erreur compile avec `tsc --noEmit` seul (Volar moins strict que `tsc` pur sur les expressions template)
- Le composant crashe **uniquement au runtime** : `Cannot read properties of undefined` ou `[Vue warn] Property "X" was accessed during render but is not defined`
### Symptômes
- Refactor où on extrait un composable et oublie de destructurer une variable, ou on retire un import devenu (apparemment) inutilisé
- Typecheck passe, tests structurels passent, page charge mais une section ne s'affiche pas / un bouton ne fait rien
- `[Vue warn]` dans la console au mount
### Bonnes pratiques / mitigations
**Recommandation outillage** : migrer le `typecheck` du projet de `tsc --noEmit` vers `vue-tsc -p tsconfig.typecheck.json --noEmit`. Avec `vue-tsc`, les expressions template sont strictement typées contre le `<script setup>` exposé. Une ref orpheline → erreur de compile, pas warning runtime.
**Checklist étendue avant de marquer une extraction "done"** :
```bash
# Pour chaque symbole supprimé/non destructuré, grep dans le template
for symbol in normalizedQuery directoryOfficeLabel; do
echo "=== $symbol ==="
# Patterns template critiques
grep -nE "v-(if|else-if|show)=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "\\{\\{[^}]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "(:|@)[a-z-]+=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
**QA visuel obligatoire post-refactor** : pour tout refactor qui touche une page importante, ouvrir la page en browser dev avant de pousser :
- naviguer aux états critiques (search, modals, toggle accordion)
- vérifier la console : zéro `[Vue warn]` ou `ReferenceError`
- tester au moins un workflow complet par section refactorée
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
---
<a id="risque-symboles-orphelins-suppression-bloc"></a>
## Symboles orphelins après suppression d'un bloc — checklist grep
### Risques
- Refactor où on supprime un bloc cohérent (state + computeds + handlers) en utilisant un Edit ciblé. Tout compile, tous les tests passent, mais au runtime : `ReferenceError: <symbole> is not defined` au mount
- Le symbole supprimé était encore référencé dans un **lifecycle hook** (`onMounted`, `onUnmounted`), un **watcher**, ou un **handler asynchrone** non inclus dans le bloc supprimé
### Symptômes
- Page qui ne mount plus après refactor, alors que typecheck OK + tests verts
- Erreur visible uniquement à `cmd+R` sur la page
### Bonnes pratiques / mitigations
```bash
# Pour chaque symbole déclaré dans le bloc à supprimer
for symbol in updatePointerType pointerMql closeInsertMenuOnOutsideClick; do
echo "=== $symbol ==="
grep -n "\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
Doit retourner **uniquement les lignes du bloc à supprimer**. Si une référence apparaît hors du bloc → ne pas supprimer sans traiter explicitement (déplacer, adapter, ou laisser).
**Lieux à vérifier en priorité** :
| Lieu | Fréquence du piège |
|------|--------------------|
| `onMounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent |
| `onUnmounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent (cleanup) |
| `watch(() => x, () => { fn() })` | ⚠️⚠️ fréquent |
| Handler async (`.then(() => fn())`) | ⚠️ rare mais existe |
| Computed dans une autre section du fichier | ⚠️ rare |
| Template (`@click`, `:disabled`) | typecheck attrape via SFC plugin |
**Garde-fou complémentaire** : QA visuel obligatoire post-refactor (cf. `risque-templates-vue-references-orphelines`).
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-extraction-vue-ts-bug-typage-latent"></a>
## Extraction `.vue` → composable `.ts` révèle des bugs de typage latents
### Risques
- Vue 3 + Volar compile les blocs `<script setup>` avec une stratégie d'inférence moins agressive que `tsc` strict pur. Un handler peut compiler dans le `.vue` si le runtime n'utilise pas le champ manquant
- Le composable extrait est compilé par `tsc -p tsconfig.typecheck.json` **hors contexte Vue** → toutes les règles strictes s'appliquent → la divergence de type devient une erreur bloquante
- C'est un effet de bord **positif** mais qui peut bloquer l'extraction tant qu'on n'a pas diagnostiqué la divergence
### Symptômes
```
Type 'X' is not assignable to type 'Y'.
The types of '<champ>.<sous-champ>' are incompatible…
Type 'A' is missing the following properties from type 'B': …
```
Cas typique : un emit Vue annonçait `Detail` (type pour la liste) alors que la fonction service renvoyait `Data` (type pour la mutation). Silencieux dans le `.vue` d'origine, devenu visible dans le `.ts` extrait.
### Bonnes pratiques / mitigations
Trois cas typiques quand l'erreur apparaît après extraction :
1. **Le type annoncé ne matche pas le type réellement émis** : aligner sur le type réellement émis plutôt que sur le type annoncé dans `defineEmits`. Si possible, corriger aussi la signature de l'émetteur — mais c'est un autre scope
2. **Le `.vue` exploitait un cast implicite** : `v-if="x.foo"` réduit le union type. Dans un `.ts` extrait, narrow explicitement avec un `if` ou un type guard
3. **Volar n'analysait pas un chemin de type complexe** : type récursif, génériques imbriqués, `Pick<...>` dans une union → extraire un alias intermédiaire propre dans `@<module>/types.ts`
**Quoi faire face à l'erreur** :
1. **NE PAS** mettre `as Foo` pour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom
2. Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
3. Aligner la signature du handler/composable sur ce type-là
4. Documenter dans un commentaire au-dessus du handler que le type émis diverge du type annoncé dans `defineEmits` (si on ne corrige pas l'émetteur dans le même refactor)
5. Ouvrir un TODO si la correction de l'émetteur est hors scope
**Recommandation outillage** : `vue-tsc` plutôt que `tsc` pur en typecheck (cf. `risque-templates-vue-references-orphelines`). Ce genre de divergence aurait été détecté **avant** le refactor.
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026

View File

@@ -149,6 +149,22 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
- Contexte technique : Vue 3 / node:test — RL799_V2 02-04-2026
### Cas additionnel : obsolescence silencieuse après refacto structurel
Au-delà du faux garde-fou de non-régression, un test en `readFileSync(path) + content.includes(...)` devient obsolète sans alarme dès qu'une réorganisation structurelle déplace le code visé. Trois variantes vécues :
1. **Fichier déplacé par scoping** (ex: `pages/X.vue``pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` mais dans `composables/use<X>.ts` ; le `.vue` existe encore mais ne contient plus le pattern, donc le test échoue sur une assertion sans rapport avec la vraie cause
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
**Mitigations spécifiques** :
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test (pas `resolve(...)` inline) — facilite le rerouting en cas de refacto
- Lors d'une extraction de logique dans un composable / sous-composant, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
- Pour les composants interactifs (formulaires, modales, listes avec actions), compléter le string-match par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash — c'est le seul moyen de valider la cohérence script ↔ template
- Contexte technique : Vue 3 / vitest — RL799_V2 30-04-2026 (3 cas observés sur la même session)
---
<a id="risque-catch-false-test-skip-e2e"></a>
@@ -171,3 +187,129 @@ source_projects: [app-alexandrie, app-template-resto, RL799_V2]
- **Signal review** : `.catch(() => false)` suivi de `test.skip` dans un test E2E
- Contexte technique : Playwright / E2E — RL799_V2 08-04-2026
---
<a id="risque-tests-e2e-6-causes-racines"></a>
## Tests E2E qui rotent — 6 causes-racines récurrentes
### Risques
- Sur une suite E2E mature, les fails ne viennent presque jamais d'un bug applicatif : ils viennent d'un désalignement test ↔ code de prod
- Conclure à une régression métier alors que c'est du test obsolète fait perdre du temps et masque les vraies régressions
### Symptômes
Les 6 patterns observés sur RL799_V2 (Playwright + Vue 3 + refactors UI fréquents) :
1. **Testid changé sans MAJ tests** : `getByTestId('library-entries')` timeout, mais le composant expose `data-testid="document-list"`. Cause : refactor d'un composant qui fusionne plusieurs vues en un composant générique avec un testid neutre.
2. **Labels métier qui changent** : `await expect(badge).toHaveText('Publiée')` échoue, le badge affiche désormais 'Convoquée'. Cause : refactor lifecycle qui renomme les labels affichés sans toucher aux testids structurels.
3. **Menus / dropdowns conditionnels** : `getByTestId('odj-insert-menu').click()` timeout aléatoire — parfois le menu s'ouvre, parfois pas. Cause : UX qui adapte le flow selon l'état (1 seul type → bouton direct, plusieurs → menu).
4. **Features supprimées** : `await page.goto('/secretaire?soireeId=xxx')` charge la page mais ne sélectionne plus la soirée. Cause : query param retiré au profit d'une navigation par onglets + click sur card.
5. **Refactor visuel** : `await expect(badge).toHaveText('A∴')` échoue, le badge affiche désormais une icône SVG. Cause : refactor de représentation (texte → icône) sans toucher au testid.
6. **Cleanup post-test** : test métier passe en 2 s, mais le `finally { await restoreEntry() }` timeout à 30 s. Cause : le PATCH de cleanup tape sur une route lente (audit log, notif, validation).
### Bonnes pratiques / mitigations
À chaque diagnostic E2E, vérifier d'abord ces 6 hypothèses avant de conclure à une régression métier :
- **Cause 1** : grep `data-testid` dans le composant cible avant de modifier le test. Ne jamais "deviner" le testid à partir du nom de la page.
- **Cause 2** : préférer asserter sur des classes CSS modifier (`.badge--published`) ou des testids d'état (`data-testid="status-published"`) plutôt que sur du texte humain (cf. `pattern-asserter-classe-css-modifier-vs-texte` dans `frontend/patterns/tests.md`).
- **Cause 3** : guard conditionnel via `isVisible({ timeout: 1_000 }).catch(() => false)` pour gérer les deux branches.
- **Cause 4** : quand un test commence par une URL avec query param, vérifier en premier que ce param est encore consommé par la page (grep `useRoute` / `route.query` dans le composant).
- **Cause 5** : asserter la classe CSS modifier (plus stable que innerHTML qui contiendrait le SVG).
- **Cause 6** : cleanup best-effort avec timeout court (cf. `pattern-cleanup-e2e-best-effort` dans `frontend/patterns/tests.md`).
### Méta-leçon
Quand on découvre N fails E2E après une période de refactor intense :
1. Lancer la suite complète une fois pour avoir la liste exhaustive
2. Trier par cause-racine plutôt que par fichier
3. Fixer en lots cohérents (1 commit par cause-racine) plutôt qu'1 commit par fail
4. Capitaliser les patterns dès qu'ils se répètent (> 2 occurrences)
- Contexte technique : Playwright / Vue 3 — RL799_V2 25-04-2026
---
<a id="risque-tests-string-match-repointer-composant"></a>
## Tests `string-match .vue` — limites et compléments après extraction
### Risques
- Quand on extrait une section/onglet vers un sous-composant, les assertions `readFileSync + content.includes('Ordre du jour')` échouent — la string est maintenant dans le sous-composant, pas dans la page
- Mauvaises réactions : supprimer le test (perd la garantie), `.skip()` (dette accumulée), inverser en `toBeFalsy()` (régression masquée), repointer aveuglément (peut camoufler un problème)
### Symptômes
```
AssertionError: expected false to be truthy
expect(tenuesPage.includes('Ordre du jour')).toBeTruthy();
^
```
### Bonnes pratiques / mitigations
**Diagnostic** : lire l'assertion et identifier ce qu'elle garantit (présence d'un comportement métier, d'un data-testid critique, d'un ordre visuel).
**Repointer correctement** :
```typescript
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '../../../..');
// Page coquille (ce qui reste : layout, tabs, rendu conditionnel)
const tenuesPage = readFileSync(
resolve(root, 'src/pages/tenues/TenuesPage.vue'),
'utf-8',
);
// Sous-composant qui incarne désormais le markup d'un onglet
const prochaineView = readFileSync(
resolve(root, 'src/pages/tenues/components/ProchaineTenueView.vue'),
'utf-8',
);
// Composable qui incarne désormais la logique d'un onglet
const useProchaine = readFileSync(
resolve(root, 'src/pages/tenues/composables/useProchaineTenue.ts'),
'utf-8',
);
test('TenuesPage utilise le modèle de vue testable pour tab/titre', () => {
expect(tenuesPage.includes('resolveTenuesTab')).toBeTruthy();
expect(useProchaine.includes('getProchaineTenueTitle')).toBeTruthy();
});
```
**Renommer aussi le test si pertinent** :
```typescript
// Avant
test('TenuesPage redirige vers une page dédiée en cas de 403...', () => { /* … */ });
// Après — le nom devient un index sémantique
test('usePastTenues redirige vers une page dédiée en cas de 403...', () => { /* … */ });
```
### Anti-pattern : tests structurels qui bougent en cascade
Si tes tests doivent être systématiquement mis à jour à chaque refactor, c'est que beaucoup de garanties sont vérifiées par string-match plutôt que par comportement. Pour les composants interactifs critiques (formulaires, listes avec actions, modales), **doubler** avec un test de mount `@vue/test-utils` qui survit aux refactors.
### Trois variantes vécues
1. **Fichier déplacé par scoping** (`pages/X.vue``pages/<module>/X.vue`) → `ENOENT` au runtime, le test crashe au lieu de signaler une régression métier
2. **Logique extraite dans un composable / sous-composant** → la chaîne attendue ne vit plus dans le `.vue` ; le test échoue sur une assertion sans rapport avec la vraie cause
3. **Variable supprimée du `<script setup>` mais conservée dans le template** → string-match passe (le template contient toujours la string), crash JS au mount du composant
**Mitigations spécifiques** :
- Centraliser le `path` du fichier visé dans une constante en tête de fichier de test — facilite le rerouting en cas de refacto
- Lors d'une extraction, grep les tests structurels qui pointaient le fichier d'origine et les rediriger vers le nouveau chemin
- Pour les composants interactifs, compléter par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash
- Contexte technique : Vue 3 / Vitest — RL799_V2 29-04-2026