Files
Coltura/docs/sites/ticket-03-spec.md
tristan 6cf5ef4cfc
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Module sites (#8)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 15:31:58 +00:00

32 KiB

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
    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
    "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

<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()

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) :

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) :

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 :

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 :

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

{
    "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 Malionpm 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 visuelmake dev-nuxt, scenarios section 10 "Test visuel manuel".
  13. Nuxt-lintmake nuxt-lint.
  14. Vitest full runmake 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.