# 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/Starseed/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
- `/home/m-tristan/workspace/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Starseed) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.

## 4. Fichiers à modifier

- `/home/m-tristan/workspace/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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.
