Files
Coltura/docs/sites/ticket-03-spec.md
tristan 03c761eed4 feat(sites) : barre de selection de site (ticket 3/4)
Barre horizontale en haut de l'app qui liste les sites autorises de
l'utilisateur et permet de switcher d'un click. Consomme le composant
MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0).

Composables :
- useModules (shared) : consomme /api/modules, expose isModuleActive.
  Pattern aligne sur useSidebar.
- useCurrentSite (layer sites) : singleton state, switchSite optimistic
  avec rollback sur erreur, garde anti-double-submit, propagation au
  store auth via action setCurrentSite dediee.

Composant :
- SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte
  blanc uniforme (conforme maquette Figma) avec taille 24px forcee via
  labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n.

Integration :
- Middleware auth.global.ts : chargement parallele sidebar + modules.
- layouts/default.vue : render conditionnel si module Sites actif ET
  user.sites.length > 0.
- logout.vue : reset des 3 composables (sidebar, modules, currentSite)
  dans un try/finally.
- nuxt.config.ts : auto-detection des composables/ de chaque layer
  module (necessaire car imports.dirs explicite override les defaults
  Nuxt).

Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00,
Pommevic #74BF04. Charge aux admins de choisir des teintes foncees
(texte blanc non contrastable via calcul WCAG, design choisi).

Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite,
SiteSelector) incluant garde anti-regression pour useI18n hors setup.
182/182 PHPUnit backend, avec et sans module actif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:45:48 +02:00

543 lines
32 KiB
Markdown

# Ticket #03 — 3/4 — Barre de sélection de site (navbar horizontale)
## 0. Pivots post-implémentation (2026-04-20)
Écarts assumés entre la spec initiale (écrite avant exploration de la lib) et
le code livré après implémentation et test visuel. À lire en premier pour
comprendre les divergences lors de la relecture.
1. **Contraste texte auto supprimé, texte blanc forcé conforme Figma.**
La spec (sections 5, 6, 10) prévoyait un calcul de luminance WCAG pour
décider entre texte noir et blanc sur chaque tile. Après test visuel, le
choix design retenu est d'imposer **texte blanc partout** (default Malio
`text-white font-bold uppercase tracking-wide`). Conséquence : charge à
l'admin de choisir des couleurs de site suffisamment foncées pour que le
blanc reste lisible. Les utilitaires `parseHex`, `getRelativeLuminance`,
`getReadableTextColor` ont été supprimés comme code mort. Seul
`isValidSiteColor(hex)` reste dans `shared/utils/color.ts` (consommé par
`SiteDrawer`).
2. **Taille texte explicite `text-2xl` (24 px) appliquée via `labelClass`.**
Malio applique `font-bold uppercase tracking-wide` sans taille explicite.
Le wrapper `SiteSelector.vue` passe `labelClass="text-2xl"` pour garantir
les 24 px de la maquette Figma.
3. **A11y : `ariaGroupLabel` au niveau radiogroup** au lieu de
`ariaLabelActive` / `ariaLabelInactive` par tile. La raison : Malio rend
déjà un `role="radio"` avec `aria-checked` par tile — le lecteur d'écran
annonce "bouton radio coché/non coché" + le nom visible. Ajouter un
`aria-label` par tile aurait dupliqué l'info et alourdi sans bénéfice.
Le seul ajout nécessaire était un label au groupe, fait via
`:aria-label="t('sites.selector.ariaGroupLabel')"` sur `MalioSiteSelector`.
4. **Auto-détection composables des layers dans `nuxt.config.ts`.**
Pas prévu dans la spec. Ajouté car `imports.dirs` explicite override les
auto-imports par défaut de Nuxt pour les composables de layer. Sans ça,
`useCurrentSite` n'est pas résolu par Nuxt. Scan dynamique aligné sur le
pattern `moduleLayers` existant.
5. **Couleurs fixtures finales :** `#056CF2` (Châtellerault), `#F3CB00`
(Saint-Jean), `#74BF04` (Pommevic). Choix client post-maquette.
## 1. Objectif
Ce ticket livre l'UI de consommation du module Sites pour l'utilisateur final : une barre horizontale en haut de l'application qui liste les sites autorises de l'utilisateur connecte, met en avant le site courant et permet de basculer d'un site a l'autre en un clic.
Le ticket consomme la donnee posee par le ticket 2 (`/api/me` expose `sites` et `currentSite`, `PATCH /api/me/current-site` permet le switch) et s'appuie sur un nouveau composant `MalioSiteSelector` fourni par la version a jour de `@malio/layer-ui`.
Resultat attendu : apres merge, un user avec ≥ 1 site voit une barre sous la navbar horizontale ; un clic sur un site non actif le rend actif, change l'etat global, et est persiste cote serveur.
## 2. Périmètre
### IN
- **Upgrade** de `@malio/layer-ui` (actuellement `^1.3.0`) vers la version contenant `MalioSiteSelector`. La signature exacte du composant (props, slots, events) doit etre lue dans `node_modules/@malio/layer-ui/COMPONENTS.md` apres installation — la spec decrit le contrat attendu, le developpeur adapte selon l'API reelle (cf. Risque 1).
- Ajouter les champs `sites: Site[]` et `currentSite: Site | null` dans le type `UserData` (`frontend/shared/types/user-data.ts`) pour refleter le payload `/api/me` enrichi au ticket 2.
- Ajouter le type partage `Site` dans `frontend/shared/types/sites.ts` (deja cree au ticket 2, sinon a creer).
- Creer le composable `useCurrentSite()` dans `frontend/modules/sites/composables/` qui expose `currentSite`, `availableSites`, `switchSite(site)`, `resetCurrentSite()`. Pattern aligne sur `useSidebar()`.
- Creer le composable `useModules()` dans `frontend/shared/composables/` qui consomme `/api/modules` et expose `isModuleActive(id: string)`. Necessaire car `isModuleActive` est requis par le ticket mais n'existe pas encore cote front.
- Creer `SiteSelector.vue` dans `frontend/modules/sites/components/` : wrapper fin autour de `MalioSiteSelector` qui branche le composable `useCurrentSite()`, gere l'optimistic update avec rollback, emet un toast de succes/erreur.
- Integrer le selecteur dans `frontend/app/layouts/default.vue` — render conditionnel sur `isModuleActive('sites') && user.sites.length > 0`.
- Appeler `resetCurrentSite()` au logout (`frontend/modules/core/pages/logout.vue`), aligne sur `resetSidebar()` deja present.
- Gestion du **contraste automatique** : le texte du bloc passe en noir ou en blanc selon la luminance de `site.color`. Fonction utilitaire `getReadableTextColor(hex: string): 'black' | 'white'` dans `frontend/shared/utils/color.ts` (nouveau fichier utilitaire partage).
- Accessibilite : chaque bloc est un `<button>` natif avec `aria-pressed` sur le site courant, focus visible (ring Tailwind), navigation clavier Tab + Enter fonctionnelle.
- Responsive minimal : `flex-1` sur chaque bloc avec `min-w-[200px]` et `overflow-x-auto` sur le conteneur pour les cas 4+ sites sur petits ecrans.
- Tests Vitest : unite sur `useCurrentSite` (switch, rollback, reset), unite sur `getReadableTextColor`, smoke test sur `SiteSelector.vue` (rendu, emission du PATCH, rollback en cas d'echec).
### OUT
- Ticket `#04` : filtrage metier par site (ex: bloquer l'acces aux ressources Commercial si l'user n'est pas rattache au site cible). Le site courant est simplement un **contexte UX** dans ce ticket, aucune regle d'autorisation ne s'appuie encore dessus.
- Modification du layout `auth.vue` (login) : le selecteur n'est **jamais** rendu hors session authentifiee. Le layout login reste inchange.
- Persistance du site actif cote front (localStorage, cookies) : le backend est source de verite, le front ne cache pas independamment.
- Gestion d'une image / d'un logo par site : les sites sont identifies par nom + couleur uniquement dans ce ticket.
- Pre-mount du selecteur sans `/api/me` complet : le middleware `auth.global.ts` garantit deja que `auth.user` est resolu avant le rendu — pas besoin de gerer un etat "chargement" specifique dans le selecteur.
- Validation cote back d'une couleur "trop claire" : non introduite. Le ticket 2 accepte `#FFFFFF`. La compensation est faite cote front via le calcul de contraste ; une contrainte back arrivera si un abus se materialise.
## 3. Fichiers à créer
### Frontend — Module Sites (layer deja cree au ticket 2)
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
### Frontend — Shared
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
### Frontend — Tests
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
- Si la requete reussit, l'etat reste aligne.
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
- `resetCurrentSite` vide l'etat.
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Coltura) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
## 4. Fichiers à modifier
- `/home/m-tristan/workspace/Coltura/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/user-data.ts` : ajouter les champs
```ts
sites: Site[]
currentSite: Site | null
```
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
- `/home/m-tristan/workspace/Coltura/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
- `/home/m-tristan/workspace/Coltura/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : ajouter les cles
```json
"sites": {
"selector": {
"switchSuccess": "Site courant change",
"switchError": "Impossible de changer de site",
"ariaLabelActive": "Site actif : {name}",
"ariaLabelInactive": "Basculer sur le site {name}"
}
}
```
Ne **pas** mettre le nom du site en cle i18n : le nom est une donnee metier, pas un label.
## 5. Schéma cible — Composant `SiteSelector.vue`
### Render attendu (conforme Figma)
- Hauteur fixe : `h-[72px]`.
- `width: 100%` (parent du `<main>` dans `layouts/default.vue`, donc occupe toute la zone a droite de la sidebar).
- Flex horizontal, chaque bloc = `flex-1` avec `min-w-[200px]`.
- Conteneur parent : `overflow-x-auto` pour scroll horizontal si 4+ sites sur ecran etroit.
- Fond de chaque bloc : `site.color` (inline style car dynamique).
- Texte : centre horizontalement et verticalement, `font-inter font-bold text-[24px] uppercase tracking-wide`, couleur calculee par `getReadableTextColor(site.color)`.
- Opacite : `opacity-100` pour le site courant, `opacity-40` pour les autres.
- Hover sur les inactifs : `hover:opacity-70 cursor-pointer transition-opacity`.
- Focus clavier : `focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 focus:outline-none`.
- Semantique : chaque bloc est un `<button type="button">` (pas `<div>`), avec :
- `aria-pressed="true"` sur le site courant.
- `aria-label` dynamique via i18n (`sites.selector.ariaLabelActive` ou `ariaLabelInactive`).
### Contrat du wrapper `SiteSelector.vue`
```vue
<template>
<MalioSiteSelector
:sites="availableSites"
:current-site-id="currentSite?.id"
:disabled="switching"
@switch="handleSwitch"
/>
</template>
<script setup lang="ts">
const { availableSites, currentSite, switching, switchSite } = useCurrentSite()
async function handleSwitch(siteId: number) {
const target = availableSites.value.find(s => s.id === siteId)
if (!target) return
await switchSite(target)
}
</script>
```
**Hypothese** : la signature exacte de `MalioSiteSelector` (nom du prop, nom de l'event) doit etre verifiee dans `@malio/layer-ui/COMPONENTS.md` apres upgrade. Si elle differe, adapter le wrapper sans toucher au composable. Le wrapper reste le seul point d'adherence a l'API externe.
Si `MalioSiteSelector` **n'embarque pas** le calcul de contraste texte, le wrapper doit le gerer en passant `:text-color` ou en injectant un style calcule. Si le composant delegue la couleur a un slot ou a un formatteur, ajuster l'appel.
### Composable `useCurrentSite()`
```ts
import type { Site } from '~/shared/types/sites'
const currentSite = ref<Site | null>(null)
const availableSites = ref<Site[]>([])
const switching = ref(false)
export function useCurrentSite() {
const auth = useAuthStore()
const api = useApi()
const { t } = useI18n()
// Hydratation depuis le store auth — single source of truth
function syncFromAuth() {
availableSites.value = auth.user?.sites ?? []
currentSite.value = auth.user?.currentSite ?? null
}
async function switchSite(site: Site) {
if (switching.value) return
const previous = currentSite.value
// Optimistic update
currentSite.value = site
switching.value = true
try {
await api.patch('/me/current-site', { site: `/api/sites/${site.id}` }, {
toastSuccessMessage: t('sites.selector.switchSuccess'),
})
// Propage au store auth pour que tous les consommateurs soient alignes
if (auth.user) {
auth.user.currentSite = site
}
} catch (error) {
// Rollback
currentSite.value = previous
throw error // useApi a deja toast l'erreur si toast est active
} finally {
switching.value = false
}
}
function resetCurrentSite() {
currentSite.value = null
availableSites.value = []
switching.value = false
}
return {
currentSite,
availableSites,
switching,
switchSite,
resetCurrentSite,
syncFromAuth,
}
}
```
**Pattern** : state singleton au niveau module (refs module-level), meme convention que `useSidebar()`. Le singleton est necessaire pour que le logout + les consommateurs multiples partagent le meme etat. `resetCurrentSite()` est appele explicitement au logout (cf. section 4).
**Hydratation** : `syncFromAuth()` est appele au mount de `SiteSelector.vue` (dans un `onMounted` ou un `watch` sur `auth.user`). Alternative : appeler dans `auth.global.ts` apres `ensureSession()`.
### Composable `useModules()`
Pattern strictement aligne sur `useSidebar()` (cf. `frontend/shared/composables/useSidebar.ts`) :
```ts
const activeModuleIds = ref<string[]>([])
const loaded = ref(false)
export function useModules() {
async function loadModules() {
try {
const api = useApi()
const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
activeModuleIds.value = data.modules ?? []
loaded.value = true
} catch {
activeModuleIds.value = []
loaded.value = true
}
}
function isModuleActive(id: string): boolean {
return activeModuleIds.value.includes(id)
}
function resetModules() {
activeModuleIds.value = []
loaded.value = false
}
return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
}
```
**Attention** : verifier la forme exacte de la reponse `/api/modules` via `curl /api/modules`. Les specs RBAC anterieurs suggerent `{ modules: string[] }` mais il faut valider.
## 6. Contraste automatique du texte
### Algorithme
Formule de luminance relative WCAG 2.1 (simplifiee) :
```ts
function getRelativeLuminance({ r, g, b }: RGB): number {
const [R, G, B] = [r, g, b].map(c => {
const normalized = c / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * R + 0.7152 * G + 0.0722 * B
}
export function getReadableTextColor(hex: string): 'black' | 'white' {
const rgb = parseHex(hex)
return getRelativeLuminance(rgb) > 0.5 ? 'black' : 'white'
}
```
Le seuil 0.5 est un compromis pragmatique : simple, lisible, pas parfait WCAG AAA mais suffisant pour distinguer blancs/jaunes pales (→ texte noir) des bleus/verts/rouges saturés (→ texte blanc).
### Integration dans le selecteur
Le composable ou le template calcule la couleur pour chaque site une seule fois :
```ts
const textColorsBySiteId = computed(() => {
const map = new Map<number, string>()
for (const site of availableSites.value) {
map.set(site.id, getReadableTextColor(site.color))
}
return map
})
```
Le template applique `:style="{ color: textColorsBySiteId.get(site.id) }"` sur chaque bloc, ou passe la map au composant `MalioSiteSelector` si son API l'accepte.
### Cas limite — hex invalide
`parseHex` leve une `Error` si le format ne matche pas `#[0-9A-Fa-f]{6}`. Au niveau du selecteur, le template entoure l'acces dans un try/catch logique : si un site a une couleur invalide (improbable car la regex backend du ticket 1 bloque), fallback a texte blanc.
## 7. Intégration dans `layouts/default.vue`
### Structure actuelle
```
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<MalioSidebar ... />
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<main>...</main>
</div>
</div>
</div>
```
### Structure cible
```
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<MalioSidebar ... />
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector" />
<main>...</main>
</div>
</div>
</div>
```
Script :
```ts
const auth = useAuthStore()
const { isModuleActive } = useModules()
const showSiteSelector = computed(() =>
isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
)
```
### Render conditionnel et flash
Le middleware `auth.global.ts` resout deja `auth.user` (via `ensureSession()`) avant le rendu des pages. Le middleware doit en plus declencher `loadModules()` pour que `isModuleActive` soit resolu au premier render. Sans ca, `showSiteSelector` sera `false` pendant un premier paint, puis `true` apres le chargement de `/api/modules` → flash visuel.
**Solution** : dans `auth.global.ts`, appeler `loadModules()` au meme niveau que `loadSidebar()`.
### Import statique vs dynamique
Deux options :
- **Import statique** (`SiteSelector.vue` est toujours dans le bundle) : simple, le `v-if` gere l'affichage. Impact bundle minimal.
- **Import dynamique** (`defineAsyncComponent`) : le composant n'est charge que si le module est actif. Plus propre au sens "desactiver Sites = zero code sites dans le bundle", mais le layer Nuxt rend le composant auto-importable → le code est deja dans le bundle de toute facon.
**Recommandation** : import statique. L'economie de bundle est marginale et le layer Nuxt charge deja tout le module.
## 8. i18n
### Clés ajoutées
```json
{
"sites": {
"selector": {
"switchSuccess": "Site courant change",
"switchError": "Impossible de changer de site",
"ariaLabelActive": "Site actif : {name}",
"ariaLabelInactive": "Basculer sur le site {name}"
}
}
}
```
### Règles
- **Jamais** traduire le nom d'un site (`site.name`). C'est une donnee metier, affichee telle quelle. L'`uppercase` est applique en CSS (`text-transform: uppercase`), pas dans la donnee.
- Les `aria-label` interpollent `{name}` directement.
- `switchError` est consomme par le toast d'erreur de `useApi` si la route serveur renvoie un code non-2xx. Pour une erreur 403 "site non autorise" (cf. ticket 2), le serveur renvoie deja un message traduit ou un code i18n stable — a arbitrer au moment de l'implementation selon la decision prise au ticket 2.
## 9. Accessibilité
- Chaque bloc est un `<button type="button">` (pas un `<div>` avec `role="button"` — preferer la semantique native).
- `aria-pressed="true"` sur le bloc du site courant, `aria-pressed="false"` sur les autres.
- `aria-label` : l'uppercase CSS est visuel ; l'aria-label garde la casse originale du nom pour le screen reader (`aria-label="Site actif : Chatellerault"`).
- Focus visible : `focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 focus:outline-none`.
- Tab : parcourt les blocs de gauche a droite.
- Enter / Espace : declenche le switch (comportement natif du `<button>`).
- `tabindex="0"` n'est pas requis sur un `<button>` (deja focusable natif). Ne pas ajouter `tabindex="-1"` sur le bloc courant : l'user doit pouvoir revenir dessus.
## 10. Plan de tests
### Vitest — `useCurrentSite.spec.ts`
1. `switchSite met a jour currentSite localement immediatement` : avant la resolution de la promise, `currentSite.value` est deja le nouveau site.
2. `switchSite persiste via /api/me/current-site` : mock `useApi`, verifier que la requete PATCH est appelee avec `site: '/api/sites/{id}'`.
3. `switchSite rollback en cas d'erreur` : mock `useApi` pour rejeter, verifier que `currentSite.value` repasse a l'ancien site.
4. `switchSite propagate au store auth apres succes` : `auth.user.currentSite` est mis a jour apres succes.
5. `resetCurrentSite vide l'etat` : apres appel, `currentSite = null`, `availableSites = []`, `switching = false`.
6. `switching est vrai pendant la requete, faux apres` : verifier le flag sur tout le cycle.
7. `double switchSite concurrent est ignore` : si `switching = true`, un second appel retourne immediatement sans effet (garde anti-double-submit).
### Vitest — `useModules.spec.ts`
1. `loadModules charge /api/modules et alimente activeModuleIds`.
2. `isModuleActive retourne true si l'id est present, false sinon`.
3. `resetModules vide l'etat`.
4. `loadModules swallow les erreurs et laisse activeModuleIds vide` (alignement avec `useSidebar`).
### Vitest — `color.spec.ts`
1. `getReadableTextColor('#FFFFFF') === 'black'`.
2. `getReadableTextColor('#000000') === 'white'`.
3. `getReadableTextColor('#056CF2') === 'white'` (bleu sature).
4. `getReadableTextColor('#F59E0B') === 'black'` (ambre clair).
5. `getReadableTextColor('#10B981') === 'white'` (vert medium-foncé). A verifier a l'implementation ; adapter l'assertion.
6. `parseHex('red') → throw` (format invalide).
7. `parseHex('#FFF') → throw` (hex court non supporte).
8. `parseHex('#abcdef')` et `parseHex('#ABCDEF')` → meme resultat (tolere la casse).
### Vitest — `SiteSelector.spec.ts`
1. `Rendu : 3 sites rendus, bloc du site courant a opacity-100`.
2. `Bloc inactif a opacity-40 et aria-pressed="false"`.
3. `Clic sur un bloc inactif appelle switchSite avec le bon site`.
4. `Si switchSite throw, l'UI affiche toujours l'ancien site courant` (via rollback).
5. `Texte d'un site avec couleur claire (#FFFFFF) est rendu noir`.
6. `Texte d'un site avec couleur foncee (#056CF2) est rendu blanc`.
### Tests PHPUnit
Pas de nouveau test backend dans ce ticket — le ticket 2 couvre deja l'endpoint `/api/me/current-site`. Si un comportement nouveau est introduit cote serveur (ce qui ne devrait pas arriver), ajouter les tests en consequence.
### Test visuel manuel
- `make dev-nuxt` (port 3004).
- Login `admin` / `admin` → selecteur avec 3 blocs (Chatellerault actif, Saint-Jean et Pommevic a 40%).
- Clic sur `Pommevic` → Pommevic devient actif (100%), Chatellerault passe a 40%, toast "Site courant change".
- F5 → site actif persiste (Pommevic).
- Logout puis re-login → Pommevic toujours actif.
- Login `bob` / `bob` → un seul bloc (Saint-Jean), affiche par coherence (cf. regle metier "afficher meme pour 1 site").
- Retirer tous les sites a `alice` via `/admin/users` → login alice → selecteur absent.
- Desactiver `SitesModule::class` dans `config/modules.php`, restart backend, refresh front → selecteur absent, layout identique au comportement d'avant ce ticket.
## 11. Risques et points d'attention
### Risque 1 — Signature de `MalioSiteSelector` inconnue au moment de la spec
La version de `@malio/layer-ui` installee localement (1.3.0) ne contient pas `MalioSiteSelector`. La spec decrit le contrat attendu (props `sites`, `current-site-id`, event `switch`), mais la signature reelle est definie par la lib et peut differer (nom du prop, structure de l'event, slots disponibles, gestion du contraste texte).
**Mitigation** : apres `npm install` de la nouvelle version, consulter `node_modules/@malio/layer-ui/COMPONENTS.md` ou le fichier Vue du composant, adapter `SiteSelector.vue` (wrapper) sans toucher au composable `useCurrentSite()`. Le wrapper est le seul point d'adherence a l'API externe.
### Risque 2 — Flash au premier paint
Si `showSiteSelector` est `false` le temps de resoudre `/api/modules`, l'user voit le layout sans selecteur puis avec → flash desagreable. La solution est de bloquer le rendu sur `loaded.value` du composable modules dans le middleware `auth.global.ts` avant que le layout ne soit instancie.
A verifier apres implementation : ouvrir le devtools "Network throttling" en Slow 3G, login, observer. Si flash : ajouter une garde d'attente avant de rendre le layout ou utiliser un skeleton.
### Risque 3 — `auth.user` muté directement
Le composable `switchSite` mute `auth.user.currentSite = site` pour propager le changement au store auth. Pinia autorise cette mutation mais elle contourne les actions formelles. Alternative plus propre : ajouter une action `auth.setCurrentSite(site)` et l'appeler. Choix pragmatique dans cette spec → privilegier la mutation directe pour rester aligne sur le pattern existant (`auth.user.currentSite` est une propriete simple) ; si un reviewer prefere l'action formelle, c'est un changement localisé sans impact autre.
### Risque 4 — Composable singleton et tests
Les refs `currentSite`, `availableSites`, `switching` sont declarees au niveau module → partagees entre tous les appels a `useCurrentSite()`. En Vitest, cela fuit entre tests si on ne fait pas un `beforeEach(() => resetCurrentSite())`. A documenter en tete du fichier de tests pour eviter des bugs inter-tests.
### Risque 5 — Contraste texte et faux positifs
Le seuil de 0.5 sur la luminance peut donner des rendus sous-optimaux sur des couleurs "limite" (ex: vert emeraude `#10B981` a une luminance qui balance pres du seuil). Si un reviewer trouve le texte peu lisible en usage reel, deux options :
- Raffiner le calcul : passer a la formule de contraste WCAG complete (ratio entre fond et texte, seuil a 4.5:1).
- Contraindre la couleur a l'entree : ajouter une validation back (ticket 4 ?) qui rejette les couleurs trop claires si le texte noir donne < 4.5:1 de contraste.
Pour ce ticket, le seuil 0.5 suffit (fixtures testees : `#056CF2` bleu sombre → blanc, `#F59E0B` ambre clair → noir, `#10B981` vert → a voir ; l'admin peut toujours eviter les couleurs pales).
### Risque 6 — Debordement responsive avec 4+ sites
`flex-1` + `min-w-[200px]` + `overflow-x-auto` sur le conteneur gere le debordement de maniere acceptable. Mais sur ecran tres etroit (tablette portrait 768px) avec 4 sites a 200px chacun, le user doit scroller horizontalement — experience sous-optimale.
Alternative : `flex-wrap` + `h-auto` pour laisser les blocs passer a la ligne → le header n'est plus a hauteur fixe 72px. Compromis a trancher selon les usages reels. Ce ticket implemente la solution scroll car la contrainte Figma est "barre de 72px" ; relecture de cette contrainte au ticket 4 si besoin.
### Risque 7 — Auto-selection du currentSite au login si null
Le ticket mentionne : "si currentSite est null et user a ≥1 site, le backend doit avoir auto-selectionne le premier (ou a defaut, faire le switch cote frontend au premier mount du selecteur)".
Le ticket 2 **ne fait pas** d'auto-selection cote backend. Il faut donc gerer cote front : au mount du selecteur, si `currentSite === null && availableSites.length > 0`, appeler `switchSite(availableSites[0])` automatiquement. Cela genere un PATCH au premier chargement d'un user nouvellement rattache — acceptable.
**Alternative** : faire l'auto-selection cote backend au ticket 2. Si cette alternative est choisie en amont, retirer ce comportement cote front. A clarifier au sprint planning.
### Risque 8 — Conflit avec le scroll principal
Le selecteur est dans `flex-1 flex flex-col` au-dessus de `<main>`. `<main>` a `overflow-y-auto` qui permet son propre scroll. Le selecteur est en dehors du `overflow-y-auto` du `<main>` → il reste fige au top quand on scrolle le contenu. Verifier qu'il n'y a pas de collision avec le `sticky top-0 h-8` deja present dans `<main>` (ligne 19-21 de `default.vue`), qui sert de "gradient de lecture" sur le contenu.
## 12. Ordre d'exécution recommandé
1. **Upgrade Malio** — `npm install @malio/layer-ui@<version>`, verifier `node_modules/@malio/layer-ui/COMPONENTS.md` pour la signature de `MalioSiteSelector`.
2. **Utilitaire couleur** — creer `frontend/shared/utils/color.ts` et ses tests. Isole et rapide a valider.
3. **Types** — mettre a jour `frontend/shared/types/user-data.ts` et verifier que `frontend/shared/types/sites.ts` existe (sinon le creer).
4. **Composable modules** — creer `useModules()` et ses tests.
5. **Composable current site** — creer `useCurrentSite()` et ses tests.
6. **Middleware** — brancher `loadModules()` dans `auth.global.ts`.
7. **Composant SiteSelector** — creer `SiteSelector.vue`, implementer wrapper autour de `MalioSiteSelector`, gerer contraste texte.
8. **Tests composant** — smoke test Vitest sur `SiteSelector.vue`.
9. **Integration layout** — modifier `frontend/app/layouts/default.vue`, brancher `showSiteSelector`.
10. **Logout reset** — ajouter `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` dans `frontend/modules/core/pages/logout.vue`.
11. **i18n** — completer `frontend/i18n/locales/fr.json`.
12. **Test visuel** — `make dev-nuxt`, scenarios section 10 "Test visuel manuel".
13. **Nuxt-lint** — `make nuxt-lint`.
14. **Vitest full run** — `make nuxt-test`, s'assurer que 100% des tests passent.
## 13. Critères d'acceptation (DoD)
- [ ] `@malio/layer-ui` upgrade vers la version contenant `MalioSiteSelector`. `package-lock.json` committe.
- [ ] Layer `frontend/modules/sites/` contient bien les dossiers `components/` et `composables/` (layer deja initialise au ticket 2 pour la page admin).
- [ ] `SiteSelector.vue` : hauteur `h-[72px]`, blocs `flex-1 min-w-[200px]`, text uppercase Inter Bold 24, fond = `site.color`, opacity 100% sur actif / 40% sur inactifs, hover 70% + cursor pointer.
- [ ] Contraste texte calcule dynamiquement : `#FFFFFF` → noir, `#056CF2` → blanc, `#F59E0B` → noir (tests Vitest verts).
- [ ] Chaque bloc est un `<button type="button">` avec `aria-pressed` et `aria-label` i18n, focus visible, navigation Tab/Enter fonctionnelle.
- [ ] Integre dans `layouts/default.vue`, rendu conditionnel `isModuleActive('sites') && user.sites.length > 0`.
- [ ] Clic sur un bloc inactif → PATCH `/api/me/current-site` via `useApi`, optimistic update, toast succes.
- [ ] Erreur PATCH → rollback du `currentSite`, toast d'erreur (celui de `useApi` par defaut).
- [ ] Switch persistant : F5 conserve le nouveau site actif.
- [ ] Desactiver `SitesModule::class` dans `config/modules.php` → selecteur absent, layout identique a avant ce ticket.
- [ ] User avec 0 site → selecteur absent (pas de "barre vide").
- [ ] User avec 1 site → selecteur present (1 bloc unique, bloc actif).
- [ ] User avec 4+ sites → scroll horizontal fonctionne, pas de debordement casse a 1280px.
- [ ] `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` appeles au logout.
- [ ] `make nuxt-lint` propre.
- [ ] `make nuxt-test` passe tous les tests (existants + 4 nouveaux suites).
- [ ] `make dev-nuxt` : aucun warning ni erreur console lors du switch et des cycles login/logout.