| 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>
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.
-
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 utilitairesparseHex,getRelativeLuminance,getReadableTextColoront été supprimés comme code mort. SeulisValidSiteColor(hex)reste dansshared/utils/color.ts(consommé parSiteDrawer). -
Taille texte explicite
text-2xl(24 px) appliquée vialabelClass. Malio appliquefont-bold uppercase tracking-widesans taille explicite. Le wrapperSiteSelector.vuepasselabelClass="text-2xl"pour garantir les 24 px de la maquette Figma. -
A11y :
ariaGroupLabelau niveau radiogroup au lieu deariaLabelActive/ariaLabelInactivepar tile. La raison : Malio rend déjà unrole="radio"avecaria-checkedpar tile — le lecteur d'écran annonce "bouton radio coché/non coché" + le nom visible. Ajouter unaria-labelpar 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')"surMalioSiteSelector. -
Auto-détection composables des layers dans
nuxt.config.ts. Pas prévu dans la spec. Ajouté carimports.dirsexplicite override les auto-imports par défaut de Nuxt pour les composables de layer. Sans ça,useCurrentSiten'est pas résolu par Nuxt. Scan dynamique aligné sur le patternmoduleLayersexistant. -
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 contenantMalioSiteSelector. La signature exacte du composant (props, slots, events) doit etre lue dansnode_modules/@malio/layer-ui/COMPONENTS.mdapres installation — la spec decrit le contrat attendu, le developpeur adapte selon l'API reelle (cf. Risque 1). - Ajouter les champs
sites: Site[]etcurrentSite: Site | nulldans le typeUserData(frontend/shared/types/user-data.ts) pour refleter le payload/api/meenrichi au ticket 2. - Ajouter le type partage
Sitedansfrontend/shared/types/sites.ts(deja cree au ticket 2, sinon a creer). - Creer le composable
useCurrentSite()dansfrontend/modules/sites/composables/qui exposecurrentSite,availableSites,switchSite(site),resetCurrentSite(). Pattern aligne suruseSidebar(). - Creer le composable
useModules()dansfrontend/shared/composables/qui consomme/api/moduleset exposeisModuleActive(id: string). Necessaire carisModuleActiveest requis par le ticket mais n'existe pas encore cote front. - Creer
SiteSelector.vuedansfrontend/modules/sites/components/: wrapper fin autour deMalioSiteSelectorqui branche le composableuseCurrentSite(), gere l'optimistic update avec rollback, emet un toast de succes/erreur. - Integrer le selecteur dans
frontend/app/layouts/default.vue— render conditionnel surisModuleActive('sites') && user.sites.length > 0. - Appeler
resetCurrentSite()au logout (frontend/modules/core/pages/logout.vue), aligne surresetSidebar()deja present. - Gestion du contraste automatique : le texte du bloc passe en noir ou en blanc selon la luminance de
site.color. Fonction utilitairegetReadableTextColor(hex: string): 'black' | 'white'dansfrontend/shared/utils/color.ts(nouveau fichier utilitaire partage). - Accessibilite : chaque bloc est un
<button>natif avecaria-pressedsur le site courant, focus visible (ring Tailwind), navigation clavier Tab + Enter fonctionnelle. - Responsive minimal :
flex-1sur chaque bloc avecmin-w-[200px]etoverflow-x-autosur le conteneur pour les cas 4+ sites sur petits ecrans. - Tests Vitest : unite sur
useCurrentSite(switch, rollback, reset), unite surgetReadableTextColor, smoke test surSiteSelector.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/mecomplet : le middlewareauth.global.tsgarantit deja queauth.userest 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 deMalioSiteSelector. BrancheuseCurrentSite(), gere l'optimistic update et les toasts./home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/useCurrentSite.ts: composable global exposant l'etatcurrentSite/availableSites, les actionsswitchSite,resetCurrentSite, et un flagswitching: 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/moduleset exposeisModuleActive(id: string): boolean. Pattern aligne suruseSidebar(): 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 :switchSitemet 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. resetCurrentSitevide l'etat.
/home/m-tristan/workspace/Coltura/frontend/shared/composables/__tests__/useModules.spec.ts: Vitest. TestsisModuleActiveapres chargement,resetModulesvide l'etat./home/m-tristan/workspace/Coltura/frontend/shared/utils/__tests__/color.spec.ts: Vitest. Jeu de donnees surgetReadableTextColor:#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-uivers la version qui inclutMalioSiteSelector. Commit dupackage-lock.jsondans le meme changeset./home/m-tristan/workspace/Coltura/frontend/shared/types/user-data.ts: ajouter les champsImport du typesites: Site[] currentSite: Site | nullSitedepuis./sites. Note : si le typeSitea deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dansfrontend/shared/types/sites.ts./home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts: si absent, creer avec l'interfaceSite(cf. section Schema ticket 2 pour la forme). Si present, aucune modification./home/m-tristan/workspace/Coltura/frontend/app/layouts/default.vue: integrerSiteSelectorsous le header, avant<main>, dans le flex column. Rendu conditionnel viav-if="showSiteSelector"ou via undefineAsyncComponentchargement 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 deuseModules().loadModules()apresloadSidebar(). Necessaire pour queisModuleActivesoit resolu quand le layout se rend./home/m-tristan/workspace/Coltura/frontend/modules/core/pages/logout.vue: appeleruseCurrentSite().resetCurrentSite()etuseModules().resetModules()apres leauth.logout(), aligne sur le patternresetSidebar()deja present./home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json: ajouter les clesNe pas mettre le nom du site en cle i18n : le nom est une donnee metier, pas un label."sites": { "selector": { "switchSuccess": "Site courant change", "switchError": "Impossible de changer de site", "ariaLabelActive": "Site actif : {name}", "ariaLabelInactive": "Basculer sur le site {name}" } }
5. Schéma cible — Composant SiteSelector.vue
Render attendu (conforme Figma)
- Hauteur fixe :
h-[72px]. width: 100%(parent du<main>danslayouts/default.vue, donc occupe toute la zone a droite de la sidebar).- Flex horizontal, chaque bloc =
flex-1avecmin-w-[200px]. - Conteneur parent :
overflow-x-autopour 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 pargetReadableTextColor(site.color). - Opacite :
opacity-100pour le site courant,opacity-40pour 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-labeldynamique via i18n (sites.selector.ariaLabelActiveouariaLabelInactive).
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.vueest toujours dans le bundle) : simple, lev-ifgere 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'uppercaseest applique en CSS (text-transform: uppercase), pas dans la donnee. - Les
aria-labelinterpollent{name}directement. switchErrorest consomme par le toast d'erreur deuseApisi 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>avecrole="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 ajoutertabindex="-1"sur le bloc courant : l'user doit pouvoir revenir dessus.
10. Plan de tests
Vitest — useCurrentSite.spec.ts
switchSite met a jour currentSite localement immediatement: avant la resolution de la promise,currentSite.valueest deja le nouveau site.switchSite persiste via /api/me/current-site: mockuseApi, verifier que la requete PATCH est appelee avecsite: '/api/sites/{id}'.switchSite rollback en cas d'erreur: mockuseApipour rejeter, verifier quecurrentSite.valuerepasse a l'ancien site.switchSite propagate au store auth apres succes:auth.user.currentSiteest mis a jour apres succes.resetCurrentSite vide l'etat: apres appel,currentSite = null,availableSites = [],switching = false.switching est vrai pendant la requete, faux apres: verifier le flag sur tout le cycle.double switchSite concurrent est ignore: siswitching = true, un second appel retourne immediatement sans effet (garde anti-double-submit).
Vitest — useModules.spec.ts
loadModules charge /api/modules et alimente activeModuleIds.isModuleActive retourne true si l'id est present, false sinon.resetModules vide l'etat.loadModules swallow les erreurs et laisse activeModuleIds vide(alignement avecuseSidebar).
Vitest — color.spec.ts
getReadableTextColor('#FFFFFF') === 'black'.getReadableTextColor('#000000') === 'white'.getReadableTextColor('#056CF2') === 'white'(bleu sature).getReadableTextColor('#F59E0B') === 'black'(ambre clair).getReadableTextColor('#10B981') === 'white'(vert medium-foncé). A verifier a l'implementation ; adapter l'assertion.parseHex('red') → throw(format invalide).parseHex('#FFF') → throw(hex court non supporte).parseHex('#abcdef')etparseHex('#ABCDEF')→ meme resultat (tolere la casse).
Vitest — SiteSelector.spec.ts
Rendu : 3 sites rendus, bloc du site courant a opacity-100.Bloc inactif a opacity-40 et aria-pressed="false".Clic sur un bloc inactif appelle switchSite avec le bon site.Si switchSite throw, l'UI affiche toujours l'ancien site courant(via rollback).Texte d'un site avec couleur claire (#FFFFFF) est rendu noir.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
alicevia/admin/users→ login alice → selecteur absent. - Desactiver
SitesModule::classdansconfig/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é
- Upgrade Malio —
npm install @malio/layer-ui@<version>, verifiernode_modules/@malio/layer-ui/COMPONENTS.mdpour la signature deMalioSiteSelector. - Utilitaire couleur — creer
frontend/shared/utils/color.tset ses tests. Isole et rapide a valider. - Types — mettre a jour
frontend/shared/types/user-data.tset verifier quefrontend/shared/types/sites.tsexiste (sinon le creer). - Composable modules — creer
useModules()et ses tests. - Composable current site — creer
useCurrentSite()et ses tests. - Middleware — brancher
loadModules()dansauth.global.ts. - Composant SiteSelector — creer
SiteSelector.vue, implementer wrapper autour deMalioSiteSelector, gerer contraste texte. - Tests composant — smoke test Vitest sur
SiteSelector.vue. - Integration layout — modifier
frontend/app/layouts/default.vue, branchershowSiteSelector. - Logout reset — ajouter
useCurrentSite().resetCurrentSite()etuseModules().resetModules()dansfrontend/modules/core/pages/logout.vue. - i18n — completer
frontend/i18n/locales/fr.json. - Test visuel —
make dev-nuxt, scenarios section 10 "Test visuel manuel". - Nuxt-lint —
make nuxt-lint. - Vitest full run —
make nuxt-test, s'assurer que 100% des tests passent.
13. Critères d'acceptation (DoD)
@malio/layer-uiupgrade vers la version contenantMalioSiteSelector.package-lock.jsoncommitte.- Layer
frontend/modules/sites/contient bien les dossierscomponents/etcomposables/(layer deja initialise au ticket 2 pour la page admin). SiteSelector.vue: hauteurh-[72px], blocsflex-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">avecaria-pressedetaria-labeli18n, focus visible, navigation Tab/Enter fonctionnelle. - Integre dans
layouts/default.vue, rendu conditionnelisModuleActive('sites') && user.sites.length > 0. - Clic sur un bloc inactif → PATCH
/api/me/current-siteviauseApi, optimistic update, toast succes. - Erreur PATCH → rollback du
currentSite, toast d'erreur (celui deuseApipar defaut). - Switch persistant : F5 conserve le nouveau site actif.
- Desactiver
SitesModule::classdansconfig/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()etuseModules().resetModules()appeles au logout.make nuxt-lintpropre.make nuxt-testpasse tous les tests (existants + 4 nouveaux suites).make dev-nuxt: aucun warning ni erreur console lors du switch et des cycles login/logout.