feat(design): livrer les lots G8 VIS-00 à VIS-04
Refonte visuelle du produit jusqu'au pilote fonctionnel. Aucune route, API, permission, étape métier ni limite Premium n'est modifiée. VIS-00 — anomalies transverses : header et bandeau cookies contenus à 320/390 px, Kal sorti du terracotta Hub, couleurs brutes remplacées par les tokens. VIS-01 — tokens arrêtés dans tailwind.config.ts et index.css (sept rôles typographiques, neutres par rôle, espacements, conteneurs, rayons, élévations, durées), verrouillés par designTokens.test.ts et démontrés par /style-tile (noindex, hors sitemap et pré-rendu). Bricolage Grotesque auto-hébergée. VIS-02 — primitives partagées : btn-quiet/btn-icon, card-notice/interactive/ summary/configuration, badges sémantiques, Field et Badge ; Modal, EmptyState, MetaChip, ConfirmModal, ProgressBar et ResourceCard migrés aux tokens. VIS-03 — shells : shellVariant.ts devient la source unique du contexte public / workspace, une racine de service dépendant aussi de la session. Rail de collaboration, saisie de code et pages éditoriales factorisés. Anneau de focus posé une seule fois, btn-primary au cran 700 pour tenir AA. VIS-04 — Hub pilote : ModuleGroup et ServiceToolTile portent l'assemblage et le choix d'outil, le démarrage d'un Hub vide devient de l'aide contextuelle, une seule action dominante par colonne, le type d'événement passe en métadonnée (emojis retirés des catalogues, EVENT_TYPE_ICONS supprimé) et les formulaires Hub prennent des libellés persistants. Le champ de partage reçoit un nom accessible. Validations : check:i18n vert, TypeScript 0 erreur, 119 Vitest (28 fichiers), Playwright 27 passed, build Docker complet (60 pages SSG, check:marketing et check:prerender conformes). Chunk initial 148 163 -> 148 690 o gzip pour tout G8. Le checkpoint « Pilote » reste ouvert : VIS-05 à VIS-07 attendent la revue Hub desktop et mobile du propriétaire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
19b04ed65b
commit
3bab4553b2
114 changed files with 5808 additions and 972 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -14,6 +14,10 @@ node_modules/
|
|||
dist/
|
||||
.DS_Store
|
||||
|
||||
# Sorties Playwright (captures et traces des recettes visuelles)
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
certbot/
|
||||
*.log
|
||||
|
||||
|
|
|
|||
22
AGENTS.md
22
AGENTS.md
|
|
@ -7,13 +7,33 @@ Les deux agents doivent appliquer les mêmes sources de vérité :
|
|||
2. consulter par défaut `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/MEMORY.md`, puis uniquement les mémoires thématiques utiles au chantier ;
|
||||
3. lire `docs/ROADMAP.md` avant de commencer un chantier lié à l'audit ;
|
||||
4. consulter `docs/audits/2026-08-code-produit.md` pour le diagnostic et les preuves ;
|
||||
5. suivre `docs/AI_COLLABORATION.md` pour les passations et mises à jour de statut.
|
||||
5. pour tout chantier visuel, lire
|
||||
`docs/audits/2026-08-style-visuel-produit.md` puis appliquer
|
||||
`docs/DESIGN_SYSTEM.md` ;
|
||||
6. suivre `docs/AI_COLLABORATION.md` pour les passations et mises à jour de statut.
|
||||
|
||||
## Règle de suivi
|
||||
|
||||
- Le code et les migrations font foi sur le comportement réel.
|
||||
- La mémoire `.claude` apporte le contexte historique ; en cas de divergence, le code et les documents versionnés les plus récents prévalent.
|
||||
- `docs/ROADMAP.md` est la source unique pour l'avancement de l'audit.
|
||||
- Le jalon G8 suit toute refonte visuelle ; G4/G6 restent une référence à
|
||||
préserver, pas un périmètre à réécrire.
|
||||
- Depuis VIS-01, les tokens de design sont arrêtés dans
|
||||
`frontend/tailwind.config.ts` et `frontend/src/index.css`, verrouillés par
|
||||
`frontend/src/shared/config/designTokens.test.ts` et démontrés par la route
|
||||
interne `/style-tile`. Absorber un besoin répété dans un token existant ; en
|
||||
ajouter un suppose de modifier `docs/DESIGN_SYSTEM.md` et ce test dans la même
|
||||
modification.
|
||||
- Depuis VIS-03, `frontend/src/shared/config/shellVariant.ts` est la source unique
|
||||
du contexte de shell (`public` / `workspace`) : Navbar, bandeau de confidentialité
|
||||
et Footer en dérivent leur conteneur. Une racine de service dépend en plus de la
|
||||
session, `ServiceRoute` y rendant la vitrine ou le tableau de bord.
|
||||
- Depuis VIS-04, l'assemblage d'un Hub passe par `ModuleGroup` (groupe de modules
|
||||
d'un même service) et `ServiceToolTile` (choix d'un outil) ; une colonne
|
||||
d'assemblage ne porte qu'une action principale et le démarrage d'un Hub vide est
|
||||
de l'aide contextuelle, jamais un aplat de service. Les lots de migration
|
||||
suivants réutilisent ces composants au lieu de recomposer un groupe.
|
||||
- Ne jamais marquer un item `DONE` sans satisfaire ses critères d'acceptation et
|
||||
inscrire une preuve vérifiable dans la roadmap (tests, commit, PR ou fichiers).
|
||||
- Mettre à jour la documentation dans la même modification que le code concerné.
|
||||
|
|
|
|||
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -6,6 +6,8 @@ Les deux agents utilisent les mêmes sources de vérité :
|
|||
|
||||
- `AGENTS.md` — point d'entrée Codex et règles communes ;
|
||||
- `docs/audits/2026-08-code-produit.md` — audit daté et preuves ;
|
||||
- `docs/audits/2026-08-style-visuel-produit.md` — audit visuel validé après G4 ;
|
||||
- `docs/DESIGN_SYSTEM.md` — direction visuelle et contrat de design de G8 ;
|
||||
- `docs/ROADMAP.md` — source unique des priorités et de l'avancement ;
|
||||
- `docs/AI_COLLABORATION.md` — protocole de prise en charge et de passation.
|
||||
|
||||
|
|
@ -14,8 +16,67 @@ Avant tout chantier issu de l'audit, lire la roadmap et passer l'item concerné
|
|||
et des preuves renseignées. Mettre à jour la roadmap et la documentation technique
|
||||
dans la même modification que le code.
|
||||
|
||||
**Priorité active :** le jalon G0 sécurité critique précède la refonte marketing,
|
||||
le SEO, la growth et toute nouvelle fonctionnalité.
|
||||
**Priorité visuelle active :** G8 est en cours. VIS-00 à VIS-04 sont terminés.
|
||||
Le checkpoint « Fondations » est **validé** (12 août 2026). Le checkpoint
|
||||
« Pilote » est **ouvert** : le Hub est livré, mais VIS-05, VIS-06 et VIS-07
|
||||
attendent la revue desktop et mobile du propriétaire avant le déploiement aux cinq
|
||||
services. Tout chantier de style doit prendre un item G8, préserver G4/G6 et suivre
|
||||
`docs/DESIGN_SYSTEM.md`. G3 reste `TODO` et indépendant de la refonte.
|
||||
|
||||
**Assemblage Hub (VIS-04) :** `ModuleGroup` est la seule composition d'un groupe de
|
||||
modules (scope de thème local, icône canonique, titre de section, décompte) et
|
||||
`ServiceToolTile` le seul choix d'outil ; les quatre lots de migration suivants les
|
||||
réutilisent. Une colonne d'assemblage ne porte **qu'une** action principale ; le
|
||||
démarrage d'un Hub vide est de l'aide contextuelle (`card-notice`), jamais un aplat
|
||||
de service. Le type d'événement est une métadonnée (`badge-meta`) : les catalogues
|
||||
`common.json` n'ont plus d'emoji et `EVENT_TYPE_ICONS` n'existe plus. `alert-warning`
|
||||
porte la restriction actionnable (capacité freemium), `card-notice` l'aide permanente.
|
||||
|
||||
**Shells (VIS-03) :** `frontend/src/shared/config/shellVariant.ts` est la source
|
||||
unique du contexte. `getShellVariant(pathname, authenticated)` renvoie `public` ou
|
||||
`workspace` et `shellContainerClass()` en dérive `shell-container` + la largeur ;
|
||||
Navbar, bandeau de confidentialité et Footer l'appellent, leur fond restant pleine
|
||||
largeur. Une racine de service (`/hub`, `/kdo`…) dépend **aussi de la session** :
|
||||
`ServiceRoute` y rend la vitrine hors session et le tableau de bord en session. Ne
|
||||
jamais recalculer un conteneur de chrome dans un composant. Le rail de
|
||||
collaboration passe par `CollaborationPanel` (`card-collaboration`), les pages de
|
||||
lecture longue par `page-editorial`, la saisie de code par `VerifyCodeForm` /
|
||||
`VerifyCodeStep`. La police de titre est portée par les rôles
|
||||
`text-display`/`page-title`/`section-title`, jamais par une page.
|
||||
|
||||
**Primitives partagées (VIS-02) :** `frontend/src/index.css` expose `btn-quiet`,
|
||||
`btn-icon`, `card-notice`, `card-interactive`, `card-summary`, `card-configuration`
|
||||
et les badges sémantiques (`badge-service`/`success`/`warning`/`danger`/`info`/`meta`).
|
||||
`Field.tsx` et `Badge.tsx` sont exportés depuis `shared/components/index.ts`.
|
||||
`Modal`, `EmptyState`, `MetaChip`, `ConfirmModal`, `ProgressBar`, `ResourceCard`
|
||||
utilisent les tokens `ink-*`, `surface-*`, `line-*`, `rounded-control/card`,
|
||||
`duration-micro/control`. Toute nouvelle page ou composant doit utiliser ces
|
||||
primitives plutôt que de recréer ses propres classes génériques. VIS-03 y ajoute
|
||||
`.link` (lien textuel souligné), `card-collaboration`, les primitives éditoriales
|
||||
et `ProgressBar tone="quota"` ; `.btn-primary` prend son fond au cran **700** car
|
||||
le blanc sur `primary-600` de marque ne tient pas AA, et l'anneau de focus est posé
|
||||
une seule fois en couche `base` sur `:focus-visible`.
|
||||
|
||||
**Tokens de design (VIS-01) :** `frontend/tailwind.config.ts` et
|
||||
`frontend/src/index.css` portent les sept rôles typographiques, les neutres
|
||||
nommés par rôle (`ink-*`, `surface-*`, `line-*`), les cinq espacements de
|
||||
composition résolus par variable CSS, les quatre conteneurs, les quatre rayons,
|
||||
les trois élévations et les trois durées. Un besoin répété s'absorbe dans un
|
||||
token existant ; en ajouter un suppose de modifier `docs/DESIGN_SYSTEM.md` et
|
||||
`frontend/src/shared/config/designTokens.test.ts` dans la même modification.
|
||||
Contrainte de contraste vérifiée par ce test : tout rôle de texte tient 4,5:1 sur
|
||||
les surfaces claires, `ink-faint` n'est pas un rôle de texte, et sur une surface
|
||||
teintée le rôle remonte d'un cran. La route interne `/style-tile` démontre le
|
||||
contrat (`noindex`, hors sitemap et pré-rendu) ; la police Bricolage Grotesque est
|
||||
retenue et auto-hébergée mais n'est appliquée nulle part ailleurs avant VIS-02/03.
|
||||
|
||||
**Règle du remplissage :** seul un état sémantique remplit une surface de couleur.
|
||||
L'identité de service marque un bord, elle n'occupe pas le fond — trois palettes
|
||||
frôlent un token sémantique et les deux nuances claires de Kal sont exactement
|
||||
`info-50`/`info-100`. L'aide contextuelle utilise `surface-notice` (sable de
|
||||
marque, universel), jamais `info-*`, réservé à l'alerte transitoire.
|
||||
`surface-accent` n'est admis que sur un élément dont les frères non sélectionnés
|
||||
sont visibles, jamais sur un bloc isolé.
|
||||
|
||||
Plateforme de services du quotidien (wishlist, sondages, Secret Santa, partage de frais...).
|
||||
Alternatives éthiques aux outils grand public, hébergées sur serveur privé, RGPD-friendly.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ agent puisse être repris sans perte de contexte par l'autre.
|
|||
| Comportement applicatif | Code, migrations et tests |
|
||||
| Architecture et invariants | `CLAUDE.md` |
|
||||
| Audit daté | `docs/audits/2026-08-code-produit.md` |
|
||||
| Audit visuel daté | `docs/audits/2026-08-style-visuel-produit.md` |
|
||||
| Direction visuelle | `docs/DESIGN_SYSTEM.md` |
|
||||
| Avancement et priorités | `docs/ROADMAP.md` |
|
||||
| Mémoire Claude Code | `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/` |
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ référence commune.
|
|||
|
||||
1. Lire `git status` et préserver les modifications de l'autre agent.
|
||||
2. Lire l'item de roadmap visé, ses dépendances et ses critères d'acceptation.
|
||||
Pour G8, lire également l'audit visuel et le contrat de design.
|
||||
3. Passer son statut à `IN_PROGRESS` et renseigner `Agent` et `Début`.
|
||||
4. Ajouter une courte note dans le journal si une hypothèse ou décision est prise.
|
||||
5. Ne traiter qu'un périmètre testable ; créer un nouvel item si le besoin dépasse
|
||||
|
|
|
|||
594
docs/DESIGN_SYSTEM.md
Normal file
594
docs/DESIGN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
# Kankwa — direction visuelle et contrat de design
|
||||
|
||||
Statut : cible validée le 12 août 2026
|
||||
Chantier associé : G8 dans `docs/ROADMAP.md`
|
||||
Diagnostic : `docs/audits/2026-08-style-visuel-produit.md`
|
||||
|
||||
Ce document est la source normative de la refonte visuelle. Il décrit ce que le
|
||||
produit doit exprimer et les règles que l'implémentation Tailwind doit respecter.
|
||||
Il ne prétend pas que tous les composants décrits existent déjà.
|
||||
|
||||
**Depuis VIS-01, les tokens sont arrêtés et implémentés.** Ils vivent dans
|
||||
`frontend/tailwind.config.ts` et `frontend/src/index.css` ; leur contrat est
|
||||
verrouillé par `frontend/src/shared/config/designTokens.test.ts`, qui recalcule
|
||||
notamment les contrastes. La route interne `/style-tile` les démontre en public,
|
||||
fonctionnel, formulaire et grille dense — hors sitemap, hors pré-rendu, `noindex`
|
||||
et refusée dans `robots.txt`. Un lot de migration doit absorber un besoin répété
|
||||
dans un token existant ; en ajouter un suppose de modifier ce document et le test
|
||||
dans la même modification.
|
||||
|
||||
## Intention : Chaleur utile
|
||||
|
||||
Kankwa aide des personnes à organiser ensemble des moments réels. Son interface
|
||||
doit évoquer la simplicité, l'attention aux autres et le plaisir de voir les
|
||||
choses avancer, pas un logiciel de gestion d'entreprise.
|
||||
|
||||
La direction **Chaleur utile** associe :
|
||||
|
||||
- une structure calme et lisible ;
|
||||
- les neutres chauds de la marque ;
|
||||
- une couleur propre à chaque service ;
|
||||
- des surfaces dont le traitement indique le rôle ;
|
||||
- une typographie plus présente dans le produit ;
|
||||
- de petites réactions visuelles qui rendent l'action agréable ;
|
||||
- une densité maîtrisée, notamment sur Kal et Kount.
|
||||
|
||||
## Principes de conception
|
||||
|
||||
### 1. L'encre structure
|
||||
|
||||
Le noir chaud de G4 porte les titres, la navigation, les zones de contexte et les
|
||||
textes importants. Il remplace le noir ou gris froid lorsqu'aucun sens sémantique
|
||||
ne l'interdit.
|
||||
|
||||
### 2. La crème respire
|
||||
|
||||
Le fond général reste chaleureux. Le blanc est réservé aux surfaces qui doivent
|
||||
réellement émerger ; il n'est pas la réponse automatique à chaque groupement.
|
||||
|
||||
### 3. La couleur de service guide
|
||||
|
||||
La palette active identifie l'espace, met en évidence l'action principale, les
|
||||
sélections et certains repères de progression. Elle n'indique jamais à elle seule
|
||||
un succès, un avertissement ou une erreur — et, réciproquement, elle ne doit pas
|
||||
pouvoir être **prise** pour l'un d'eux : elle marque un bord, elle ne remplit pas
|
||||
un bloc. Voir « Règle du remplissage ».
|
||||
|
||||
### 4. Une surface exprime un rôle
|
||||
|
||||
Ressource, interaction, résumé, collaboration, configuration, aide et état vide
|
||||
possèdent des traitements reconnaissables. Deux composants qui ont la même forme
|
||||
doivent remplir le même rôle. Les sept traitements arrêtés en VIS-01 :
|
||||
|
||||
| Rôle | Traitement |
|
||||
|---|---|
|
||||
| `resource` | `surface-raised`, bordure `line`, `rounded-card`, `shadow-raised` |
|
||||
| `interactive` | `surface-raised`, bordure `line-accent`, anneau `primary-200` |
|
||||
| `summary` | `surface-inverse`, texte `ink-inverse` |
|
||||
| `collaboration` | `surface-raised` + filet gauche `primary-600` |
|
||||
| `configuration` | `surface-muted`, bordure `line` |
|
||||
| `notice` | `surface-notice`, sans bordure, avec icône |
|
||||
| `empty` | `surface-sunken`, bordure pointillée `line-strong` |
|
||||
|
||||
Un rôle se reconnaît à sa combinaison forme + bord + fond, jamais à une teinte
|
||||
seule.
|
||||
|
||||
### 5. Les données restent humaines
|
||||
|
||||
Les montants, disponibilités, votes et quantités sont lisibles sans faire de
|
||||
Kankwa une banque ou une feuille de calcul. Le résumé et la prochaine action
|
||||
priment sur la précision décorative.
|
||||
|
||||
### 6. Les détails donnent du plaisir
|
||||
|
||||
Focus visible, sélection franche, survol léger, progression fluide et confirmation
|
||||
claire. Les animations restent courtes, utiles et désactivables.
|
||||
|
||||
### 7. La composition crée l'impact
|
||||
|
||||
L'interface ne dépend pas de gradients systématiques, de glassmorphism, de fortes
|
||||
ombres ou d'illustrations décoratives. L'impact vient de la typographie, des
|
||||
proportions, des respirations et de la couleur.
|
||||
|
||||
## Architecture des couleurs
|
||||
|
||||
Le système possède quatre couches strictes :
|
||||
|
||||
1. **Marque Kankwa** : encre, crème, blancs chauds et terracotta de référence.
|
||||
2. **Service** : Hub, Kdo, Kal, Kwiz, Kontrib ou Kount via
|
||||
`serviceThemes.ts` et `ServiceThemeScope`.
|
||||
3. **Sémantique** : succès, avertissement, danger et information, universels.
|
||||
4. **Neutres fonctionnels** : texte, bordure, surface atténuée et état désactivé.
|
||||
|
||||
Les neutres fonctionnels sont nommés par rôle depuis VIS-01. `ink` porte le
|
||||
texte, `surface` le fond, `line` la bordure ; la variante `accent` de chacun suit
|
||||
la palette de service active et ne fige donc aucun hexadécimal :
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|---|---|---|
|
||||
| `ink` | `#1A1F2E` | titres et texte fort |
|
||||
| `ink-soft` | `#2C3140` | texte dense important |
|
||||
| `ink-muted` | `#4A4F5C` | texte courant secondaire |
|
||||
| `ink-subtle` | `#656974` | métadonnée réellement secondaire |
|
||||
| `ink-faint` | `#8B8F99` | désactivé, placeholder — **jamais** essentiel |
|
||||
| `ink-inverse` | `#FFFFFF` | texte sur `surface-inverse` |
|
||||
| `surface-page` | `#FAFAF7` | fond général |
|
||||
| `surface-raised` | `#FFFFFF` | surface qui doit émerger |
|
||||
| `surface-muted` | `#F0EDE7` | groupement calme, survol léger |
|
||||
| `surface-sunken` | `#F5F2EC` | zone de contexte en creux |
|
||||
| `surface-inverse` | `#1A1F2E` | résumé dominant |
|
||||
| `surface-notice` | `#E8DCCB` | aide contextuelle persistante — sable de marque |
|
||||
| `line` / `line-subtle` / `line-strong` | `#E5E3DD` / `#F0EDE7` / `#C8CAD0` | séparation, séparation interne, contour |
|
||||
|
||||
Un seul rôle introduit une valeur nouvelle. L'ardoise historique `#8B8F99`
|
||||
(`gray-400/500`) ne donne que 3,2:1 sur la crème et 2,8:1 sur `surface-muted` :
|
||||
elle ne peut pas porter du texte secondaire. `ink-subtle` est donc assombri
|
||||
jusqu'à passer AA sur les quatre surfaces claires, et l'ardoise redescend au rang
|
||||
`ink-faint`, réservé au désactivé et au non textuel.
|
||||
|
||||
### Règle du remplissage (revue du checkpoint « Fondations »)
|
||||
|
||||
**Seul un état sémantique remplit une surface de couleur.** Un aplat teinté ne
|
||||
porte aucun sens propre : il hérite du sens de sa teinte. Trois palettes de
|
||||
service sont proches d'un token sémantique — Kontrib du danger, Kdo de
|
||||
l'avertissement, Kwiz du succès — et les deux nuances claires de Kal sont
|
||||
*exactement* `info-50` et `info-100`. Un panneau rempli à `primary-50` se lit donc
|
||||
comme une erreur sous Kontrib, et une aide bleue est indiscernable de Kal.
|
||||
|
||||
Il en découle :
|
||||
|
||||
- l'identité de service **marque** — bord, filet, icône, titre en `ink-accent` —
|
||||
elle **n'occupe pas** le fond d'un bloc ;
|
||||
- `surface-accent` reste autorisée pour un élément dont les frères non
|
||||
sélectionnés sont visibles (choix, onglet, puce). Elle y signifie « celui-ci »,
|
||||
jamais « attention ». Elle ne remplit jamais un bloc isolé ;
|
||||
- l'aide contextuelle utilise `surface-notice`, teinte de marque qu'aucun service
|
||||
ne peut revendiquer, et non `info-*` ;
|
||||
- `info-*` reste réservé à l'alerte informative transitoire (`alert-info`), qui
|
||||
annonce un état et non un contexte permanent.
|
||||
|
||||
`designTokens.test.ts` vérifie la collision `info` / Kal et l'appartenance de
|
||||
`surface-notice` à la marque ; `StyleTile.test.tsx` vérifie que les rôles
|
||||
`collaboration` et `notice` n'utilisent aucun aplat de service.
|
||||
|
||||
Règles :
|
||||
|
||||
- tout rôle de texte (`ink`, `ink-soft`, `ink-muted`, `ink-subtle`) tient 4,5:1
|
||||
sur `surface-page`, `raised`, `muted` et `sunken` ; `ink-faint` ne tient que le
|
||||
seuil non textuel de 3:1 et seulement sur `page` et `raised` ;
|
||||
- sur une surface teintée (`surface-accent`), le rôle de texte remonte d'un cran :
|
||||
`ink-subtle` y échoue, `ink-muted` passe ;
|
||||
- aucun hexadécimal de marque ou service dans un composant métier ;
|
||||
- aucun `red-*`, `amber-*`, `emerald-*` ou équivalent local lorsqu'un token
|
||||
sémantique existe ;
|
||||
- une modale portalisée reçoit explicitement son `serviceId` lorsqu'elle appartient
|
||||
à un service ;
|
||||
- une surface générique sans contexte métier utilise la marque Kankwa ;
|
||||
- Hub peut montrer les accents des services sur les modules, mais ses actions
|
||||
structurelles restent dans la palette Hub.
|
||||
|
||||
## Typographie
|
||||
|
||||
### Rôles cibles
|
||||
|
||||
Les sept rôles sont arrêtés et implémentés (`theme.extend.fontSize`). Chacun
|
||||
embarque sa hauteur de ligne, son interlettrage et sa graisse : appeler le rôle
|
||||
suffit, il n'y a plus de `text-2xl font-bold tracking-tight` à recomposer.
|
||||
|
||||
| Rôle | Classe | Rendu 320 → 1440 px | Usage |
|
||||
|---|---|---:|---|
|
||||
| `display` | `text-display` | 40 → 72 px, 700 | heroes marketing uniquement |
|
||||
| `page-title` | `text-page-title` | 28 → 36 px, 700 | titre d'un espace ou d'une ressource |
|
||||
| `section-title` | `text-section-title` | 20 → 24 px, 600 | groupe fonctionnel important |
|
||||
| `card-title` | `text-card-title` | 17 px, 600 | ressource ou panneau |
|
||||
| `body` | `text-body` | 16 px, 1.55 | contenu et actions courantes |
|
||||
| `body-sm` | `text-body-sm` | 14 px | aide secondaire |
|
||||
| `meta` | `text-meta` | 13 px | date, compteur ou détail réellement secondaire |
|
||||
|
||||
Les deux rôles les plus larges sont fluides (`clamp`) : ils tiennent de 320 à
|
||||
1440 px sans breakpoint local. Les cinq autres sont fixes, car une taille de lecture
|
||||
qui bouge avec la fenêtre nuit plus qu'elle n'aide.
|
||||
|
||||
`meta` est le plus petit rôle autorisé : `text-xs` ne porte jamais seul une
|
||||
information essentielle ou une action principale. La hiérarchie dépend d'abord du
|
||||
rôle, pas de classes choisies page par page.
|
||||
|
||||
### Police — décision VIS-01
|
||||
|
||||
**Bricolage Grotesque est retenue comme police de titre**, auto-hébergée et
|
||||
réservée aux titres. La pile système reste la police de texte : `font-sans` est
|
||||
déclarée explicitement plutôt que laissée au défaut Tailwind, afin que le rendu
|
||||
courant ne dépende plus d'un ordre implicite.
|
||||
|
||||
Coût mesuré, pas estimé :
|
||||
|
||||
- fichier variable latin de `@fontsource-variable/bricolage-grotesque` : 41 344 o ;
|
||||
- axe `wght` figé à 600–800 (`fontTools instantiateVariableFont`), le produit
|
||||
n'ayant aucun titre plus léger : **37 332 o** (36,5 Ko), un seul fichier, une
|
||||
seule requête ;
|
||||
- `unicode-range` limité au latin : aucune requête pour les autres alphabets ;
|
||||
- `font-display: swap` : le texte est lisible immédiatement.
|
||||
|
||||
Repli système stable, métriques réalignées pour que la substitution `swap` ne
|
||||
déplace pas la mise en page. Les valeurs sont mesurées : Bricolage rend le même
|
||||
échantillon à **100,85 %** de la largeur d'Arial sous Chromium, et sa table `hhea`
|
||||
donne ascent 93 % / descent 27 % / line-gap 0 % pour 1000 unités par em. Rapportés
|
||||
à l'em déjà mis à l'échelle, les overrides de `Kankwa Display Fallback` valent donc
|
||||
`size-adjust: 100.85%`, `ascent-override: 92.22%`, `descent-override: 26.77%`,
|
||||
`line-gap-override: 0%` (cf. `frontend/src/index.css`).
|
||||
|
||||
**Déploiement VIS-03** : la face est portée par le *rôle*, jamais par la page.
|
||||
`text-display`, `text-page-title` et `text-section-title` reçoivent `font-display`
|
||||
dans `index.css` ; appeler le rôle suffit et aucun écran ne peut se retrouver avec
|
||||
un titre dans une autre police. `card-title`, `body`, `body-sm` et `meta` restent
|
||||
sur la pile système pour que les listes denses gardent leur calme. `font-sans`,
|
||||
déclaré en couche `utilities`, reste l'échappatoire : c'est ce qui permet au style
|
||||
tile de comparer les deux piles par simple retrait (`.type-specimen-system`).
|
||||
|
||||
## Espacement et conteneurs
|
||||
|
||||
Les noms sont arrêtés. Les cinq espacements sont résolus par variable CSS
|
||||
(`--space-*`, définie dans `frontend/src/index.css`) plutôt que par une valeur
|
||||
figée : `px-gutter` ou `gap-cards` suivent le viewport sans qu'un composant ait à
|
||||
déclarer ses propres breakpoints.
|
||||
|
||||
| Token | < 640 px | ≥ 640 px | ≥ 1024 px | Usage |
|
||||
|---|---:|---:|---:|---|
|
||||
| `gutter` | 16 px | 24 px | 32 px | gouttière de page |
|
||||
| `section` | 32 px | 40 px | 56 px | espace entre sections |
|
||||
| `cards` | 12 px | 16 px | 20 px | espace entre cartes |
|
||||
| `panel` | 16 px | 20 px | 24 px | espace interne de panneau |
|
||||
| `compact` | 8 px | 8 px | 8 px | groupe d'actions ou de métadonnées |
|
||||
|
||||
Les valeurs mobiles reprennent la gouttière existante de `.page` (16 px) : les
|
||||
adopter ne déplace aucun écran déjà livré.
|
||||
|
||||
| Conteneur | Largeur | Usage |
|
||||
|---|---:|---|
|
||||
| `max-w-public` | 72 rem | homepage, vitrines et landings |
|
||||
| `max-w-workspace` | 80 rem | espaces fonctionnels |
|
||||
| `max-w-editorial` | 48 rem | lecture longue |
|
||||
| `max-w-form` | 32 rem | formulaire isolé |
|
||||
|
||||
`max-w-public` vaut exactement `max-w-6xl`, la largeur déjà employée par G4/G6 :
|
||||
adopter le token ne déplace aucune page publique.
|
||||
|
||||
Une marge négative ne doit jamais supposer une gouttière différente de son parent.
|
||||
Les alignements Navbar, contenu et Footer utilisent les mêmes conteneurs dans un
|
||||
même contexte.
|
||||
|
||||
## Rayons, bordures et élévation
|
||||
|
||||
Quatre niveaux de rayon, arrêtés :
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|---|---:|---|
|
||||
| `rounded-control` | 8 px | champ, bouton, case |
|
||||
| `rounded-panel` | 12 px | panneau compact |
|
||||
| `rounded-card` | 16 px | carte |
|
||||
| `rounded-editorial` | 24 px | composition éditoriale ou hero |
|
||||
|
||||
`rounded-component`, employé par les classes globales existantes, reste un alias
|
||||
de `rounded-control` : aucune classe en place ne casse. `rounded-card` passe en
|
||||
revanche de 12 à 16 px, changement produit assumé qui distingue enfin la carte du
|
||||
panneau.
|
||||
|
||||
Les valeurs arbitraires ne sont autorisées que pour un besoin visuel documenté.
|
||||
|
||||
Trois niveaux d'élévation suffisent, teintés à l'encre plutôt qu'au noir pur pour
|
||||
rester dans l'ambiance chaude :
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|---|---|---|
|
||||
| `shadow-flat` | `none` | contenu structuré dans le flux |
|
||||
| `shadow-raised` | `0 1px 2px / 0 2px 8px` encre | surface interactive ou importante |
|
||||
| `shadow-floating` | `0 4px 12px / 0 16px 40px` encre | menu, modale, popover |
|
||||
|
||||
Une ombre n'est pas utilisée pour compenser une mauvaise hiérarchie. Bordure,
|
||||
surface, espace et typographie sont privilégiés.
|
||||
|
||||
## Mouvement
|
||||
|
||||
Trois durées, arrêtées :
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|---|---:|---|
|
||||
| `duration-micro` | 120 ms | focus, survol, micro-retour |
|
||||
| `duration-control` | 180 ms | sélection, ouverture légère, état de contrôle |
|
||||
| `duration-panel` | 240 ms | transition de panneau, progression visible |
|
||||
|
||||
Deux courbes : `ease-standard` pour une entrée ou un changement d'état,
|
||||
`ease-exit` pour une sortie.
|
||||
|
||||
Contraintes :
|
||||
|
||||
- pas d'animation qui retarde une action ;
|
||||
- pas de déplacement décoratif continu ;
|
||||
- hover uniquement pour un élément réellement interactif ;
|
||||
- état final immédiatement compréhensible sans animation ;
|
||||
- `prefers-reduced-motion` respecté partout.
|
||||
|
||||
## Primitives Tailwind attendues
|
||||
|
||||
### Boutons
|
||||
|
||||
Variantes minimales :
|
||||
|
||||
- `primary` : une action dominante par région ;
|
||||
- `secondary` : action importante non dominante ;
|
||||
- `quiet` : action contextuelle ;
|
||||
- `danger` : action destructive ;
|
||||
- `icon` : action compacte avec nom accessible.
|
||||
|
||||
Les tailles doivent offrir des hauteurs cohérentes et une cible tactile confortable.
|
||||
Un groupe ne présente pas plusieurs boutons remplis concurrents sans justification.
|
||||
|
||||
`btn-primary` prend son fond au cran **700**, pas 600 (VIS-03) : sous la palette de
|
||||
marque — la seule qui s'applique hors d'un `ServiceThemeScope`, donc sur la Navbar
|
||||
et toutes les pages publiques — le blanc sur `primary-600` ne donne que 3,5:1. Les
|
||||
six palettes de service passaient déjà ; le cran 700 les couvre toutes et
|
||||
`designTokens.test.ts` recalcule ce contraste pour les sept palettes.
|
||||
|
||||
Un lien textuel dans une phrase, ou une action tertiaire, utilise `.link` : accent
|
||||
du service et **soulignement permanent**, la couleur seule ne signalant pas un lien.
|
||||
`ink-accent` tient 5,0:1 sur les surfaces blanches mais descend à 4,3:1 sur une
|
||||
surface teintée : sur `surface-muted` — donc dans le rail de collaboration — c'est
|
||||
`btn-quiet` qui porte l'action.
|
||||
|
||||
### Champs
|
||||
|
||||
Une composition de champ comprend selon le besoin :
|
||||
|
||||
- libellé persistant ;
|
||||
- contrôle ;
|
||||
- aide ;
|
||||
- erreur ou confirmation ;
|
||||
- compteur ou unité.
|
||||
|
||||
Le placeholder est un exemple, jamais le seul libellé. Le focus et l'erreur ne
|
||||
reposent pas seulement sur une faible variation de couleur.
|
||||
|
||||
### Cartes et panneaux
|
||||
|
||||
Variantes minimales :
|
||||
|
||||
- `resource` : Hub, liste, sondage ou groupe ;
|
||||
- `interactive` : élément sélectionnable ou actionnable ;
|
||||
- `summary` : résultat, solde, avancement ;
|
||||
- `collaboration` : partage, co-propriété, commentaires ;
|
||||
- `configuration` : paramètres ou formulaire structuré ;
|
||||
- `notice` : aide, contexte ou restriction ;
|
||||
- `empty` : absence de contenu et prochaine action.
|
||||
|
||||
Les variantes doivent être portées par les composants partagés ou des assemblages
|
||||
documentés. Une nouvelle page ne recrée pas sa propre carte générique.
|
||||
|
||||
`collaboration` est la seule variante qui n'est pas blanche : `card-collaboration`
|
||||
pose `surface-muted` et `rounded-panel`, et son titre `card-collaboration-title`
|
||||
donne au rail son rythme commun. Le composant `CollaborationPanel` apporte cette
|
||||
surface lui-même — les pages n'enveloppent plus `SharePanel`, `CoOwnerPanel` ou
|
||||
`CommentBlock` à la main.
|
||||
|
||||
`page-editorial`, `editorial-section`, `editorial-heading` et `editorial-body`
|
||||
composent les pages de lecture longue (mentions, confidentialité, CGU) dans le
|
||||
conteneur `max-w-editorial`. Elles répétaient la même composition section par
|
||||
section ; elles la prennent maintenant au même endroit.
|
||||
|
||||
`ProgressBar` porte un `tone` : `progress` (atteindre le maximum est l'objectif,
|
||||
succès à 100 %) ou `quota` (le maximum est une limite, alerte à 100 %). Même barre,
|
||||
sens inverse — un quota freemium n'a pas à donner lieu à une seconde barre.
|
||||
|
||||
`empty` est porté par `EmptyState` : une absence de contenu ne s'écrit pas en
|
||||
paragraphe gris centré au cas par cas. Les quatre familles de message d'état
|
||||
courtes sont `alert-error`, `alert-success`, `alert-info` et `alert-warning` ;
|
||||
cette dernière (VIS-04) porte la **restriction actionnable** — capacité freemium
|
||||
restante, limite atteinte — là où `notice` porte l'aide permanente. Une restriction
|
||||
est un état, elle a donc droit à son aplat ; une aide n'en est pas un.
|
||||
|
||||
### Badges et états
|
||||
|
||||
- badge de service : couleur issue de `serviceThemes.ts` ;
|
||||
- statut métier : token sémantique si le sens est succès/alerte/erreur/info ;
|
||||
- métadonnée : traitement neutre ;
|
||||
- sélection : accent de service et signal autre que la seule couleur.
|
||||
|
||||
### Icônes
|
||||
|
||||
Les SVG canoniques existants sont prioritaires. Emojis, étoiles typographiques,
|
||||
caractères `×` décoratifs et nouvelles bibliothèques d'icônes ne sont pas ajoutés
|
||||
au cas par cas. Toute extension du set doit conserver le même poids, les mêmes
|
||||
proportions et un nom accessible lorsque nécessaire.
|
||||
|
||||
## Shells
|
||||
|
||||
### Contrat implémenté (VIS-03)
|
||||
|
||||
`frontend/src/shared/config/shellVariant.ts` est la **source unique** du contexte :
|
||||
`getShellVariant(pathname, authenticated)` renvoie `public` ou `workspace`, et
|
||||
`shellContainerClass()` en dérive `shell-container` + la largeur du contexte. Les
|
||||
trois éléments de chrome — Navbar, bandeau de confidentialité, Footer — l'appellent ;
|
||||
leur fond reste pleine largeur, seul leur contenu est ramené sur l'axe de lecture.
|
||||
C'est la réponse à VIS-AUD-06, qui relevait trois axes de lecture sur une même page.
|
||||
|
||||
Une racine de service (`/hub`, `/kdo`, …) est la seule ambiguïté du contrat :
|
||||
`ServiceRoute` y rend la vitrine hors session et le tableau de bord en session. Le
|
||||
chemin seul ne décide donc pas — **l'état d'authentification fait partie du
|
||||
contexte** et est passé une fois au conteneur, jamais deviné composant par
|
||||
composant.
|
||||
|
||||
### Shell public
|
||||
|
||||
Utilisé par la homepage, les vitrines, les landings, la démo et les pages
|
||||
éditoriales publiques. Ses axes de conteneur sont ceux de G4/G6 (`max-w-public`).
|
||||
|
||||
### Shell workspace
|
||||
|
||||
Utilisé par dashboards, édition, participation et compte. Il comprend :
|
||||
|
||||
- navigation compacte ;
|
||||
- masthead de contexte ;
|
||||
- contenu dominant ;
|
||||
- rails optionnels de collaboration ;
|
||||
- Footer adapté à la longueur et au contexte.
|
||||
|
||||
Le shell peut changer de présentation sans changer les routes, permissions ou
|
||||
ordres d'action.
|
||||
|
||||
### Masthead de service
|
||||
|
||||
Le masthead remplace le petit titre générique par une composition compacte :
|
||||
|
||||
- retour ou fil de contexte ;
|
||||
- icône et nom du service ;
|
||||
- titre de la ressource ;
|
||||
- état ou métadonnée utile ;
|
||||
- une action principale ;
|
||||
- accent de service discret.
|
||||
|
||||
Il reste compact sur mobile et ne provoque aucun débordement. `ServicePageHeader`
|
||||
en est l'implémentation : le fil de contexte vient du `ServiceThemeScope` courant
|
||||
(prop `serviceId` pour l'imposer), et les gouttières négatives reprennent le token
|
||||
`gutter` du parent (`-mx-gutter px-gutter`) plutôt qu'une valeur fixe — c'était la
|
||||
cause du débordement mobile VIS-AUD-05.
|
||||
|
||||
### Rail de collaboration
|
||||
|
||||
Partage, co-propriété et commentaires forment une famille visuelle secondaire.
|
||||
Le lien de partage est l'action dominante du rail ; le QR est une aide, pas le
|
||||
centre de la page. L'ordre et la disponibilité des fonctions restent inchangés.
|
||||
|
||||
Implémentation : `CollaborationPanel` (surface `card-collaboration`) enveloppe
|
||||
`SharePanel`, `CoOwnerPanel` et `CommentBlock`. Le champ de lien est en lecture
|
||||
seule, le QR reste à 140 px et la date d'un commentaire est **toujours visible** —
|
||||
une information ne dépend jamais du survol, qui n'existe pas au doigt.
|
||||
|
||||
### Authentification
|
||||
|
||||
Connexion, inscription et vérification différée aboutissaient à trois copies de la
|
||||
même saisie de code à six chiffres : `VerifyCodeForm` la porte, `VerifyCodeStep`
|
||||
l'habille en modale. En plein écran mobile la modale remplace la page : elle
|
||||
rappelle donc la marque (`[data-brand-recall]` dans `Modal`), rappel qui disparaît
|
||||
dès qu'elle redevient une fenêtre posée sur une page visible.
|
||||
|
||||
## Expression des services
|
||||
|
||||
| Service | Rôle visuel dans le produit |
|
||||
|---|---|
|
||||
| Hub | Assemblage, vue d'ensemble, portes vers les autres outils |
|
||||
| Kdo | Désir, choix et progression vers l'offert |
|
||||
| Kal | Disponibilité, lecture de grille et convergence |
|
||||
| Kwiz | Choix rapide, énergie et comparaison des réponses |
|
||||
| Kontrib | Répartition concrète, mouvement et complétude |
|
||||
| Kount | Clarté des montants, équilibre et apaisement |
|
||||
|
||||
Chaque service conserve sa palette canonique. La personnalité vient surtout des
|
||||
états, de la composition et de la donnée principale, pas d'un décor spécifique
|
||||
qui dupliquerait le système partagé.
|
||||
|
||||
### Assemblage du Hub (contrat implémenté VIS-04)
|
||||
|
||||
Le Hub est le seul espace qui montre plusieurs services à la fois. Deux composants
|
||||
en portent la règle, et les cinq migrations suivantes doivent les réutiliser plutôt
|
||||
que recomposer un groupe à la main :
|
||||
|
||||
- `ModuleGroup` — un groupe de modules d'un même service : `ServiceThemeScope`
|
||||
local, icône canonique, titre de section (`h2`) et **décompte** en `badge-meta`.
|
||||
La palette marque le groupe ; les actions structurelles de la page (créer,
|
||||
ajouter, dupliquer) restent hors des groupes, donc en palette Hub.
|
||||
- `ServiceToolTile` — le choix d'un outil à ajouter. Cinq boutons pleins
|
||||
concurrents faisaient cinq actions dominantes sur une même région : le choix est
|
||||
une carte `interactive`, et l'outil conseillé se distingue par un **libellé**
|
||||
(`badge-service`) autant que par son anneau, jamais par la couleur seule.
|
||||
|
||||
Le démarrage d'un Hub vide est de l'aide contextuelle : rôle `notice`, jamais un
|
||||
aplat de service, qui se lirait comme une alerte. Une colonne d'assemblage ne porte
|
||||
qu'une action principale — « Ajouter un outil » ; le rail de collaboration garde la
|
||||
sienne. Le type d'événement est une métadonnée (`badge-meta`), pas un emoji collé
|
||||
au titre : le masthead porte le seul `h1` de la page, les groupes et les
|
||||
participations sont des sections.
|
||||
|
||||
## Responsive
|
||||
|
||||
La validation minimale couvre 320, 390, 768, 1280 et 1440 px.
|
||||
|
||||
Règles :
|
||||
|
||||
- aucun débordement horizontal non intentionnel ;
|
||||
- aucune action principale uniquement disponible au hover ;
|
||||
- aucun label indispensable masqué au profit d'une icône ambiguë ;
|
||||
- les rails suivent le contenu sur mobile sans devenir plus importants que lui ;
|
||||
- les tableaux et grilles denses conservent leur lisibilité et leurs interactions
|
||||
tactiles ;
|
||||
- les headers sticky tiennent compte de la Navbar réelle ;
|
||||
- le bandeau cookies ne masque pas la preuve ou l'action du premier viewport ;
|
||||
- les zones tactiles principales visent au moins 44 × 44 px lorsque la composition
|
||||
le permet, sans descendre sous les exigences WCAG applicables.
|
||||
|
||||
## Accessibilité
|
||||
|
||||
La refonte respecte au minimum :
|
||||
|
||||
- contraste WCAG AA pour texte, contrôles et états essentiels ;
|
||||
- focus visible sur tous les contrôles ;
|
||||
- ordre clavier identique à l'ordre visuel logique ;
|
||||
- nom accessible pour les boutons icône ;
|
||||
- libellé persistant pour les champs critiques ;
|
||||
- sens non transmis par la couleur seule ;
|
||||
- modales avec focus initial, piégeage et restitution du focus préservés ;
|
||||
- annonces et états de chargement existants non supprimés ;
|
||||
- réduction du mouvement prise en charge.
|
||||
|
||||
L'anneau de focus est posé **une seule fois** (VIS-03), en couche `base` sur
|
||||
`:focus-visible` — donc jamais au clic — avec `ring-ink-accent` et un décalage sur
|
||||
`surface-page`. Le chrome ne déclarait aucun anneau et dépendait du style par
|
||||
défaut du navigateur, invisible sur l'ardoise comme sur la crème. Un composant qui
|
||||
a besoin d'un anneau différent le redéclare.
|
||||
|
||||
## Invariants fonctionnels protégés
|
||||
|
||||
G8 ne modifie pas :
|
||||
|
||||
- routes, schémas API, base de données ou règles Premium ;
|
||||
- droits guest, owner et co-owner ;
|
||||
- création et liaison des modules Hub ;
|
||||
- parcours de partage, annulation ou conversion invité ;
|
||||
- ordre des workflows ;
|
||||
- structure tactile des grilles Kal ;
|
||||
- geste de suppression existant ;
|
||||
- comportement des portails et modales ;
|
||||
- configuration des landings, hreflang, canonical, JSON-LD ou sitemap ;
|
||||
- récit et CTA des pages G4/G6.
|
||||
|
||||
Un wrapper ou une prop purement présentationnelle est acceptable. Toute nouvelle
|
||||
interaction — QR repliable, navigation mobile différente, action sticky ou
|
||||
réorganisation métier — exige un item et une validation distincts.
|
||||
|
||||
## Ordre de migration
|
||||
|
||||
1. anomalies visibles et tokens ;
|
||||
2. primitives partagées ;
|
||||
3. shell, navigation, auth et transverse ;
|
||||
4. Hub pilote ;
|
||||
5. Kdo et Kontrib ;
|
||||
6. Kount ;
|
||||
7. Kal et Kwiz ;
|
||||
8. harmonisation prudente de G4/G6 ;
|
||||
9. validation globale et documentation finale.
|
||||
|
||||
Aucune page n'est migrée isolément en copiant de longues chaînes Tailwind. Un
|
||||
besoin répété doit d'abord être absorbé par un token, une variante ou une
|
||||
composition partagée.
|
||||
|
||||
## Définition de terminé
|
||||
|
||||
Un item visuel n'est `DONE` que si :
|
||||
|
||||
- ses critères de roadmap sont satisfaits ;
|
||||
- ses vues desktop et mobile ont été revues ;
|
||||
- ses états vide, chargé, erreur, succès et désactivé pertinents ont été vus ;
|
||||
- ses rôles guest/owner/co-owner pertinents ont été vérifiés ;
|
||||
- TypeScript, i18n et tests concernés sont verts ;
|
||||
- l'audit axe requis ne remonte pas de nouvelle violation ;
|
||||
- aucun débordement ou régression clavier n'est introduit ;
|
||||
- les preuves sont inscrites dans `docs/ROADMAP.md` ;
|
||||
- ce document est mis à jour dans la même modification si une règle évolue.
|
||||
387
docs/ROADMAP.md
387
docs/ROADMAP.md
|
|
@ -1,7 +1,8 @@
|
|||
# Roadmap de remédiation et de croissance
|
||||
|
||||
Dernière mise à jour : 11 août 2026
|
||||
Source : `docs/audits/2026-08-code-produit.md`
|
||||
Dernière mise à jour : 12 août 2026
|
||||
Sources : `docs/audits/2026-08-code-produit.md`,
|
||||
`docs/audits/2026-08-style-visuel-produit.md`
|
||||
|
||||
Ce fichier est le tableau de suivi commun à Codex et Claude Code. Mettre à jour les
|
||||
statuts, preuves et décisions dans la même modification que le code.
|
||||
|
|
@ -18,6 +19,7 @@ statuts, preuves et décisions dans la même modification que le code.
|
|||
| G5 | Fondations SEO corrigées | `DONE` | Canonical/hreflang/JSON-LD testés sur FR et EN |
|
||||
| G6 | Acquisition SEO | `DONE` | Premier cluster de pages utiles indexable |
|
||||
| G7 | Boucles de croissance | `DONE` | Conversion invité→organisateur mesurée et optimisée |
|
||||
| G8 | Refonte visuelle du produit | `TODO` | Socle fonctionnel aligné sur G4/G6, sans régression métier, responsive ou accessible |
|
||||
|
||||
## G0 — Sécurité critique
|
||||
|
||||
|
|
@ -337,6 +339,86 @@ CTA contextualisé, maillage, FR/EN, SSG, sitemap, canonical et données structu
|
|||
| GRW-06 | Contributions de tous les services dans le Hub | `DONE` | Les six types de participation remontent, l'inscription tardive les rattrape sur les cinq services | `services/hub/router.py` + `Dashboard.tsx` ; Kal/Kwiz ajoutés, repli email vérifié sur Kdo/Kontrib/Kount/Kal/Kwiz (migration `0029`), montants formatés avec leur devise ; 50 Pytest PostgreSQL + 62 Vitest verts (11 août 2026) |
|
||||
| GRW-07 | Pose en lot des outils d'un template | `DONE` | Les modules conseillés sont créés vides en une transaction, dans la limite gratuite visible d'avance | `services/hub/modules.py` + `POST /hub/{id}/modules`, `TemplateStarterPanel.tsx` ; capacité freemium réservée pour le lot entier (lot de 4 refusé en bloc à 2/3 services), aucun item/date/participant créé, Kwiz laissé en création manuelle car un sondage exige ses options ; 51 Pytest PostgreSQL + 65 Vitest verts (11 août 2026) |
|
||||
|
||||
## G8 — Refonte visuelle du produit
|
||||
|
||||
Audit validé par le propriétaire le 12 août 2026. L'implémentation porte sur les
|
||||
styles Tailwind, les tokens, les primitives et les shells de présentation. Elle ne
|
||||
modifie ni l'architecture, ni les fonctionnalités, ni les contrats métier.
|
||||
|
||||
Sources canoniques :
|
||||
|
||||
- diagnostic et preuves : `docs/audits/2026-08-style-visuel-produit.md` ;
|
||||
- direction et règles : `docs/DESIGN_SYSTEM.md` ;
|
||||
- validation : section « Validation visuelle G8 » de `docs/TESTING.md`.
|
||||
|
||||
| ID | Travail | Statut | Dépendances | Acceptation synthétique | Preuves |
|
||||
|---|---|---|---|---|---|
|
||||
| VIS-DOC-01 | Versionner l'audit, la direction et le plan | `DONE` | G4, G6 | Diagnostic exhaustif, cible normative, lots, critères et protocole reliés aux points d'entrée des agents | `docs/audits/2026-08-style-visuel-produit.md`, `docs/DESIGN_SYSTEM.md`, G8 et documentation commune (12 août 2026) |
|
||||
| VIS-00 | Corriger les anomalies visuelles transverses | `DONE` | VIS-DOC-01 | Aucun débordement du header à 320/390 px ; Kal n'utilise plus le terracotta Hub ; couleurs brutes remplacées par les tokens appropriés ; cookie compact ; coquilles et textes codés en dur corrigés ; formatage Kount propre | Header/Navbar, cookie, Kal/Kwiz/Kount/compte/CGU et i18n ; 70 Vitest, 10 Playwright, axe critique 0, build Docker 60 pages ; matrice FR/EN 320/390/1440 sans débordement ; chunk initial +117 octets gzip (12 août 2026) |
|
||||
| VIS-01 | Valider le style tile et compléter les tokens Tailwind | `DONE` | VIS-00 | Typographie, tailles, espacements, conteneurs, rayons, bordures, élévations, surfaces et mouvement documentés puis démontrés en public, fonctionnel, formulaire et grille dense ; décision de police et coût mesurés | `frontend/tailwind.config.ts`, `frontend/src/index.css`, `/style-tile` (`shared/pages/StyleTile.tsx`), `designTokens.test.ts`, `e2e/style-tile.spec.ts` ; `docs/DESIGN_SYSTEM.md` et `docs/TESTING.md` mis à jour ; Bricolage Grotesque retenue, 37 332 o auto-hébergés, non appliquée hors style tile ; 24 fichiers / 84 Vitest, Playwright `5 passed` sur 320/390/768/1280/1440, axe `main` 0 violation, build 60 pages ; chunk initial +91 o gzip, CSS partagée +1 928 o gzip (12 août 2026) |
|
||||
| VIS-02 | Refaire les primitives partagées | `DONE` | VIS-01 | Boutons, champs, cartes, panneaux, badges, états vides, alertes et modales couvrent les rôles de `docs/DESIGN_SYSTEM.md` sans dupliquer les workflows | `frontend/src/index.css` (btn-quiet/btn-icon, card-notice/interactive/summary/configuration, badge-service/success/warning/danger/info/meta, tokens ink/surface/line/duration dans toutes les classes de base) ; `Field.tsx` et `Badge.tsx` créés et exportés depuis `index.ts` ; `Modal`, `EmptyState`, `MetaChip`, `ConfirmModal`, `ProgressBar`, `ResourceCard` migrés aux tokens sémantiques ; `primitives.test.tsx` : 9 tests ; 95 Vitest, 0 TypeScript error (12 août 2026) |
|
||||
| VIS-03 | Aligner les shells et pages transverses | `DONE` | VIS-02 | Navbar public/workspace, Footer, masthead, rail de collaboration, auth, compte et pages système cohérents ; alignements, clavier et responsive validés | `shellVariant.ts` source unique du contexte (racine de service résolue par la session) ; Navbar, bandeau et Footer sur `shell-container` ; `CollaborationPanel`, `VerifyCodeForm`/`VerifyCodeStep`, primitives éditoriales, `ProgressBar tone="quota"` ; anneau `:focus-visible` global et `.btn-primary` au cran 700 (AA sur les sept palettes) ; police de titre déployée sur les trois rôles de composition ; 53 fichiers ; **114 Vitest verts** (27 fichiers, dont `shellVariant.test.ts` 9 et `shell.test.tsx` 9), TypeScript 0 erreur, `check:i18n` vert, Playwright **21 passed** dont `visual-shells.spec.ts` 6 sur 320/390/768/1280/1440 × FR/EN × public/workspace, axe `nav`+`footer`+bandeau **0 violation** (dérogation VIS-01 fermée), build Docker 60 pages SSG ; chunk initial 148 163 → 148 670 o gzip (+507 o) et CSS 9 289 → 11 765 o gzip cumulés depuis `HEAD` pour tout G8 (12 août 2026) |
|
||||
| VIS-04 | Migrer le Hub comme pilote fonctionnel | `DONE` | VIS-03 | Dashboard, création, édition et public expriment l'assemblage des services ; module et prochaine action dominants ; onboarding, partage et permissions inchangés | `ModuleGroup` et `ServiceToolTile` (nouveaux) portent l'assemblage et le choix d'outil ; démarrage en `card-notice` sans aplat de service, une seule action dominante par colonne ; type d'événement en `badge-meta` (emojis retirés de `common.json`, `EVENT_TYPE_ICONS` supprimé) ; formulaires Hub sur `Field` ; `alert-warning` ajoutée et nom accessible du champ de partage corrigé ; `HubPilot.test.tsx` (5) et `e2e/visual-hub.spec.ts` (6, cinq largeurs × FR/EN, axe `main` 0 violation) ; 119 Vitest, Playwright `27 passed`, TypeScript et `check:i18n` verts, build Docker 60 pages ; chunk initial 148 163 → 148 690 o gzip cumulés depuis `HEAD` pour tout G8 (12 août 2026) |
|
||||
| VIS-05 | Migrer Kdo et Kontrib | `TODO` | VIS-04 | Cartes et états métier lisibles et distincts ; disponible/réservé/offert et restant/déclaré/complet compréhensibles sans la couleur seule ; parcours guest/owner/co-owner et gestes conservés | — |
|
||||
| VIS-06 | Migrer Kount | `TODO` | VIS-04 | Solde et prochaine action dominants ; dépenses, membres et répartitions regroupés sans perte de précision ; devises et décimales propres ; densité mobile maîtrisée | — |
|
||||
| VIS-07 | Migrer Kal et Kwiz | `TODO` | VIS-04 | Grilles, choix et résultats lisibles et tactiles ; états oui/peut-être/non et sélection Kwiz perceptibles sans la couleur seule ; interactions historiques intactes | — |
|
||||
| VIS-08 | Harmoniser prudemment G4/G6 | `TODO` | VIS-03, VIS-05, VIS-06, VIS-07 | Tokens et conteneurs raccordés sans changer récit, preuve, CTA, previews, configuration SEO ou hiérarchie des six vitrines et 38 URL de landing | — |
|
||||
| VIS-09 | Recette visuelle, accessible et performante | `TODO` | VIS-00 à VIS-08 | Matrice 320/390/768/1280/1440, FR/EN, guest/owner/co-owner et états critiques revue ; axe/TypeScript/i18n/Vitest/Playwright/build verts ; bundle et police sans régression non acceptée | — |
|
||||
|
||||
### Limites de périmètre G8
|
||||
|
||||
Les changements autorisés sont :
|
||||
|
||||
- ajout ou modification de tokens Tailwind et variables CSS ;
|
||||
- évolution des classes globales et variantes de composants partagés ;
|
||||
- wrappers ou props purement présentationnels ;
|
||||
- ajustements responsive, typographiques, iconographiques et de contraste ;
|
||||
- correction de textes visibles, hardcodes de style et défauts d'affichage ;
|
||||
- documentation et tests de non-régression nécessaires.
|
||||
|
||||
Sont exclus sans nouvel item et validation explicite :
|
||||
|
||||
- changement de route, contrat API, schéma de données ou permission ;
|
||||
- déplacement, suppression ou ajout d'une étape métier ;
|
||||
- changement des limites Premium ;
|
||||
- navigation mobile entièrement nouvelle ;
|
||||
- QR repliable, action sticky ou autre nouveau comportement ;
|
||||
- réécriture de la homepage, des vitrines ou des landings SEO ;
|
||||
- ajout de preuve sociale, métrique d'usage ou contenu artificiel.
|
||||
|
||||
### Checkpoints de validation
|
||||
|
||||
1. **Fondations** : ✅ **validé le 12 août 2026** par le propriétaire sur
|
||||
`/style-tile`, après correction des deux rôles de surface signalés en revue
|
||||
(cf. « règle du remplissage »). La décision de police et les tokens arrêtés
|
||||
sont consignés dans `docs/DESIGN_SYSTEM.md`. La page reste disponible comme
|
||||
référence de revue pour les lots suivants : elle est interne (`noindex`,
|
||||
refusée dans `robots.txt`, absente du sitemap et du pré-rendu). VIS-02 est
|
||||
débloqué.
|
||||
2. **Pilote** : ⏳ **ouvert le 12 août 2026** — VIS-04 est livré, la revue Hub
|
||||
desktop et mobile du propriétaire reste à faire avant le déploiement aux cinq
|
||||
services. Surfaces à revoir : édition d'un Hub avec modules, tableau de bord et
|
||||
page publique, à 390 et 1440 px. Les captures des cinq largeurs en FR et EN sont
|
||||
jointes au rapport Playwright de `e2e/visual-hub.spec.ts`. VIS-05, VIS-06 et
|
||||
VIS-07 sont bloqués tant que ce checkpoint n'est pas validé.
|
||||
3. **Services** : revue de chaque famille avec ses états métier et rôles d'accès
|
||||
avant de commencer VIS-08.
|
||||
4. **Public** : comparaison avant/après de la homepage, des six vitrines et d'un
|
||||
échantillon représentatif de landings ; aucune redirection créative de G4/G6.
|
||||
5. **Sortie** : VIS-09 est le seul item autorisé à clôturer G8 après consolidation
|
||||
de toutes les preuves.
|
||||
|
||||
### Preuves minimales par item
|
||||
|
||||
- fichiers et composants réellement modifiés ;
|
||||
- commandes de validation et résultats chiffrés ;
|
||||
- captures desktop/mobile ou référence à leur rapport ;
|
||||
- routes, langues, rôles et états examinés ;
|
||||
- résultat du contrôle de débordement horizontal ;
|
||||
- résultat axe sur les vues concernées ;
|
||||
- mesure de bundle si tokens, police, icônes ou dépendances changent ;
|
||||
- limite connue ou décision de risque, le cas échéant.
|
||||
|
||||
## Indicateurs de pilotage
|
||||
|
||||
- Sécurité : zéro vulnérabilité critique connue ; temps de correction P0.
|
||||
|
|
@ -828,3 +910,304 @@ CTA contextualisé, maillage, FR/EN, SSG, sitemap, canonical et données structu
|
|||
compte à 2/3 ; Vitest `65 passed` avec les trois cas de
|
||||
`TemplateStarterPanel.test.tsx` ; `check:i18n` (2 langues, 10 namespaces) et
|
||||
`tsc --noEmit` verts.
|
||||
|
||||
### 2026-08-12 — validation de l'audit visuel et ouverture de G8 (Codex)
|
||||
|
||||
- Le propriétaire valide l'audit du style après G4 et la direction de refonte
|
||||
« Chaleur utile ».
|
||||
- Décision : préserver la structure, le récit, la preuve et les CTA de G4/G6 ;
|
||||
faire descendre leur langage visuel dans les outils fonctionnels.
|
||||
- Périmètre : Tailwind, tokens, primitives, shells, responsive, accessibilité et
|
||||
finition éditoriale. L'architecture et les fonctionnalités sont exclues.
|
||||
- Documentation créée : `docs/audits/2026-08-style-visuel-produit.md` pour le
|
||||
diagnostic et `docs/DESIGN_SYSTEM.md` pour la cible normative.
|
||||
- G8 est ouvert en `TODO` avec dix items, checkpoints Hub/public et preuves
|
||||
minimales. `VIS-DOC-01` seul est `DONE` ; aucune implémentation frontend n'est
|
||||
incluse dans cette modification.
|
||||
|
||||
### 2026-08-12 — VIS-00 anomalies visuelles transverses (Codex)
|
||||
|
||||
- Le débordement venait de deux causes partagées : `ServicePageHeader` supposait
|
||||
une gouttière mobile de 24 px alors que `.page` en fournit 16, et la première
|
||||
ligne de Navbar dépassait de 15 px à 320 px. Les gouttières sont maintenant
|
||||
alignées ; le mot-symbole se replie sur le logo canonique sous 360 px tout en
|
||||
conservant son nom accessible.
|
||||
- `TimeSlotSelector` utilise désormais `primary-*` et hérite donc du bleu Kal.
|
||||
Les états produit actifs emploient les tokens `warning`, `danger` ou neutres
|
||||
selon leur sens ; le composant Kal historique `VoteForm.tsx`, sans import actif,
|
||||
reste volontairement inchangé conformément à l'audit de dette legacy.
|
||||
- Le bandeau de confidentialité est compact sur mobile. Les commentaires publics
|
||||
vouvoient, Kwiz n'a plus de texte français ni de pluriel codé dans ses composants,
|
||||
la coquille des CGU est retirée et le compte ne présente plus Kankwa+ comme une
|
||||
offre « bientôt disponible ». Les icônes de suppression ont un nom accessible.
|
||||
- Les pourcentages Kount passent par `formatPercentage()` : locale FR/EN et au plus
|
||||
deux décimales, y compris dans les répartitions et leurs totaux.
|
||||
- Recette reproductible : `frontend/e2e/visual-foundations.spec.ts` joint les
|
||||
captures Kdo public et homepage en FR/EN à 320, 390 et 1440 px. Captures revues,
|
||||
aucun scroll horizontal ; bandeau ≤ 72 px ; CTA et réassurance du hero visibles ;
|
||||
axe sans violation critique sur les vues concernées.
|
||||
- Validation : i18n `2 langues / 10 namespaces`, TypeScript vert, Vitest
|
||||
`22 fichiers / 70 tests`, Playwright Chromium `10 passed`, image
|
||||
`kankwa-frontend:g8-vis00` construite avec `44 WebP` contrôlés et `60 HTML`.
|
||||
Le chunk initial passe de 148 163 à 148 280 octets gzip (+117 octets, +0,08 %),
|
||||
régression minime acceptée pour les libellés et SVG accessibles. VIS-00 est `DONE` ;
|
||||
VIS-01 est le prochain lot et son style tile constitue le prochain checkpoint.
|
||||
|
||||
### 2026-08-12 — VIS-01 tokens et style tile (Claude Code)
|
||||
|
||||
- La couche de tokens est **additive par construction** : les neutres nommés par
|
||||
rôle reprennent les valeurs de la palette chaude existante, `max-w-public` vaut
|
||||
exactement l'actuel `max-w-6xl` et `rounded-component` reste un alias de
|
||||
`rounded-control`. Adopter un token ne déplace donc aucun écran déjà livré. Deux
|
||||
changements produit sont assumés et documentés : `rounded-card` passe de 12 à
|
||||
16 px, ce qui distingue enfin la carte du panneau, et `ink-subtle` prend une
|
||||
valeur nouvelle.
|
||||
- **Contraste** : l'ardoise historique `#8B8F99` (`gray-400/500`), utilisée
|
||||
partout comme « texte secondaire », ne donne que 3,2:1 sur la crème et 2,8:1 sur
|
||||
`surface-muted` — elle échoue AA. `ink-subtle` est donc `#656974` (4,7:1 au pire
|
||||
cas) et l'ardoise redescend à `ink-faint`, réservé au désactivé et au non
|
||||
textuel. `designTokens.test.ts` recalcule les contrastes de chaque rôle d'encre
|
||||
sur chaque surface claire : la règle est vérifiée, pas seulement écrite.
|
||||
`text-gray-400/500` reste en place pour les écrans non migrés et disparaîtra
|
||||
avec VIS-02/VIS-03.
|
||||
- Les cinq espacements de composition passent par des variables CSS
|
||||
(`--space-*`) : `px-gutter` ou `gap-cards` suivent le viewport sans qu'un
|
||||
composant déclare ses propres breakpoints. Les valeurs mobiles reprennent la
|
||||
gouttière existante de `.page`.
|
||||
- **Police** : Bricolage Grotesque retenue pour les titres. Fichier variable latin
|
||||
ramené de 41 344 à **37 332 o** en figeant l'axe `wght` à 600–800, `unicode-range`
|
||||
latin, `font-display: swap`, licence OFL versionnée à côté du fichier. Le repli
|
||||
`Kankwa Display Fallback` est réaligné sur des métriques mesurées (ratio de
|
||||
largeur 100,85 % contre Arial sous Chromium ; `hhea` 93/27/0), donc la
|
||||
substitution ne déplace pas la mise en page. La face est déclarée globalement
|
||||
mais appliquée uniquement par le style tile : **le produit ne paie pas la police
|
||||
tant que VIS-02/VIS-03 ne l'ont pas déployée sur les titres réels.**
|
||||
- `/style-tile` démontre les tokens dans les quatre contextes exigés, avec bascule
|
||||
police de titre / pile système, sélecteur de service pour éprouver
|
||||
`ServiceThemeScope`, et relevés de tailles rendues en direct pour prouver le
|
||||
clamp à 320 et à 1440 px. La page est interne : `noindex`, `Disallow` dans
|
||||
`robots.txt`, absente de `seo-routes.json` et de `landings.json`, chargée en
|
||||
`lazy` — le pré-rendu reste à 60 pages contrôlées.
|
||||
- Deux défauts d'accessibilité trouvés et corrigés dans la grille dense, tous deux
|
||||
génériques : une zone qui défile doit être focalisable au clavier, et un
|
||||
conteneur `overflow-x-auto` doit être `relative`, faute de quoi les libellés
|
||||
`sr-only` en position absolue échappent au découpage et font défiler la page
|
||||
entière au lieu de la seule grille. Le test mesure désormais la vérité
|
||||
utilisateur (`window.scrollX` après une tentative de défilement) plutôt que
|
||||
`documentElement.scrollWidth`, qui ne distingue pas les deux cas sous Chromium.
|
||||
- Validation : TypeScript vert, i18n `2 langues / 10 namespaces`, Vitest
|
||||
`24 fichiers / 84 tests`, Playwright `5 passed` sur `style-tile.spec.ts` et
|
||||
`4 passed` sur les suites VIS-00/thème, axe sans violation sérieuse ni critique
|
||||
sur `main`, build complet `60 pages pré-rendues (30 FR + 30 EN)` validées.
|
||||
- Coût, mesuré au `gzip -9` sur deux builds du même arbre (référence = arbre
|
||||
courant privé des seuls fichiers VIS-01) : chunk initial 147 743 → 147 834 o
|
||||
(**+91 o**), feuille de style partagée 9 298 → 11 226 o (**+1 928 o**). La CSS
|
||||
supplémentaire est celle des utilitaires du style tile ; ce sont très
|
||||
majoritairement les mêmes (`bg-surface-*`, `text-ink-*`, `rounded-*`,
|
||||
`duration-*`, `shadow-*`) que VIS-02 et VIS-03 rendront utilisées par les écrans
|
||||
réels, donc une dépense avancée plutôt qu'ajoutée. VIS-09 doit la remesurer.
|
||||
- VIS-01 est `DONE`. Le checkpoint « Fondations » est ouvert : **VIS-02 attend la
|
||||
revue humaine du style tile.** → validé le 12 août 2026, VIS-02 `DONE`.
|
||||
|
||||
### 2026-08-12 — checkpoint « Fondations » : règle du remplissage (Claude Code)
|
||||
|
||||
- Revue du propriétaire sur `/style-tile`. Deux rôles de surface donnaient un sens
|
||||
faux : `collaboration`, rempli à `primary-50`, prenait sous Kontrib l'apparence
|
||||
d'une erreur ; `notice`, en `info-*`, se confondait avec Kal.
|
||||
- Les valeurs confirment le diagnostic et le durcissent : Kontrib `#F9EDF0` est
|
||||
voisin de `danger-50` `#FFF0EE`, Kdo est ambré comme `warning` et Kwiz vert
|
||||
comme `success` — mais surtout **Kal `50`/`100` sont exactement `info-50`/`info-100`**.
|
||||
Kal et l'information ne sont pas voisins, ils sont identiques.
|
||||
- Cause unique : un aplat teinté ne porte aucun sens propre, il hérite du sens de
|
||||
sa teinte. La correction est donc une règle, pas six retouches — **seul un état
|
||||
sémantique remplit une surface de couleur** ; l'identité de service marque un
|
||||
bord. `collaboration` devient une surface neutre à filet `primary-600`, ce qui
|
||||
vaut pour les six palettes sans exception.
|
||||
- `surface-notice` est ajouté : le sable de marque `#E8DCCB`, seule teinte
|
||||
appartenant à la marque et à aucun service, donc la seule qui garde le même sens
|
||||
sous les six palettes. Aucune couleur nouvelle — c'est le token `sable`
|
||||
existant, nommé par rôle. `info-*` reste réservé à l'alerte informative
|
||||
transitoire.
|
||||
- `surface-accent` survit avec une contrainte explicite : uniquement sur un
|
||||
élément dont les frères non sélectionnés sont visibles, où il signifie
|
||||
« celui-ci » et ne peut pas se lire « attention ». Jamais sur un bloc isolé.
|
||||
- Verrouillage : `designTokens.test.ts` vérifie la collision `info`/Kal,
|
||||
l'appartenance de `surface-notice` à la marque et sa distinction de chaque
|
||||
nuance claire de service ; `StyleTile.test.tsx` vérifie que `collaboration` et
|
||||
`notice` n'utilisent aucun aplat de service. Vitest `24 fichiers / 86 tests`,
|
||||
TypeScript vert, Playwright `9 passed` (style tile, VIS-00, thème), axe `main`
|
||||
0 violation.
|
||||
- **Checkpoint « Fondations » validé le 12 août 2026.** Les tokens, la décision de
|
||||
police et les sept rôles de surface sont la référence contraignante des lots
|
||||
suivants. VIS-02 est débloqué et devient le prochain lot ; son propre checkpoint
|
||||
est celui de VIS-04 (pilote Hub).
|
||||
|
||||
### 2026-08-12 — VIS-02 primitives partagées (Claude Code)
|
||||
|
||||
- `index.css` : boutons `btn-quiet` (action contextuelle sans fond) et `btn-icon`
|
||||
(cible carrée, nom accessible obligatoire) ajoutés ; `btn-secondary` migré de
|
||||
`gray-300`/`gray-50` vers `line-strong`/`surface-muted` ; toutes les classes de
|
||||
bouton et de contrôle utilisent `duration-control ease-standard`. `.input` migré
|
||||
vers `line-strong`, `ink`, `ink-faint`, `surface-raised`.
|
||||
- Variantes de cartes ajoutées : `card-notice` (`surface-notice`, `rounded-panel`)
|
||||
pour l'aide contextuelle ; `card-interactive` (hover élévé, `duration-control`) ;
|
||||
`card-summary` (panneau compact) ; `card-configuration` (`surface-sunken`).
|
||||
`.card` et `.card-item` migrent de `bg-white border-gray-200 shadow-sm` vers
|
||||
`bg-surface-raised border-line shadow-raised`. `.card-title` adopte `text-card-title`
|
||||
et `text-ink` ; `.card-meta` adopte `text-body-sm` et `text-ink-subtle` ;
|
||||
`.card-actions` adopte `gap-compact`.
|
||||
- Badges sémantiques : `badge-service` (palette primaire du service), `badge-success`,
|
||||
`badge-warning`, `badge-danger`, `badge-info`, `badge-meta` (neutre, remplace
|
||||
`badge-gray`). Les alias historiques `badge-indigo`/`badge-green`/`badge-gray`
|
||||
sont conservés.
|
||||
- Composants migrés : `Modal` (`bg-surface-raised`, `rounded-card`, `shadow-floating`,
|
||||
`text-ink`, `text-ink-faint`, `rounded-t-card`, bouton fermeture `duration-micro`) ;
|
||||
`EmptyState` (`text-ink-subtle`/`text-ink-faint`, `text-body-sm`) ; `MetaChip`
|
||||
(`bg-surface-muted text-ink-muted`) ; `ConfirmModal` (`text-ink-muted`) ;
|
||||
`ProgressBar` (`text-ink-subtle`, `bg-surface-muted`) ; `ResourceCard` (delete
|
||||
button `text-ink-faint`, `rounded-control`).
|
||||
- Nouveaux composants : `Field.tsx` (label + contrôle + aide + erreur/succès +
|
||||
compteur, marqueur requis `aria-hidden`) ; `Badge.tsx` (variante typée, renvoie
|
||||
la classe CSS correcte, `meta` par défaut).
|
||||
- Exports ajoutés dans `shared/components/index.ts`.
|
||||
- Tests : `primitives.test.tsx` (9 tests Field + Badge) ; total **95 Vitest verts**,
|
||||
TypeScript vert. VIS-03 est débloqué.
|
||||
|
||||
### 2026-08-12 — VIS-03 shells et pages transverses (Claude Code)
|
||||
|
||||
- **Un seul axe de lecture.** `shared/config/shellVariant.ts` devient la source
|
||||
unique du contexte : `getShellVariant(pathname, authenticated)` renvoie `public`
|
||||
ou `workspace`, `shellContainerClass()` en dérive `shell-container` + la largeur.
|
||||
Navbar, bandeau de confidentialité et Footer l'appellent ; leur fond reste pleine
|
||||
largeur, seul leur contenu est ramené sur l'axe. C'est la réponse à VIS-AUD-06
|
||||
(Navbar pleine largeur, contenu `max-w-6xl`/`7xl`, pied de page `max-w-4xl`).
|
||||
- **La session fait partie du contexte.** Première rédaction fausse : quatre tests
|
||||
ont montré que `/hub`, `/kdo`… étaient classées `public`. `ServiceRoute` y rend la
|
||||
vitrine hors session et le tableau de bord en session ; le chemin seul ne décide
|
||||
donc pas. L'ambiguïté est résolue une fois, dans le contrat, plutôt que devinée
|
||||
composant par composant.
|
||||
- **Chrome** : Navbar refaite sur les tokens, rangée de six services qui **défile**
|
||||
à 320 px au lieu de se réduire à des icônes ambiguës, `aria-current="page"` unique ;
|
||||
Footer et bandeau alignés ; `CookieBanner` repris sur `surface-inverse` et les
|
||||
durées. `ServicePageHeader` reprend le token `gutter` du parent (`-mx-gutter
|
||||
px-gutter`) au lieu d'une valeur fixe — cause du débordement mobile VIS-AUD-05 —
|
||||
et affiche le fil de contexte du service depuis le `ServiceThemeScope`.
|
||||
- **Rail de collaboration** : `CollaborationPanel` (surface `card-collaboration`,
|
||||
`surface-muted`) enveloppe `SharePanel`, `CoOwnerPanel` et `CommentBlock`, qui
|
||||
n'ont plus à être habillés par chaque page. La date d'un commentaire n'est plus
|
||||
révélée au survol : une information ne dépend pas d'un geste qui n'existe pas au
|
||||
doigt. Le champ de lien reste en lecture seule, le QR à 140 px.
|
||||
- **Auth** : la saisie du code à six chiffres existait en trois copies (connexion,
|
||||
inscription, vérification différée) ; `VerifyCodeForm` la porte, `VerifyCodeStep`
|
||||
l'habille en modale. En plein écran mobile, `Modal` rappelle la marque
|
||||
(`[data-brand-recall]`), rappel absent dès qu'elle redevient une fenêtre posée.
|
||||
- **Pages transverses** : compte (quota freemium en `ProgressBar tone="quota"` —
|
||||
même barre, sens inverse, pas de seconde barre), mentions/confidentialité/CGU sur
|
||||
`page-editorial` + `editorial-section/heading/body`, 404, vérification d'e-mail,
|
||||
préférences e-mail et acceptation de co-propriété repris sur les tokens.
|
||||
- **Deux corrections de fond remontées par la recette**, traitées au niveau du
|
||||
système et non de l'écran :
|
||||
- axe signalait `btn-primary` dans la Navbar — blanc sur `primary-600` de marque
|
||||
ne donne que 3,46:1. La classe passe à `bg-primary-700 hover:bg-primary-800`
|
||||
(4,77:1 en marque, les six palettes de service étant plus sombres), et
|
||||
`designTokens.test.ts` recalcule désormais ce contraste **pour les sept
|
||||
palettes**. Corriger un écran aurait laissé la primitive fausse partout ailleurs.
|
||||
- aucun anneau de focus n'était déclaré sur le chrome : le style par défaut du
|
||||
navigateur est invisible sur l'ardoise comme sur la crème. L'anneau est posé une
|
||||
fois en couche `base` sur `:focus-visible` — donc jamais au clic.
|
||||
- **Police de titre déployée** : `font-display` est portée par les rôles
|
||||
`text-display`, `text-page-title` et `text-section-title` dans `index.css`, pas
|
||||
par les pages. Conséquence assumée : le fichier de 37 332 o, gratuit tant que
|
||||
VIS-01 ne l'appliquait qu'au style tile, est maintenant demandé par toute page
|
||||
portant un titre. La bascule du style tile est devenue un **retrait**
|
||||
(`type-specimen-system` + `font-sans`), ce qui a demandé d'inverser son test
|
||||
Vitest et son test Playwright.
|
||||
- **Tests** : `shellVariant.test.ts` (9) verrouille la décision de contexte —
|
||||
chemin exact et non préfixe, barre finale tolérée, deux largeurs distinctes ;
|
||||
`shell.test.tsx` (9) rend Navbar + contenu + Footer et vérifie l'alignement des
|
||||
deux extrémités, y compris la bascule vitrine → tableau de bord sur `/kdo` selon
|
||||
la session ; `e2e/visual-shells.spec.ts` (6) contrôle le rendu réel sur cinq
|
||||
largeurs × FR/EN × public/workspace, le défilement de la rangée de services, le
|
||||
parcours clavier et l'anneau réellement peint, la modale d'auth, et **ferme la
|
||||
dérogation axe de VIS-01** en analysant `nav`, `footer` et le bandeau. Le contrôle
|
||||
de débordement a été extrait dans `e2e/helpers/overflow.ts` : il pousse réellement
|
||||
la fenêtre puis écarte ce qui défile dans son conteneur et les décors rognés par
|
||||
un ancêtre — trois faux positifs de la homepage venaient de là.
|
||||
- **Portée tenue** : trois violations axe restantes appartiennent au récit G4/G6 de
|
||||
la homepage. Elles sont laissées à VIS-08, qui a seul mandat d'y toucher ; élargir
|
||||
la portée axe ici aurait élargi le lot.
|
||||
- **Validations** : `114 Vitest verts` (27 fichiers), TypeScript 0 erreur,
|
||||
`npm run check:i18n` vert, Playwright `21 passed`, build Docker complet avec
|
||||
`check:marketing` et `check:prerender` (60 pages SSG, canonical/hreflang/OG/JSON-LD
|
||||
conformes). Mesure de bundle contre un build de référence du `HEAD` courant
|
||||
(`19b04ed`, donc **cumul de tout G8 non encore commité**, VIS-00 à VIS-03) : chunk
|
||||
initial 148 163 → 148 670 o gzip (+507 o), CSS partagée 9 289 → 11 765 o gzip dont
|
||||
+1 928 o déjà attribués à VIS-01. VIS-09 doit remesurer, police de titre incluse.
|
||||
- VIS-03 est `DONE`. **VIS-04 (Hub pilote) est le prochain lot** et porte le
|
||||
checkpoint « Pilote » : revue desktop et mobile avant déploiement aux cinq services.
|
||||
|
||||
### 2026-08-12 — VIS-04 Hub pilote (Claude Code)
|
||||
|
||||
- **Le Hub assemble, il ne liste pas.** Les modules étaient rendus par une
|
||||
composition recopiée à l'identique dans l'édition et le public : icône emoji,
|
||||
`h3` gris, grille locale. `ModuleGroup` la porte une fois — `ServiceThemeScope`
|
||||
local, icône canonique, titre de section et **décompte** en `badge-meta`. La
|
||||
palette du service marque le groupe ; les actions structurelles de la page
|
||||
restent en palette Hub, conformément à la règle de `docs/DESIGN_SYSTEM.md`. Les
|
||||
quatre lots de migration suivants réutilisent ce composant au lieu d'en refaire un.
|
||||
- **Cinq actions dominantes n'en sont aucune.** Le démarrage proposait cinq
|
||||
`btn-primary` concurrents sur un aplat `primary-50`. Le bloc devient de l'aide
|
||||
contextuelle (`card-notice`, règle du remplissage : un aplat de service se lirait
|
||||
comme une alerte sous Kdo ou Kontrib) et chaque outil devient un choix
|
||||
(`ServiceToolTile`, carte `interactive`). L'outil demandé par une landing ou une
|
||||
vitrine reste signalé, désormais par un **libellé** « Conseillé » autant que par
|
||||
son anneau : la sélection ne repose plus sur la couleur seule. La colonne
|
||||
d'assemblage ne garde qu'une action principale, « Ajouter un outil », sous un
|
||||
titre de section qui nommait jusque-là un vide.
|
||||
- **Un seul `h1` par surface.** Les participations du tableau de bord ouvraient une
|
||||
seconde page dans la page (`h1` + `pt-6`) ; elles deviennent une section
|
||||
`aria-labelledby`. Même correction sur les groupes de modules. Les emojis de type
|
||||
d'événement disparaissent des catalogues `common.json` (`types.event.*`) et
|
||||
`EVENT_TYPE_ICONS` est supprimé : le type est une métadonnée, il est rendu en
|
||||
`badge-meta` dans le masthead et sur les cartes — c'est la règle d'icônes du
|
||||
design system, pas une préférence.
|
||||
- **Champs sans libellé.** Le titre d'`EditHubModal` n'avait ni libellé ni
|
||||
placeholder : son identité tenait à sa position. Les formulaires Hub (création
|
||||
modale, `/hub/new`, édition, sélecteur de template) passent à `Field` ; les textes
|
||||
visibles ne changent pas, ils passent du placeholder au libellé persistant.
|
||||
- **Deux défauts d'accessibilité réels trouvés par la recette**, corrigés dans le
|
||||
périmètre : le champ de lien de `SharePanel` n'avait aucun nom accessible (il
|
||||
reprend le titre du panneau), et `alert-warning` manquait pour la restriction
|
||||
actionnable de `TemplateStarterPanel`, qui recomposait ses classes à la main.
|
||||
- **Faux positif de contraste.** Le contrôle axe signalait sept nœuds du tableau de
|
||||
bord à 1440 px, dont un rôle qui tient AA sur la table des tokens. La cause n'est
|
||||
pas une couleur mais le fondu d'entrée de `.page` : la mesure lisait des couleurs
|
||||
mélangées au fond (4,31:1 au lieu de 4,5:1, `#6c6f7a` au lieu de `#656974`). La
|
||||
recette émule donc `prefers-reduced-motion` et affiche le `failureSummary` d'axe
|
||||
dans son message — sans lui, l'écart ne dit ni sa valeur ni ses couleurs.
|
||||
- **Portée tenue** : `Cancel.tsx`, `CancelLinkDisplay.tsx` et les gris de
|
||||
`SeoLanding.tsx` restent inchangés — surfaces invité et landings, qui appartiennent
|
||||
aux lots de service et à VIS-08. Aucune route, permission, étape métier ni limite
|
||||
Premium n'a bougé ; partage, co-gestion, onboarding et pose en lot d'un template
|
||||
sont vérifiés inchangés par les tests.
|
||||
- **Tests** : `HubPilot.test.tsx` (5) verrouille l'assemblage — rôle `notice` sans
|
||||
aplat de service, cinq outils de même poids, groupes par service avec décompte,
|
||||
une seule action dominante dans `.service-main`, un seul `h1` sur les trois
|
||||
surfaces ; `e2e/visual-hub.spec.ts` (6) contrôle le rendu réel d'un Hub complet
|
||||
simulé sur cinq largeurs × FR/EN pour le tableau de bord, l'édition et le public,
|
||||
le rail de collaboration intact, les cinq tuiles au fond réellement identique et
|
||||
axe sur `main` à 390 et 1440 px. `critical-flow.spec.ts` a été mis à jour : le
|
||||
service demandé est désormais assuré par son libellé, pas seulement par un anneau.
|
||||
- **Validations** : `119 Vitest verts` (28 fichiers), TypeScript 0 erreur,
|
||||
`npm run check:i18n` vert (2 langues, 10 namespaces), Playwright `27 passed`
|
||||
(suite complète, aucune régression des lots VIS-00/01/03 ni de `service-theme`),
|
||||
build Docker complet avec `check:marketing` et `check:prerender` (60 pages SSG
|
||||
conformes). Mesure de bundle contre un build de référence du `HEAD` courant
|
||||
(`19b04ed`, donc **cumul de tout G8 non commité**, VIS-00 à VIS-04) : chunk initial
|
||||
148 163 → 148 690 o gzip (+527 o), CSS partagée 9 289 → 11 880 o gzip. VIS-04 seul
|
||||
pèse +20 o de JS et +115 o de CSS par rapport à l'état VIS-03, malgré deux
|
||||
composants ajoutés — le retrait des compositions recopiées les compense.
|
||||
- VIS-04 est `DONE`. **Le checkpoint « Pilote » est ouvert : VIS-05, VIS-06 et
|
||||
VIS-07 attendent la revue Hub desktop et mobile du propriétaire** (édition,
|
||||
tableau de bord et page publique, 390 et 1440 px ; captures jointes au rapport
|
||||
Playwright de `visual-hub.spec.ts`).
|
||||
|
|
|
|||
187
docs/TESTING.md
187
docs/TESTING.md
|
|
@ -49,3 +49,190 @@ docker run --rm --network host \
|
|||
--workdir /workspace mcr.microsoft.com/playwright:v1.62.1-noble \
|
||||
sh -c "npm ci && npm run test:e2e"
|
||||
```
|
||||
|
||||
## Validation visuelle G8
|
||||
|
||||
Chaque item G8 ajoute à ses tests fonctionnels une recette visuelle proportionnée
|
||||
au périmètre. Tant qu'une régression visuelle automatisée exhaustive n'existe pas,
|
||||
les captures et contrôles Playwright sont consignés comme preuves dans
|
||||
`docs/ROADMAP.md` ; ils ne remplacent pas Vitest, les tests E2E ou axe.
|
||||
|
||||
### Viewports minimaux
|
||||
|
||||
| Largeur | Usage |
|
||||
|---:|---|
|
||||
| 320 px | plus petit mobile supporté et résistance des contrôles |
|
||||
| 390 px | mobile courant et référence de l'audit |
|
||||
| 768 px | bascule tablette et rails |
|
||||
| 1280 px | workspace desktop |
|
||||
| 1440 px | pages publiques G4/G6 et composition large |
|
||||
|
||||
Pour une modification locale, 390 et 1440 px sont le minimum. Les cinq largeurs
|
||||
sont obligatoires pour VIS-03, chaque lot service et VIS-09.
|
||||
|
||||
### Contrat automatisé VIS-00
|
||||
|
||||
`frontend/e2e/visual-foundations.spec.ts` verrouille les anomalies transverses
|
||||
traitées avant les fondations du design system :
|
||||
|
||||
- Kdo public en FR/EN à 320, 390 et 1440 px, avec titre long ;
|
||||
- Navbar et `ServicePageHeader` contenus dans le viewport ;
|
||||
- homepage et bandeau de confidentialité en FR/EN à 390 et 1440 px ;
|
||||
- hauteur du bandeau limitée à 72 px, CTA visible, absence de scroll horizontal ;
|
||||
- captures jointes au rapport Playwright et contrôle axe des vues concernées.
|
||||
|
||||
Commande ciblée :
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/visual-foundations.spec.ts
|
||||
```
|
||||
|
||||
### Contrat automatisé VIS-01
|
||||
|
||||
Deux contrats couvrent les fondations. `frontend/src/shared/config/designTokens.test.ts`
|
||||
verrouille la table des tokens elle-même : sept rôles typographiques, quatre
|
||||
rayons, trois élévations, trois durées, espacements résolus par variable CSS et
|
||||
contrastes des rôles d'encre recalculés sur chaque surface claire. Un lot de
|
||||
migration ne peut donc ni supprimer ni ajouter un rôle en silence.
|
||||
|
||||
`frontend/e2e/style-tile.spec.ts` contrôle la démonstration `/style-tile` :
|
||||
|
||||
- les cinq largeurs de la matrice, captures jointes au rapport ;
|
||||
- absence de défilement horizontal réel de la fenêtre, mesurée par le contrôle
|
||||
partagé `e2e/helpers/overflow.ts` : la fenêtre est réellement poussée à droite,
|
||||
puis les éléments sortis du viewport sont listés en écartant ce qui défile
|
||||
volontairement dans son conteneur (`data-dense-scroll`, rangée de services) et
|
||||
les décors rognés par un ancêtre au débordement masqué ;
|
||||
- échelle typographique réellement fluide entre 320 et 1440 px, `meta` stable ;
|
||||
- police de titre servie par le domaine, sans requête tierce, réellement chargée ;
|
||||
- `prefers-reduced-motion` neutralisant les transitions sans changer l'état final ;
|
||||
- axe sans violation sérieuse ou critique sur `main`.
|
||||
|
||||
La portée axe est volontairement limitée au contenu : la Navbar et le pied de page
|
||||
portent des écarts connus qui appartiennent à VIS-03. **Cette dérogation est fermée
|
||||
depuis VIS-03**, qui analyse le chrome (voir ci-dessous).
|
||||
|
||||
Commandes ciblées :
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/style-tile.spec.ts
|
||||
npx vitest run src/shared/config/designTokens.test.ts src/shared/pages/StyleTile.test.tsx
|
||||
```
|
||||
|
||||
### Contrat automatisé VIS-03
|
||||
|
||||
Trois contrats couvrent les shells et les pages transverses.
|
||||
|
||||
`frontend/src/shared/config/shellVariant.test.ts` verrouille la décision de
|
||||
contexte elle-même : pages publiques et légales en `public`, reste en `workspace`,
|
||||
racine de service selon la session, correspondance par chemin exact et non par
|
||||
préfixe, tolérance de la barre finale, un seul conteneur par contexte et deux
|
||||
largeurs distinctes documentées.
|
||||
|
||||
`frontend/src/shared/components/shell.test.tsx` rend Navbar + contenu + Footer et
|
||||
vérifie que les deux extrémités partagent le conteneur du contexte — y compris la
|
||||
bascule vitrine → tableau de bord sur `/kdo` selon la session —, que les six
|
||||
services sont nommés, qu'une seule entrée porte `aria-current="page"`, que le rail
|
||||
de collaboration expose une surface commune, que la date d'un commentaire est
|
||||
visible sans survol et que le champ de partage reste en lecture seule.
|
||||
|
||||
`frontend/e2e/visual-shells.spec.ts` contrôle le rendu réel :
|
||||
|
||||
- Navbar, bandeau de confidentialité et Footer alignés au pixel sur les cinq
|
||||
largeurs, en FR et EN, dans un contexte public (`/`) et un contexte workspace
|
||||
(participation invitée `/share/<token>`, API simulées) ;
|
||||
- rangée de services qui défile dans son conteneur à 320 px sans entraîner la page ;
|
||||
- parcours clavier traversant le chrome dans l'ordre visuel, avec un anneau de
|
||||
focus réellement peint ;
|
||||
- axe sans violation sérieuse ou critique sur `nav`, `footer` et le bandeau — c'est
|
||||
la dérogation de VIS-01 que ce lot avait mandat de fermer. Le récit des pages
|
||||
publiques garde ses écarts G4/G6 jusqu'à VIS-08, seul habilité à y toucher ;
|
||||
- modale d'authentification rappelant la marque en plein écran mobile, fermée au
|
||||
clavier, rappel absent en fenêtre desktop.
|
||||
|
||||
Commandes ciblées :
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/visual-shells.spec.ts
|
||||
npx vitest run src/shared/config/shellVariant.test.ts src/shared/components/shell.test.tsx
|
||||
```
|
||||
|
||||
### Contrat automatisé VIS-04
|
||||
|
||||
Deux contrats couvrent le Hub pilote, et servent de modèle aux quatre lots de
|
||||
migration suivants : ce qu'un lot service doit prouver, c'est l'assemblage et la
|
||||
hiérarchie, pas le pixel.
|
||||
|
||||
`frontend/src/services/hub/pages/HubPilot.test.tsx` verrouille la composition :
|
||||
démarrage en rôle `notice` sans aplat de service et cinq outils de même poids,
|
||||
modules groupés par service avec leur décompte, **une seule action dominante** dans
|
||||
la colonne d'assemblage, un seul `h1` par surface sur l'édition, le public et le
|
||||
tableau de bord — les participations y sont une section. Les cartes attachées
|
||||
interrogent leur propre service : leur API est laissée sans réponse, le lot porte
|
||||
sur l'assemblage et non sur leur contenu.
|
||||
|
||||
`frontend/e2e/visual-hub.spec.ts` contrôle le rendu réel avec un Hub complet
|
||||
simulé (deux modules attachés, une participation) :
|
||||
|
||||
- tableau de bord, édition et page publique sur les cinq largeurs en FR et EN,
|
||||
sans débordement horizontal, captures jointes au rapport ;
|
||||
- groupes de modules nommés par service, et rail de collaboration inchangé —
|
||||
lien public, co-gestionnaires, commentaires ;
|
||||
- Hub vide : cinq tuiles réellement de même fond calculé, marqueur `onboarding`
|
||||
consommé puis retiré de l'URL ;
|
||||
- axe sans violation sérieuse ou critique sur `main` à 390 et 1440 px, avec le
|
||||
`failureSummary` dans le message d'échec — sans lui, un écart de contraste ne
|
||||
dit ni sa valeur ni ses couleurs.
|
||||
|
||||
Le contrôle axe **émule `prefers-reduced-motion`** : `.page` entre en fondu, et une
|
||||
mesure prise pendant les 200 ms d'animation lit des couleurs mélangées au fond
|
||||
(ici 4,31:1 au lieu de 4,5:1 sur un rôle qui tient AA à l'arrivée). Toute nouvelle
|
||||
recette axe sur une surface animée doit faire de même.
|
||||
|
||||
Commandes ciblées :
|
||||
|
||||
```bash
|
||||
npm run test:e2e -- e2e/visual-hub.spec.ts
|
||||
npx vitest run src/services/hub/pages/HubPilot.test.tsx
|
||||
```
|
||||
|
||||
### Matrice de contenu
|
||||
|
||||
Selon les routes touchées, vérifier :
|
||||
|
||||
- FR et EN lorsque du texte, une largeur ou une composition éditoriale change ;
|
||||
- visiteur, guest ayant participé, owner et co-owner lorsque ces rôles existent ;
|
||||
- état vide, état chargé, contenu long, erreur, succès et désactivé pertinents ;
|
||||
- titre ou nom long, URL longue, montant et devise, décimales et nombreuses
|
||||
options/dates lorsque le service les accepte ;
|
||||
- clavier seul, focus visible et ordre de tabulation ;
|
||||
- mouvement réduit ;
|
||||
- aucun scroll horizontal de `documentElement`, sauf zone dense explicitement
|
||||
conçue pour défiler ;
|
||||
- contraste et signification des états sans dépendre de la couleur seule.
|
||||
|
||||
### Échantillon de non-régression G4/G6
|
||||
|
||||
Avant VIS-08 puis pendant VIS-09, comparer au minimum :
|
||||
|
||||
- homepage FR et EN ;
|
||||
- une vitrine Hub et les cinq vitrines outils ;
|
||||
- une landing `organiser`, une `outils`, une `comparatif` et une éditoriale dans
|
||||
chaque langue ;
|
||||
- preview Hub mobile et au moins une preview de chaque service ;
|
||||
- Navbar, Footer et bandeau cookies sur 390 et 1440 px.
|
||||
|
||||
La recette confirme que le récit, l'unique preuve produit, le CTA principal,
|
||||
canonical, hreflang, JSON-LD, previews et liens de création restent inchangés.
|
||||
|
||||
### Preuve à inscrire dans la roadmap
|
||||
|
||||
Pour chaque item :
|
||||
|
||||
- routes, langues, rôles, états et viewports examinés ;
|
||||
- résultat des contrôles de largeur et d'axe ;
|
||||
- commandes TypeScript, i18n, Vitest, Playwright et build réellement exécutées ;
|
||||
- avant/après ou emplacement du rapport de captures ;
|
||||
- mesure du chunk initial si une police, un asset, une icône ou une dépendance
|
||||
change ;
|
||||
- toute limite de l'environnement de validation.
|
||||
|
|
|
|||
474
docs/audits/2026-08-style-visuel-produit.md
Normal file
474
docs/audits/2026-08-style-visuel-produit.md
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
# Audit Kankwa — style visuel du produit après G4
|
||||
|
||||
Date : 12 août 2026
|
||||
Statut : `VALIDÉ` par le propriétaire du produit
|
||||
Périmètre : frontend public et fonctionnel, système Tailwind et cohérence de marque
|
||||
Hors périmètre : architecture applicative, contrats API, modèle de données et parcours métier
|
||||
|
||||
Ce document conserve le diagnostic factuel ayant conduit à la refonte visuelle
|
||||
du produit. La cible normative est décrite dans `docs/DESIGN_SYSTEM.md` et le
|
||||
déroulement de l'implémentation est suivi exclusivement dans le jalon G8 de
|
||||
`docs/ROADMAP.md`.
|
||||
|
||||
## Résumé exécutif
|
||||
|
||||
La refonte marketing G4 et l'acquisition SEO G6 ont donné à Kankwa une face
|
||||
publique forte, cohérente et désirable. La homepage, les six vitrines et les
|
||||
landings utilisent un langage éditorial identifiable : fond encre, chaleur
|
||||
terracotta, grande typographie, rythme généreux, récit problème → résultat et une
|
||||
preuve produit unique.
|
||||
|
||||
Le passage dans les outils provoque toutefois une rupture nette. Les pages
|
||||
fonctionnelles héritent encore majoritairement d'un socle visuel utilitaire :
|
||||
titres petits, cartes blanches quasi uniformes, métadonnées très présentes,
|
||||
faible hiérarchie entre action et administration, rails collaboratifs techniques
|
||||
et expérience mobile parfois encombrée. Les palettes propres aux services sont
|
||||
bien propagées, mais ne suffisent pas à transmettre l'âme du produit.
|
||||
|
||||
La décision validée est donc la suivante :
|
||||
|
||||
> Faire descendre le langage visuel de G4 dans le produit, sans refaire les
|
||||
> vitrines et landings, sans modifier le cœur métier et sans transformer Kankwa
|
||||
> en logiciel de gestion générique.
|
||||
|
||||
La direction retenue est nommée **Chaleur utile** : une interface calme, humaine
|
||||
et expressive, structurée par l'encre et la crème Kankwa, guidée par la couleur
|
||||
de chaque service, et ponctuée d'interactions agréables mais sobres.
|
||||
|
||||
## Périmètre et méthode
|
||||
|
||||
### Sources consultées
|
||||
|
||||
- `CLAUDE.md`, `AGENTS.md`, `docs/ROADMAP.md` et
|
||||
`docs/AI_COLLABORATION.md` ;
|
||||
- `docs/audits/2026-08-code-produit.md` et
|
||||
`docs/audits/2026-08-seo-landings.md` ;
|
||||
- les mémoires Claude historiques utiles sous
|
||||
`/home/miaw/.claude/projects/-home-miaw-kankwa/memory/` : concept de
|
||||
plateforme, architecture frontend, copywriting, patterns de feedback,
|
||||
co-propriété, annulation et spécifications Kdo/Kal/Kwiz ;
|
||||
- le système Tailwind, les styles globaux, les composants partagés et les pages
|
||||
des six services ;
|
||||
- les tests frontend et E2E décrivant les contrats d'interaction existants.
|
||||
|
||||
Les mémoires Claude apportent du contexte historique, mais ne prévalent jamais
|
||||
sur le code, les migrations ou les documents versionnés récents. Une divergence
|
||||
notable a notamment été observée autour de l'état de l'offre Premium.
|
||||
|
||||
### Inspection réalisée
|
||||
|
||||
- revue statique des classes Tailwind et des primitives CSS ;
|
||||
- inventaire des composants publics, partagés et propres aux services ;
|
||||
- rendu de la homepage, des vitrines, des landings, de l'authentification et de
|
||||
plusieurs pages fonctionnelles en desktop et mobile ;
|
||||
- simulation locale des réponses API pour les vues authentifiées, sans écriture
|
||||
dans la base de données ;
|
||||
- vérification des largeurs, débordements, tailles de titres, couleurs de thème,
|
||||
densité de cartes et quantité d'actions visibles.
|
||||
|
||||
Les captures utilisées pendant l'audit étaient temporaires. Les mesures et
|
||||
conclusions utiles sont consignées ci-dessous ; aucun asset d'audit n'est requis
|
||||
pour comprendre ou reprendre le chantier.
|
||||
|
||||
## Évaluation synthétique
|
||||
|
||||
Ces notes sont des repères qualitatifs destinés à hiérarchiser le travail, pas
|
||||
des métriques produit.
|
||||
|
||||
| Axe | Évaluation | Lecture |
|
||||
|---|---:|---|
|
||||
| Identité de la homepage G4 | 9/10 | Direction forte et immédiatement reconnaissable |
|
||||
| Vitrines et landings SEO | 8,5/10 | Cohérents, distinctifs et reliés à une preuve réelle |
|
||||
| Cohérence public → produit | 5/10 | Rupture sensible à l'entrée dans les outils |
|
||||
| Hiérarchie des pages fonctionnelles | 4,5/10 | De nombreux éléments ont le même poids visuel |
|
||||
| Personnalité des services dans le produit | 6/10 | Palettes justes mais langage surtout générique |
|
||||
| Expérience mobile fonctionnelle | 5/10 | Parcours utilisables mais denses et parfois débordants |
|
||||
| Cohérence du système Tailwind | 6/10 | Bon socle de couleurs, tokens incomplets et variantes locales |
|
||||
| Maintenabilité visuelle | 6/10 | Primitives présentes mais insuffisamment structurantes |
|
||||
|
||||
## Référence à préserver : G4 et G6
|
||||
|
||||
La base publique ne doit pas être refondue une seconde fois. Les invariants
|
||||
suivants sont acquis et protégés :
|
||||
|
||||
- direction créative « encre + terracotta » ;
|
||||
- promesse courte et orientée bénéfice ;
|
||||
- récit problème → résultat ;
|
||||
- une seule preuve produit principale par page ;
|
||||
- un seul objectif de conversion dominant ;
|
||||
- respiration généreuse et hiérarchie éditoriale ;
|
||||
- six vitrines distinctives avec intention de service préservée ;
|
||||
- previews fidèles, sans fausse métrique ni preuve sociale inventée ;
|
||||
- `ServiceVitrine`, `SeoLanding` et `ServiceProductPreview` comme briques
|
||||
partagées ;
|
||||
- `serviceThemes.ts` comme source unique des palettes ;
|
||||
- `ServiceThemeScope` pour propager le thème sans dupliquer les composants ;
|
||||
- couleurs sémantiques universelles, jamais redéfinies selon un service ;
|
||||
- ratio intrinsèque de la preview Hub préservé sur mobile ;
|
||||
- contenu des 38 URL SEO piloté par configuration et i18n, sans composant local
|
||||
par landing.
|
||||
|
||||
L'harmonisation future de G4/G6 est limitée à l'adoption prudente des tokens
|
||||
validés, aux alignements de conteneurs et aux corrections de cohérence. Elle ne
|
||||
doit modifier ni le récit, ni l'ordre des sections, ni la stratégie de preuve,
|
||||
ni les CTA.
|
||||
|
||||
## Mesures du socle actuel
|
||||
|
||||
### Typographie fonctionnelle
|
||||
|
||||
Après exclusion du marketing, de l'administration et des tests, l'inventaire
|
||||
statique relève approximativement :
|
||||
|
||||
- 266 occurrences de `text-sm` ;
|
||||
- 193 occurrences de `text-xs` ;
|
||||
- 14 occurrences de `text-xl` ;
|
||||
- 12 occurrences de `text-2xl` ;
|
||||
- 7 occurrences de `text-lg` ;
|
||||
- 3 occurrences de `text-base`.
|
||||
|
||||
Le titre partagé des pages et dashboards est généralement en `text-xl`, soit
|
||||
20 px. Le produit repose donc massivement sur des textes de 12 à 14 px, y
|
||||
compris pour des informations et actions qui ne sont pas secondaires.
|
||||
|
||||
### Rayons et surfaces
|
||||
|
||||
L'ensemble des fichiers TSX contient notamment :
|
||||
|
||||
- 64 occurrences de `rounded-full` ;
|
||||
- 41 de `rounded-xl` ;
|
||||
- 32 de `rounded-lg` ;
|
||||
- 11 de `rounded-2xl` ;
|
||||
- 7 de `rounded-md` ;
|
||||
- 6 de `rounded-component` ;
|
||||
- 5 de `rounded-card` ;
|
||||
- plusieurs rayons arbitraires en `2rem` et `2.5rem`.
|
||||
|
||||
Les tokens `component` et `card` existent, mais ne structurent donc pas encore
|
||||
le produit. Les cartes génériques de `index.css` utilisent principalement fond
|
||||
blanc, bordure grise et ombre légère ; des ressources, aides, résumés et panneaux
|
||||
de collaboration restent visuellement trop proches.
|
||||
|
||||
### Formulaires
|
||||
|
||||
L'inventaire trouve environ 104 `input`, 19 `textarea` et 5 `select`, pour
|
||||
35 éléments `label`. Ce ratio ne constitue pas à lui seul une preuve de défaut
|
||||
d'accessibilité : certains contrôles possèdent un nom accessible autrement. Il
|
||||
signale néanmoins une dépendance importante aux placeholders et au contexte
|
||||
implicite. La refonte doit vérifier chaque parcours et généraliser les libellés
|
||||
persistants lorsque le champ porte une information durable.
|
||||
|
||||
## Constats détaillés
|
||||
|
||||
Les niveaux sont propres au chantier visuel :
|
||||
|
||||
- `C0` : défaut visible ou responsive à corriger avant la refonte de finition ;
|
||||
- `C1` : problème structurel qui empêche la cohérence globale ;
|
||||
- `C2` : amélioration de qualité ou de plaisir d'usage.
|
||||
|
||||
### VIS-AUD-01 — rupture public → produit (`C1`)
|
||||
|
||||
La homepage et les vitrines évoquent des moments partagés et une organisation
|
||||
simple. Les outils utilisent ensuite un langage plus administratif : arrière-plan
|
||||
crème, rectangles blancs, bordures fines, petits titres, métadonnées grises,
|
||||
formulaires et QR codes.
|
||||
|
||||
Le produit reste compréhensible, mais exprime moins clairement :
|
||||
|
||||
- le service actuellement utilisé ;
|
||||
- le caractère vivant et collectif de l'espace ;
|
||||
- la progression de l'organisateur ou de l'invité ;
|
||||
- la différence entre créer, participer, partager et administrer.
|
||||
|
||||
Preuves principales : `frontend/src/shared/components/ServicePageHeader.tsx`,
|
||||
`frontend/src/shared/components/ServiceDashboard.tsx` et
|
||||
`frontend/src/index.css`.
|
||||
|
||||
### VIS-AUD-02 — hiérarchie typographique trop faible (`C1`)
|
||||
|
||||
Les titres de page à 20 px se distinguent peu des titres de cartes. La densité de
|
||||
`text-xs` et `text-sm` donne à plusieurs pages l'allure d'un panneau de réglages,
|
||||
particulièrement sur Kal, Kount et le compte.
|
||||
|
||||
Effets observés :
|
||||
|
||||
- balayage visuel difficile ;
|
||||
- fatigue accrue sur mobile ;
|
||||
- aides et informations utiles perçues comme accessoires ;
|
||||
- faible présence émotionnelle du contexte et de la progression.
|
||||
|
||||
### VIS-AUD-03 — surfaces génériques et sémantique des cartes (`C1`)
|
||||
|
||||
Une carte ressource, un état vide, un panneau de partage, une aide, un commentaire
|
||||
et un résumé comptable utilisent souvent le même triptyque fond blanc + bordure
|
||||
grise + rayon moyen. Le type de surface n'indique pas son rôle.
|
||||
|
||||
La cible doit distinguer au minimum : ressource, interaction, résumé,
|
||||
collaboration, configuration, aide et état vide, sans créer un composant abstrait
|
||||
pour chaque écran.
|
||||
|
||||
### VIS-AUD-04 — palettes justes mais sous-exploitées (`C1`)
|
||||
|
||||
`serviceThemes.ts` et `ServiceThemeScope` fonctionnent correctement. La couleur
|
||||
de service se limite toutefois souvent au bouton primaire, au focus, à quelques
|
||||
badges et à la navigation active. Elle influe peu sur la composition générale et
|
||||
les états métier.
|
||||
|
||||
La couleur doit guider le masthead, les sélections, la progression, les icônes de
|
||||
section et certaines surfaces interactives, avec une faible opacité. Elle ne doit
|
||||
pas colorer toutes les cartes ni remplacer le rouge, l'orange, le vert ou le bleu
|
||||
sémantique.
|
||||
|
||||
### VIS-AUD-05 — débordement horizontal partagé sur mobile (`C0`)
|
||||
|
||||
`.page` applique une gouttière de 16 px sur mobile, alors que
|
||||
`ServicePageHeader` applique `-mx-6 px-6` sans breakpoint. Sur un viewport de
|
||||
393 px, plusieurs pages mesurées atteignent 401 px de largeur.
|
||||
|
||||
Parcours confirmés pendant l'audit : Hub édition et Kdo public. Comme la cause est
|
||||
partagée, toutes les pages utilisant ce shell doivent être vérifiées.
|
||||
|
||||
Preuves : `frontend/src/index.css` et
|
||||
`frontend/src/shared/components/ServicePageHeader.tsx`.
|
||||
|
||||
### VIS-AUD-06 — shell et navigation mal raccordés à G4 (`C1`)
|
||||
|
||||
La Navbar est une barre d'outils en deux lignes, pleine largeur et à marges fixes.
|
||||
Les pages publiques utilisent des conteneurs `max-w-6xl` et `max-w-7xl`, tandis
|
||||
que le Footer reste en `max-w-4xl`. Les axes de lecture ne sont donc pas toujours
|
||||
alignés.
|
||||
|
||||
Sur mobile, les noms des services disparaissent au profit d'icônes seules. Le
|
||||
contrat est accessible techniquement grâce aux textes masqués, mais la
|
||||
découvrabilité visuelle reste faible pour un nouvel utilisateur.
|
||||
|
||||
Preuves : `frontend/src/shared/components/Navbar.tsx` et
|
||||
`frontend/src/shared/components/Footer.tsx`.
|
||||
|
||||
### VIS-AUD-07 — rails collaboratifs trop dominants (`C1`)
|
||||
|
||||
La structure trois colonnes est pertinente et doit être conservée, conformément
|
||||
aux invariants historiques. Son traitement crée néanmoins plusieurs problèmes :
|
||||
|
||||
- QR code parfois plus visible que l'objet métier ;
|
||||
- partage, co-propriété et commentaires traités comme une succession de petits
|
||||
formulaires ;
|
||||
- pages très longues sur mobile ;
|
||||
- grands espaces vides lorsque le contenu principal est court ;
|
||||
- compétition entre les actions du rail et l'action principale de la page.
|
||||
|
||||
La refonte doit traiter ces éléments comme une zone de collaboration secondaire,
|
||||
sans changer l'ordre des workflows. Un éventuel QR repliable est une évolution de
|
||||
comportement et exige une validation séparée.
|
||||
|
||||
### VIS-AUD-08 — authentification et compte génériques (`C1`)
|
||||
|
||||
Sur mobile, l'authentification plein écran devient une grande surface blanche qui
|
||||
efface le contexte et la personnalité de Kankwa. L'ergonomie plein écran est
|
||||
acceptable ; son traitement visuel doit rappeler la marque et l'intention à
|
||||
l'origine de la connexion.
|
||||
|
||||
Le compte empile plusieurs cartes denses avec de nombreuses informations en
|
||||
`text-xs`. Sa hiérarchie identité → usage → abonnement → sécurité est faible.
|
||||
Le texte indiquant que Kankwa+ « arrive bientôt » semble également diverger du
|
||||
parcours Stripe présent dans le code et doit être vérifié avant toute mise en
|
||||
forme définitive.
|
||||
|
||||
Preuves : `frontend/src/shared/pages/Login.tsx`,
|
||||
`frontend/src/shared/pages/Register.tsx`, `frontend/src/shared/components/Modal.tsx`
|
||||
et `frontend/src/shared/pages/Account.tsx`.
|
||||
|
||||
### VIS-AUD-09 — bandeau cookies obstructif sur mobile (`C0`)
|
||||
|
||||
Le bandeau fixe passe en colonne et occupe approximativement le tiers inférieur
|
||||
d'un écran 393 × 844 lors de la première visite. Il masque une partie de la preuve
|
||||
ou de la réassurance que G4 place précisément dans le premier viewport.
|
||||
|
||||
Il doit être raccourci et recomposé sans affaiblir le consentement, le contraste
|
||||
ou les zones tactiles.
|
||||
|
||||
Preuve : `frontend/src/shared/components/CookieBanner.tsx`.
|
||||
|
||||
### VIS-AUD-10 — couleurs et styles codés localement (`C0`)
|
||||
|
||||
Quelques composants contournent encore les thèmes et tokens partagés :
|
||||
|
||||
- terracotta `#C77548` dans le sélecteur horaire Kal ;
|
||||
- `amber` utilisé directement pour des votes ou statuts ;
|
||||
- `red`, `emerald` et autres couleurs brutes dans certaines pages ;
|
||||
- ombres et gris codés localement ;
|
||||
- rayons arbitraires nombreux.
|
||||
|
||||
Le cas Kal est particulièrement visible : un contrôle d'un service bleu reprend
|
||||
la couleur historique du Hub.
|
||||
|
||||
Preuves : `frontend/src/services/kal/components/TimeSlotSelector.tsx`,
|
||||
`frontend/src/services/kal/components/VoteSelectionGrid.tsx` et
|
||||
`frontend/src/shared/pages/Account.tsx`.
|
||||
|
||||
### VIS-AUD-11 — incohérences éditoriales visibles (`C0`)
|
||||
|
||||
La finition visuelle doit inclure les défauts de texte directement exposés :
|
||||
|
||||
- « Écris ton commentaire… » tutoie sur une page publique où le vouvoiement est
|
||||
l'invariant documenté ;
|
||||
- « Choisissez une option » est codé en français dans un composant Kwiz ;
|
||||
- les libellés `vote/votes` de résultats Kwiz méritent une vérification i18n ;
|
||||
- un caractère `)` parasite s'affiche après « 3. Usage acceptable » dans les CGU
|
||||
françaises ;
|
||||
- plusieurs pages transverses utilisent des emojis ou glyphes ponctuels là où
|
||||
le produit dispose d'icônes SVG canoniques ;
|
||||
- certaines répartitions Kount peuvent afficher trop de décimales.
|
||||
|
||||
Preuves : `frontend/src/i18n/locales/fr/common.json`,
|
||||
`frontend/src/services/kwiz/components/VoteForm.tsx`,
|
||||
`frontend/src/services/kwiz/components/ResultsBar.tsx`,
|
||||
`frontend/src/shared/pages/Terms.tsx` et les composants Kount de répartition.
|
||||
|
||||
### VIS-AUD-12 — formulaires trop dépendants du contexte (`C1`)
|
||||
|
||||
La primitive `.input` est utile mais ne suffit pas à exprimer libellé, aide,
|
||||
erreur, succès et regroupement. La refonte doit définir une composition de champ
|
||||
persistante, sans modifier les données envoyées ni les règles de validation.
|
||||
|
||||
Les contrôles principaux doivent viser 40 à 44 px de hauteur, un focus visible et
|
||||
un message d'erreur placé de façon stable. Les placeholders ne doivent servir
|
||||
qu'à donner un exemple.
|
||||
|
||||
### VIS-AUD-13 — densité et respiration mal réparties (`C2`)
|
||||
|
||||
Le produit est simultanément dense localement et vide globalement : beaucoup de
|
||||
petits textes et actions dans les cartes, puis de grands espaces sans structure
|
||||
sous ou autour du contenu. Hub édition, Kal public, Kount édition et le compte
|
||||
illustrent ce contraste.
|
||||
|
||||
La solution n'est pas d'ajouter des panneaux, mais d'agrandir les éléments utiles,
|
||||
de mieux grouper les actions et de donner une largeur intentionnelle aux zones de
|
||||
lecture.
|
||||
|
||||
### VIS-AUD-14 — mouvement et plaisir d'usage limités (`C2`)
|
||||
|
||||
Le socle fournit essentiellement un `fade-in` et un shimmer. Les sélections,
|
||||
cartes interactives, confirmations et transitions de progression sont peu
|
||||
expressives. Une grammaire courte de 120/180/240 ms peut apporter du plaisir sans
|
||||
faire du produit une vitrine animée. `prefers-reduced-motion` est obligatoire.
|
||||
|
||||
### VIS-AUD-15 — dette legacy à ne pas migrer aveuglément (`C1`)
|
||||
|
||||
L'audit a relevé des composants Kal historiques sans import actif, notamment
|
||||
`VoteGrid.tsx` et `VoteForm.tsx`, ainsi qu'une primitive CSS
|
||||
`.layout-main-aside` apparemment inutilisée. Avant de styliser un composant, il
|
||||
faut confirmer qu'il appartient au chemin réellement rendu. La refonte ne doit
|
||||
pas investir dans du code mort ni le supprimer sans audit d'usage dédié.
|
||||
|
||||
## Lecture par famille de pages
|
||||
|
||||
| Zone | Situation actuelle | Cible de la refonte |
|
||||
|---|---|---|
|
||||
| Homepage | Forte et distinctive | Préserver la structure ; tokens seulement en fin de chantier |
|
||||
| Vitrines | Six identités cohérentes | Préserver récit, preuve et CTA |
|
||||
| Landings SEO | Cohérentes avec G4 | Migration de tokens et non-régression des 38 URL |
|
||||
| Démo publique | Plus proche du legacy | La raccorder au shell public sans dupliquer une preuve |
|
||||
| Hub | Onboarding et QR dominants | Cockpit chaleureux, modules clairement identifiés par service |
|
||||
| Kdo | Lisible mais plat | Cartes plus généreuses, états disponible/réservé/offert nets |
|
||||
| Kontrib | Très proche de Kdo | Même famille de cartes, tonalité plus logistique et active |
|
||||
| Kal | Dense, petite grille | États de vote lisibles et tactiles, contexte mieux hiérarchisé |
|
||||
| Kwiz | Fonctionnel et standard | Choix plus tactiles, résultat immédiatement comparable |
|
||||
| Kount | Densité cognitive élevée | Résumé dominant, dépenses secondaires, actions regroupées |
|
||||
| Authentification | Claire mais générique | Contexte de marque compact, sans hero marketing |
|
||||
| Compte | Empilement administratif | Identité, usage, abonnement et sécurité séparés |
|
||||
| Pages légales/système | Traitement minimal | Shell éditorial cohérent avec Kankwa |
|
||||
| Modales | Bonne base technique | Hiérarchie, espacements et contexte de service |
|
||||
| Navbar/Footer | Fonctionnels, axes incohérents | Variantes public/workspace et conteneurs alignés |
|
||||
|
||||
## Rendus fonctionnels mesurés
|
||||
|
||||
Les vues authentifiées ont été rendues avec une API simulée localement. Les
|
||||
mesures notables sont :
|
||||
|
||||
| Vue | Largeur | Hauteur de contenu | Observation principale |
|
||||
|---|---:|---:|---|
|
||||
| Hub édition desktop | 1440 | 1577 px | Titre 20 px, QR et onboarding très présents |
|
||||
| Hub édition mobile | 393 | 1925 px | Largeur réelle 401 px, rail très long |
|
||||
| Kdo public mobile | 393 | 1319 px | Largeur réelle 401 px, carte couverte peu contrastée |
|
||||
| Kal public desktop | 1440 | 1057 px | Grille et aides petites, grand vide périphérique |
|
||||
| Kount édition desktop | 1440 | 1529 px | 10 cartes et 28 boutons, forte compétition visuelle |
|
||||
| Compte desktop | 1440 | 1057 px | Cartes étroites, hiérarchie faible |
|
||||
|
||||
Ces mesures ne constituent pas des seuils à reproduire. Elles servent de baseline
|
||||
pour vérifier que G8 réduit le débordement, la densité et la compétition visuelle.
|
||||
|
||||
## Risques à maîtriser
|
||||
|
||||
### Diluer G4
|
||||
|
||||
Appliquer les nouveaux composants publics trop tôt pourrait uniformiser les pages
|
||||
qui ont justement gagné en singularité. G4/G6 doivent être traités en dernier,
|
||||
après validation du produit fonctionnel.
|
||||
|
||||
### Surutiliser les couleurs de service
|
||||
|
||||
Une couleur d'accent n'est pas un fond universel. Les surfaces principales restent
|
||||
neutres et chaleureuses ; la couleur guide les interactions et l'identité.
|
||||
|
||||
### Transformer la refonte en chantier d'architecture
|
||||
|
||||
Les variantes de présentation et wrappers légers sont autorisés. Toute évolution
|
||||
de navigation, ordre des workflows, persistance, contrat API ou comportement
|
||||
métier sort de G8 et exige un item séparé.
|
||||
|
||||
### Casser les parcours tactiles ou accessibles
|
||||
|
||||
Kal, les modales, les gestes de suppression, le focus, les portails thémés et les
|
||||
parcours guest/co-owner possèdent des invariants fonctionnels. La finition visuelle
|
||||
ne justifie aucune régression.
|
||||
|
||||
### Accroître le bundle
|
||||
|
||||
Une police ou un asset neuf doit être auto-hébergé, sous-ensemble si possible et
|
||||
mesuré. La refonte ne doit pas charger une bibliothèque d'icônes ou d'animation
|
||||
pour quelques détails.
|
||||
|
||||
## Séquencement recommandé
|
||||
|
||||
1. Corriger les anomalies de cohérence et le débordement mobile.
|
||||
2. Valider un style tile et les fondations Tailwind.
|
||||
3. Refaire les primitives et le shell partagé.
|
||||
4. Utiliser Hub comme pilote fonctionnel.
|
||||
5. Migrer Kdo et Kontrib, puis Kount, puis Kal et Kwiz.
|
||||
6. Harmoniser les pages transverses et système.
|
||||
7. Répercuter uniquement les tokens nécessaires sur G4/G6.
|
||||
8. Exécuter la matrice responsive, accessibilité, i18n et performance.
|
||||
|
||||
Le détail, les dépendances, les statuts et les preuves attendues sont dans G8 de
|
||||
`docs/ROADMAP.md`.
|
||||
|
||||
## Critères de sortie de la refonte
|
||||
|
||||
- aucune page critique ne déborde horizontalement à 320, 390, 768, 1280 et
|
||||
1440 px ;
|
||||
- les six services restent identifiables sans compromettre les couleurs
|
||||
sémantiques ;
|
||||
- chaque page possède un titre, une action principale et un contenu dominant
|
||||
clairement perceptibles ;
|
||||
- aucune information essentielle ne repose par défaut sur `text-xs` ;
|
||||
- les champs critiques ont un nom accessible et un libellé visuel persistant ;
|
||||
- les actions tactiles principales atteignent une taille confortable ;
|
||||
- le bandeau cookies ne masque plus la preuve du premier viewport mobile ;
|
||||
- navigation clavier, focus et contrastes atteignent les exigences documentées ;
|
||||
- `prefers-reduced-motion` est respecté ;
|
||||
- les parcours guest, owner, co-owner, annulation et conversion restent inchangés ;
|
||||
- les interactions tactiles Kal et la suppression par geste restent intactes ;
|
||||
- les vitrines et landings conservent récit, preuve, CTA, SEO et performance ;
|
||||
- les validations listées dans `docs/TESTING.md` sont exécutées et leurs preuves
|
||||
inscrites dans la roadmap.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Kankwa dispose déjà de la bonne histoire et d'une bonne expression publique. Le
|
||||
prochain enjeu n'est pas d'ajouter davantage de marketing, mais de faire ressentir
|
||||
la même qualité lorsque l'utilisateur crée, partage ou rejoint un espace.
|
||||
|
||||
La refonte validée est ambitieuse sur le style et conservatrice sur le produit :
|
||||
elle unifie la typographie, les surfaces, les composants, les shells et les états,
|
||||
tout en protégeant strictement l'architecture et les fonctionnalités existantes.
|
||||
|
|
@ -222,5 +222,9 @@ test('la vitrine Kdo conserve son intention jusqu’à l’onboarding du Hub', a
|
|||
await expect(page).toHaveURL(new RegExp(`/hub/${eventId}\\?service=kdo$`))
|
||||
const onboarding = page.getByRole('region', { name: 'Par quoi souhaitez-vous commencer ?' })
|
||||
await expect(onboarding.getByText('Votre choix est prêt : commencez par Kdo.')).toBeVisible()
|
||||
await expect(onboarding.getByRole('button', { name: /Kdo/ })).toHaveClass(/ring-2/)
|
||||
// Le service demandé reste signalé, désormais par un libellé autant que par
|
||||
// l'anneau : la sélection ne repose pas sur la couleur seule (VIS-04).
|
||||
const recommended = onboarding.getByRole('button', { name: /Kdo/ })
|
||||
await expect(recommended).toHaveClass(/ring-line-accent/)
|
||||
await expect(recommended.getByText('Conseillé')).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
47
frontend/e2e/helpers/overflow.ts
Normal file
47
frontend/e2e/helpers/overflow.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Contrôle transverse de la recette visuelle : la fenêtre ne doit jamais défiler
|
||||
* horizontalement.
|
||||
*
|
||||
* Deux dépassements restent légitimes et sont écartés du relevé : ce qui défile
|
||||
* volontairement dans son propre conteneur (rangée de services de la Navbar,
|
||||
* grille dense marquée `data-dense-scroll`) et les décors des pages publiques,
|
||||
* rognés par un ancêtre au débordement masqué. Ce qui est mesuré est donc le
|
||||
* seul dépassement réellement perceptible — celui qui ferait défiler la page.
|
||||
*
|
||||
* `documentElement.scrollWidth` ne sait pas faire cette distinction sous
|
||||
* Chromium : la vérité utilisateur est mesurée en tentant réellement de faire
|
||||
* défiler la fenêtre, puis en listant les éléments sortis du viewport.
|
||||
*/
|
||||
export async function expectNoHorizontalOverflow(page: Page) {
|
||||
const result = await page.evaluate(() => {
|
||||
window.scrollTo(9999, 0)
|
||||
const scrolled = window.scrollX
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
const clipped = (element: HTMLElement) => {
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (getComputedStyle(node).overflowX !== 'visible') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const client = document.documentElement.clientWidth
|
||||
const offenders = [...document.querySelectorAll<HTMLElement>('body *')]
|
||||
.filter(element => !clipped(element) && !element.closest('[data-dense-scroll]'))
|
||||
.map(element => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => rect.width > 0 && (rect.left < -0.5 || rect.right > client + 1.5))
|
||||
.slice(0, 8)
|
||||
.map(({ element, rect }) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
}))
|
||||
return { scrolled, offenders }
|
||||
})
|
||||
|
||||
expect(result.scrolled, 'la fenêtre ne doit pas défiler horizontalement').toBe(0)
|
||||
expect(result.offenders, JSON.stringify(result.offenders)).toEqual([])
|
||||
}
|
||||
|
|
@ -27,8 +27,11 @@ test('une modale métier hérite de la palette du service sans modifier Modal',
|
|||
await expect(dialog).toHaveAttribute('data-service-theme', 'kdo')
|
||||
expect(await dialog.evaluate(node => getComputedStyle(node).getPropertyValue('--color-primary-600').trim())).toBe('138 90 24')
|
||||
|
||||
// L'action principale prend le cran 700 de la palette du service : c'est le
|
||||
// fond que `.btn-primary` déclare depuis VIS-03 (contraste AA sous les sept
|
||||
// palettes, cf. `designTokens.test.ts`).
|
||||
const submit = dialog.getByRole('button', { name: 'Créer la liste' })
|
||||
await expect(submit).toHaveCSS('background-color', 'rgb(138, 90, 24)')
|
||||
await expect(submit).toHaveCSS('background-color', 'rgb(114, 71, 17)')
|
||||
|
||||
const axe = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()
|
||||
expect(axe.violations).toEqual([])
|
||||
|
|
@ -86,24 +89,24 @@ test('chaque espace métier propage sa palette aux composants génériques', asy
|
|||
await page.addInitScript(() => localStorage.setItem('token', 'theme-scope-test-token'))
|
||||
|
||||
const services = [
|
||||
{ id: 'hub', path: '/hub', color: '138 68 34', rgb: 'rgb(138, 68, 34)' },
|
||||
{ id: 'kdo', path: '/kdo', color: '138 90 24', rgb: 'rgb(138, 90, 24)' },
|
||||
{ id: 'kal', path: '/kal', color: '47 74 116', rgb: 'rgb(47, 74, 116)' },
|
||||
{ id: 'kwiz', path: '/kwiz', color: '45 106 87', rgb: 'rgb(45, 106, 87)' },
|
||||
{ id: 'kontrib', path: '/kontrib', color: '132 55 76', rgb: 'rgb(132, 55, 76)' },
|
||||
{ id: 'kount', path: '/kount', color: '85 65 116', rgb: 'rgb(85, 65, 116)' },
|
||||
{ id: 'hub', path: '/hub', color: '138 68 34', action: 'rgb(120, 57, 28)' },
|
||||
{ id: 'kdo', path: '/kdo', color: '138 90 24', action: 'rgb(114, 71, 17)' },
|
||||
{ id: 'kal', path: '/kal', color: '47 74 116', action: 'rgb(38, 61, 96)' },
|
||||
{ id: 'kwiz', path: '/kwiz', color: '45 106 87', action: 'rgb(36, 86, 70)' },
|
||||
{ id: 'kontrib', path: '/kontrib', color: '132 55 76', action: 'rgb(110, 45, 64)' },
|
||||
{ id: 'kount', path: '/kount', color: '85 65 116', action: 'rgb(69, 51, 95)' },
|
||||
]
|
||||
|
||||
for (const service of services) {
|
||||
await page.goto(service.path)
|
||||
const scope = page.locator(`[data-service-theme-scope="${service.id}"]`)
|
||||
await expect(scope).toHaveAttribute('style', new RegExp(`--color-primary-600: ${service.color}`))
|
||||
await expect(page.locator('main .btn-primary').first()).toHaveCSS('background-color', service.rgb)
|
||||
await expect(page.locator('main .btn-primary').first()).toHaveCSS('background-color', service.action)
|
||||
}
|
||||
|
||||
await page.goto('/kount/groups/kount-detail/edit')
|
||||
const detailScope = page.locator('[data-service-theme-scope="kount"]')
|
||||
await expect(detailScope).toHaveAttribute('style', /--color-primary-600: 85 65 116/)
|
||||
await expect(page.getByRole('button', { name: '+ Ajouter' })).toHaveCSS('background-color', 'rgb(85, 65, 116)')
|
||||
await expect(page.getByRole('button', { name: '+ Ajouter' })).toHaveCSS('background-color', 'rgb(69, 51, 95)')
|
||||
await expect(page.locator('span.text-primary-600', { hasText: '30,50 €' })).toHaveCSS('color', 'rgb(85, 65, 116)')
|
||||
})
|
||||
|
|
|
|||
133
frontend/e2e/style-tile.spec.ts
Normal file
133
frontend/e2e/style-tile.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
import { expectNoHorizontalOverflow } from './helpers/overflow'
|
||||
|
||||
/**
|
||||
* Contrat automatisé VIS-01.
|
||||
*
|
||||
* `/style-tile` est la surface de revue du contrat de design. Ce test verrouille
|
||||
* ce qui doit rester vrai des fondations elles-mêmes : l'échelle typographique
|
||||
* fluide, l'absence de débordement sur les cinq largeurs de `docs/TESTING.md`,
|
||||
* le chargement réel de la police auto-hébergée et le respect du mouvement
|
||||
* réduit. Il ne juge pas l'esthétique — c'est le rôle de la revue humaine du
|
||||
* checkpoint « Fondations ».
|
||||
*/
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ width: 320, height: 720, name: '320' },
|
||||
{ width: 390, height: 844, name: '390' },
|
||||
{ width: 768, height: 1024, name: '768' },
|
||||
{ width: 1280, height: 900, name: '1280' },
|
||||
{ width: 1440, height: 1000, name: '1440' },
|
||||
]
|
||||
|
||||
test('le style tile tient sur les cinq largeurs de la recette visuelle', async ({ page }, testInfo) => {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height })
|
||||
await page.goto('/style-tile')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
// Les cibles tactiles principales de la zone dense restent confortables.
|
||||
const cell = page.locator('table span').first()
|
||||
expect((await cell.boundingBox())!.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
await testInfo.attach(`style-tile-${viewport.name}`, {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('l’échelle typographique est réellement fluide entre 320 et 1440 px', async ({ page }) => {
|
||||
const read = async (width: number) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto('/style-tile')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
return page.evaluate(() => {
|
||||
const size = (selector: string) =>
|
||||
parseFloat(getComputedStyle(document.querySelector(selector)!).fontSize)
|
||||
return {
|
||||
display: size('.text-display'),
|
||||
pageTitle: size('h1'),
|
||||
meta: size('.text-meta'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const narrow = await read(320)
|
||||
const wide = await read(1440)
|
||||
|
||||
// Bornes du clamp : 40 → 72 px pour `display`, 28 → 36 px pour `page-title`.
|
||||
expect(narrow.display).toBeCloseTo(40, 0)
|
||||
expect(wide.display).toBeGreaterThan(narrow.display)
|
||||
expect(wide.display).toBeLessThanOrEqual(72)
|
||||
expect(narrow.pageTitle).toBeCloseTo(28, 0)
|
||||
expect(wide.pageTitle).toBeGreaterThan(narrow.pageTitle)
|
||||
|
||||
// `meta` est le plus petit rôle autorisé et ne suit pas le viewport.
|
||||
expect(narrow.meta).toBeCloseTo(13, 0)
|
||||
expect(wide.meta).toBeCloseTo(13, 0)
|
||||
})
|
||||
|
||||
test('la police de titre est auto-hébergée et réellement chargée', async ({ page }) => {
|
||||
const fontRequests: string[] = []
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url())
|
||||
if (/\.(woff2?|ttf|otf)$/.test(url.pathname)) fontRequests.push(`${url.host}${url.pathname}`)
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.goto('/style-tile')
|
||||
// La police n'est demandée qu'une fois un titre réellement rendu : attendre le
|
||||
// seul chargement du document observerait une page encore vide.
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
// Le fichier est servi par le domaine du produit : aucun appel à un tiers.
|
||||
expect(fontRequests).toEqual(['127.0.0.1:4173/fonts/bricolage-grotesque-latin-600-800.woff2'])
|
||||
expect(await page.evaluate(async () => {
|
||||
const faces = await document.fonts.load('700 32px "Bricolage Grotesque"')
|
||||
return faces.length > 0 && faces.every(face => face.status === 'loaded')
|
||||
})).toBe(true)
|
||||
|
||||
// Décochée, la bascule rend la comparaison possible sans recharger la page.
|
||||
const before = await page.locator('h1').evaluate(el => getComputedStyle(el).fontFamily)
|
||||
await page.getByRole('checkbox', { name: /Bricolage Grotesque/ }).uncheck()
|
||||
const after = await page.locator('h1').evaluate(el => getComputedStyle(el).fontFamily)
|
||||
expect(before).toContain('Bricolage Grotesque')
|
||||
expect(after).not.toContain('Bricolage Grotesque')
|
||||
})
|
||||
|
||||
test('le mouvement réduit neutralise les transitions sans changer l’état final', async ({ browser }) => {
|
||||
const context = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 1280, height: 900 } })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/style-tile')
|
||||
|
||||
await page.getByRole('button', { name: 'Jouer les trois durées' }).click()
|
||||
const bar = page.locator('.duration-panel').first()
|
||||
await expect(bar).toHaveClass(/w-full/)
|
||||
// Chromium sérialise 0,01 ms en notation scientifique : comparer le nombre.
|
||||
const duration = await bar.evaluate(el => getComputedStyle(el).transitionDuration)
|
||||
expect(parseFloat(duration)).toBeLessThan(0.001)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('le style tile ne présente aucune violation axe sérieuse ou critique', async ({ page }) => {
|
||||
for (const width of [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto('/style-tile')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
|
||||
// Portée limitée au contenu : la Navbar et le pied de page appartiennent à
|
||||
// VIS-03 et portent des écarts connus (contraste, liens sans nom
|
||||
// accessible) que ce lot n'a pas mandat de corriger.
|
||||
const results = await new AxeBuilder({ page }).include('main').analyze()
|
||||
const blocking = results.violations.filter(v => v.impact === 'critical' || v.impact === 'serious')
|
||||
expect(JSON.stringify(blocking.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.html) })), null, 2)).toBe('[]')
|
||||
}
|
||||
})
|
||||
98
frontend/e2e/visual-foundations.spec.ts
Normal file
98
frontend/e2e/visual-foundations.spec.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
const shareToken = '00000000-0000-4000-8000-000000000101'
|
||||
|
||||
const guestList = {
|
||||
id: '00000000-0000-4000-8000-000000000102',
|
||||
title: 'Une liste au titre volontairement long pour le contrôle mobile',
|
||||
description: 'Des idées partagées sans déborder de la page.',
|
||||
items: [{
|
||||
id: '00000000-0000-4000-8000-000000000103',
|
||||
name: 'Un cadeau', description: null, price: null, url: null, image_url: null,
|
||||
position: 0, is_priority: false, is_reserved: false, reserved_by: null,
|
||||
participation_mode: false, contribution_total: 0, contribution_count: 0,
|
||||
}],
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: import('@playwright/test').Page) {
|
||||
const width = await page.evaluate(() => {
|
||||
const client = document.documentElement.clientWidth
|
||||
const offenders = [...document.querySelectorAll<HTMLElement>('body *')]
|
||||
.map(element => ({ element, rect: element.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => rect.left < -0.5 || rect.right > client + 0.5)
|
||||
.slice(0, 8)
|
||||
.map(({ element, rect }) => ({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: element.className,
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
}))
|
||||
return { client, scroll: document.documentElement.scrollWidth, offenders }
|
||||
})
|
||||
expect(width.scroll, JSON.stringify(width.offenders)).toBeLessThanOrEqual(width.client)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.removeItem('cookie_notice_dismissed'))
|
||||
})
|
||||
|
||||
test('le header service résiste aux largeurs critique et desktop sans débordement', async ({ page }, testInfo) => {
|
||||
await page.route(`**/api/kdo/lists/shared/${shareToken}`, route => route.fulfill({ json: guestList }))
|
||||
await page.route('**/api/shared/comments/**', route => route.fulfill({ json: [] }))
|
||||
|
||||
for (const lang of ['fr', 'en']) {
|
||||
await page.goto('/')
|
||||
await page.evaluate(language => localStorage.setItem('kankwa_lang', language), lang)
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 1000 : 844 })
|
||||
await page.goto(`/share/${shareToken}`)
|
||||
await expect(page.getByRole('heading', { name: guestList.title })).toBeVisible()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
const headerBox = await page.locator('main .sticky').first().boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(headerBox!.x).toBeGreaterThanOrEqual(-0.5)
|
||||
expect(headerBox!.x + headerBox!.width).toBeLessThanOrEqual(width + 0.5)
|
||||
|
||||
await testInfo.attach(`header-kdo-${lang}-${width}px`, {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const axe = await new AxeBuilder({ page }).include('main').analyze()
|
||||
expect(axe.violations.filter(violation => violation.impact === 'critical')).toEqual([])
|
||||
})
|
||||
|
||||
test('le bandeau de confidentialité reste compact sur mobile et desktop', async ({ page }, testInfo) => {
|
||||
const locales = [
|
||||
{ lang: 'fr', text: 'Stockage essentiel et mesure d’audience configurable.', cta: 'Créer mon événement' },
|
||||
{ lang: 'en', text: 'Essential local storage and configurable audience measurement.', cta: 'Create my event' },
|
||||
]
|
||||
for (const locale of locales) {
|
||||
await page.goto('/')
|
||||
await page.evaluate(language => localStorage.setItem('kankwa_lang', language), locale.lang)
|
||||
for (const width of [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 1000 : 844 })
|
||||
await page.goto('/')
|
||||
|
||||
const banner = page.getByText(locale.text).locator('..').locator('..')
|
||||
await expect(banner).toBeVisible()
|
||||
const box = await banner.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.height).toBeLessThanOrEqual(72)
|
||||
await expect(page.getByRole('link', { name: locale.cta }).first()).toBeInViewport()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
await testInfo.attach(`cookie-homepage-${locale.lang}-${width}px`, {
|
||||
body: await page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const axe = await new AxeBuilder({ page }).analyze()
|
||||
expect(axe.violations.filter(violation => violation.impact === 'critical')).toEqual([])
|
||||
})
|
||||
236
frontend/e2e/visual-hub.spec.ts
Normal file
236
frontend/e2e/visual-hub.spec.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
import { expectNoHorizontalOverflow } from './helpers/overflow'
|
||||
|
||||
/**
|
||||
* Contrat automatisé VIS-04 — le Hub comme pilote fonctionnel.
|
||||
*
|
||||
* Le lot porte le checkpoint « Pilote » : les quatre surfaces du Hub (tableau de
|
||||
* bord, création, édition, public) doivent exprimer l'assemblage des services,
|
||||
* tenir les cinq largeurs de la recette dans les deux langues et rester sans
|
||||
* violation axe bloquante. Le partage, les permissions et l'onboarding ne
|
||||
* changent pas : ce test vérifie qu'ils sont toujours là.
|
||||
*/
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ width: 320, height: 720 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1280, height: 900 },
|
||||
{ width: 1440, height: 1000 },
|
||||
]
|
||||
|
||||
const userId = '00000000-0000-4000-8000-000000000301'
|
||||
const eventId = '00000000-0000-4000-8000-000000000302'
|
||||
const shareToken = '00000000-0000-4000-8000-000000000303'
|
||||
const kdoId = '00000000-0000-4000-8000-000000000304'
|
||||
const kontribId = '00000000-0000-4000-8000-000000000305'
|
||||
const kdoToken = '00000000-0000-4000-8000-000000000306'
|
||||
const kontribToken = '00000000-0000-4000-8000-000000000307'
|
||||
|
||||
const user = {
|
||||
id: userId,
|
||||
email: 'lea@example.test',
|
||||
lang: 'fr',
|
||||
email_verified: true,
|
||||
is_premium: false,
|
||||
premium_until: null,
|
||||
subscription_plan: null,
|
||||
subscription_status: null,
|
||||
cancel_at_period_end: false,
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
has_password: true,
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: eventId,
|
||||
title: 'Anniversaire de Léa',
|
||||
event_type: 'anniversaire',
|
||||
event_date: '2026-09-12',
|
||||
location: 'Lyon',
|
||||
share_token: shareToken,
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
}
|
||||
|
||||
const attachedServices = [
|
||||
{ id: 'svc-kdo', service_type: 'kdo', resource_id: kdoId, is_visible: true, created_at: '2026-08-10T12:00:00Z' },
|
||||
{ id: 'svc-kontrib', service_type: 'kontrib', resource_id: kontribId, is_visible: true, created_at: '2026-08-10T12:00:00Z' },
|
||||
]
|
||||
|
||||
const eventDetail = (services: typeof attachedServices) => ({
|
||||
...summary,
|
||||
user_id: userId,
|
||||
description: 'Un seul lien pour tout organiser.',
|
||||
services,
|
||||
notifications: [],
|
||||
})
|
||||
|
||||
const publicEvent = {
|
||||
id: eventId,
|
||||
title: summary.title,
|
||||
event_type: summary.event_type,
|
||||
event_date: summary.event_date,
|
||||
location: summary.location,
|
||||
description: 'Un seul lien pour tout organiser.',
|
||||
services: [
|
||||
{ id: 'svc-kdo', service_type: 'kdo', resource_id: kdoId, public_token: kdoToken },
|
||||
{ id: 'svc-kontrib', service_type: 'kontrib', resource_id: kontribId, public_token: kontribToken },
|
||||
],
|
||||
}
|
||||
|
||||
const kdoItem = {
|
||||
id: '00000000-0000-4000-8000-000000000308',
|
||||
name: 'Un livre', description: null, price: null, currency: 'EUR', url: null, image_url: null,
|
||||
position: 0, is_priority: false, is_reserved: true, reserved_by: 'Camille',
|
||||
participation_mode: false, contribution_total: 0, contribution_count: 0,
|
||||
}
|
||||
|
||||
const kdoList = {
|
||||
id: kdoId, user_id: userId, title: 'Idées cadeaux', description: null,
|
||||
share_token: kdoToken, created_at: '2026-08-10T12:00:00Z',
|
||||
items: [kdoItem], reservation_count: 1, event_id: eventId,
|
||||
}
|
||||
|
||||
const kontribItem = {
|
||||
id: '00000000-0000-4000-8000-000000000309',
|
||||
name: 'Gâteau', description: null, url: null, quantity: 1, unit: null, position: 0,
|
||||
quantity_declared_total: 1, declaration_count: 1, is_covered: true, declarations: [],
|
||||
}
|
||||
|
||||
const kontribList = {
|
||||
id: kontribId, user_id: userId, title: 'À apporter', description: null,
|
||||
share_token: kontribToken, created_at: '2026-08-10T12:00:00Z',
|
||||
auto_validate_suggestions: false, items: [kontribItem], suggestions: [], event_id: eventId,
|
||||
}
|
||||
|
||||
/**
|
||||
* Un Hub complet et déterministe : deux services attachés, une participation,
|
||||
* aucune requête réelle. `services` permet de rejouer la même page vide, qui est
|
||||
* l'état de démarrage.
|
||||
*/
|
||||
async function mockHub(page: Page, { services = attachedServices } = {}) {
|
||||
// `.page` entre en fondu (`animate-fadein`). Sans cette émulation, une mesure
|
||||
// prise pendant les 200 ms d'animation lit des couleurs mélangées au fond et
|
||||
// fait échouer le contraste sur des rôles qui tiennent AA à l'arrivée.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('token', 'hub-pilot-token')
|
||||
localStorage.setItem('cookie_notice_dismissed', '1')
|
||||
})
|
||||
await page.route('**/api/**', route => {
|
||||
const { pathname } = new URL(route.request().url())
|
||||
// Le glob attrape aussi les modules servis par Vite (`/src/shared/api/…`) :
|
||||
// seuls les appels réellement adressés à l'API sont simulés.
|
||||
if (!pathname.startsWith('/api/')) return route.continue()
|
||||
const json = (body: unknown) => route.fulfill({ json: body })
|
||||
|
||||
// Les chemins sont comparés exactement : `/api/shared/ownership/hub/<id>`
|
||||
// se termine lui aussi par `/hub/<id>` et prendrait le détail de l'événement.
|
||||
if (pathname === '/api/auth/me') return json(user)
|
||||
if (pathname === '/api/hub/my-contributions') return json([{
|
||||
type: 'reservation', cancel_token: 'cancel-1', participant_name: 'Léa',
|
||||
created_at: '2026-08-11T12:00:00Z', service_title: 'Idées cadeaux',
|
||||
item_name: 'Un livre', amount: null, currency: null, public_token: kdoToken,
|
||||
}])
|
||||
if (pathname === '/api/hub') return json([summary])
|
||||
if (pathname === `/api/hub/shared/${shareToken}`) return json(publicEvent)
|
||||
if (pathname === `/api/hub/${eventId}`) return json(eventDetail(services))
|
||||
if (pathname.includes('/kdo/lists/')) return json(kdoList)
|
||||
if (pathname.includes('/kontrib/lists/')) return json(kontribList)
|
||||
return json([])
|
||||
})
|
||||
}
|
||||
|
||||
const SURFACES = [
|
||||
{ name: 'dashboard', path: '/hub', heading: 'Mes projets' },
|
||||
{ name: 'edition', path: `/hub/${eventId}`, heading: summary.title },
|
||||
{ name: 'public', path: `/hub/p/${shareToken}`, heading: summary.title },
|
||||
]
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
test(`la surface ${surface.name} du Hub tient les cinq largeurs dans les deux langues`, async ({ page }, testInfo) => {
|
||||
await mockHub(page)
|
||||
|
||||
for (const lang of ['fr', 'en']) {
|
||||
await page.goto('/')
|
||||
await page.evaluate(language => localStorage.setItem('kankwa_lang', language), lang)
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.goto(surface.path)
|
||||
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
|
||||
|
||||
// Un seul titre de page par surface : les participations et les groupes
|
||||
// de modules sont des sections, pas des pages dans la page.
|
||||
expect(await page.getByRole('heading', { level: 1 }).count(), `${surface.name} ${lang} ${viewport.width}px`).toBe(1)
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
await testInfo.attach(`hub-${surface.name}-${lang}-${viewport.width}px`, {
|
||||
body: await page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('l’édition groupe les modules par service et garde partage, permissions et activité', async ({ page }) => {
|
||||
await mockHub(page)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.goto(`/hub/${eventId}`)
|
||||
|
||||
// L'assemblage se lit par service : un groupe nommé, son icône canonique et
|
||||
// le nombre de modules qu'il porte.
|
||||
const kdoGroup = page.getByRole('region', { name: /Kdo/ })
|
||||
await expect(kdoGroup.getByRole('heading', { level: 2, name: 'Kdo' })).toBeVisible()
|
||||
await expect(kdoGroup.getByText('Idées cadeaux')).toBeVisible()
|
||||
await expect(page.getByRole('region', { name: /Kontrib/ }).getByText('À apporter')).toBeVisible()
|
||||
|
||||
// Rail de collaboration inchangé : lien public, co-gestionnaires, commentaires.
|
||||
// Le lien public est présenté dans un champ en lecture seule, pas en texte.
|
||||
await expect(page.locator('input[readonly]').first()).toHaveValue(new RegExp(`/hub/p/${shareToken}$`))
|
||||
await expect(page.getByText('Co-gestionnaires')).toBeVisible()
|
||||
await expect(page.getByText('Commentaires')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Ajouter un outil', exact: false })).toBeVisible()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
|
||||
test('un Hub vide propose les cinq outils comme choix de même poids', async ({ page }) => {
|
||||
await mockHub(page, { services: [] })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`/hub/${eventId}?onboarding=1`)
|
||||
|
||||
const onboarding = page.getByRole('region', { name: 'Par quoi souhaitez-vous commencer ?' })
|
||||
await expect(onboarding).toBeVisible()
|
||||
|
||||
const tiles = onboarding.getByRole('button')
|
||||
await expect(tiles).toHaveCount(5)
|
||||
// Règle du remplissage : aucune tuile ne prend le fond plein d'une action
|
||||
// principale, elles restent des choix comparables.
|
||||
const backgrounds = await tiles.evaluateAll(nodes => nodes.map(node => getComputedStyle(node).backgroundColor))
|
||||
expect(new Set(backgrounds).size).toBe(1)
|
||||
|
||||
// Le marqueur d'onboarding est consommé puis retiré de l'URL (activation produit).
|
||||
await expect(page).toHaveURL(new RegExp(`/hub/${eventId}$`))
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
|
||||
test('les surfaces du Hub ne présentent aucune violation axe sérieuse ou critique', async ({ page }) => {
|
||||
await mockHub(page)
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
for (const width of [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(surface.path)
|
||||
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('main').analyze()
|
||||
const blocking = results.violations.filter(v => v.impact === 'critical' || v.impact === 'serious')
|
||||
expect(
|
||||
JSON.stringify(blocking.map(v => ({ id: v.id, nodes: v.nodes.map(n => ({ html: n.html, why: n.failureSummary })) })), null, 2),
|
||||
`${surface.name} ${width}px`,
|
||||
).toBe('[]')
|
||||
}
|
||||
}
|
||||
})
|
||||
211
frontend/e2e/visual-shells.spec.ts
Normal file
211
frontend/e2e/visual-shells.spec.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
import { expectNoHorizontalOverflow } from './helpers/overflow'
|
||||
|
||||
/**
|
||||
* Contrat automatisé VIS-03 — shells et pages transverses.
|
||||
*
|
||||
* L'audit VIS-AUD-06 relevait trois axes de lecture sur une même page : une
|
||||
* Navbar pleine largeur, un contenu en `max-w-6xl`/`max-w-7xl` et un pied de
|
||||
* page en `max-w-4xl`. Ce test verrouille l'inverse : les trois éléments de
|
||||
* chrome (Navbar, bandeau de confidentialité, Footer) partagent le conteneur de
|
||||
* leur contexte, à toutes les largeurs de la recette et dans les deux langues.
|
||||
*
|
||||
* Il ferme aussi la dérogation inscrite dans `docs/TESTING.md` : la portée axe
|
||||
* de VIS-01 excluait la Navbar et le pied de page, « écarts connus qui
|
||||
* appartiennent à VIS-03 ». Ils sont désormais analysés.
|
||||
*/
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ width: 320, height: 720 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1280, height: 900 },
|
||||
{ width: 1440, height: 1000 },
|
||||
]
|
||||
|
||||
const shareToken = '00000000-0000-4000-8000-000000000201'
|
||||
|
||||
const guestList = {
|
||||
id: '00000000-0000-4000-8000-000000000202',
|
||||
title: 'Liste partagée de la recette VIS-03',
|
||||
description: 'Participation invitée, shell workspace.',
|
||||
items: [{
|
||||
id: '00000000-0000-4000-8000-000000000203',
|
||||
name: 'Un cadeau', description: null, price: null, url: null, image_url: null,
|
||||
position: 0, is_priority: false, is_reserved: false, reserved_by: null,
|
||||
participation_mode: false, contribution_total: 0, contribution_count: 0,
|
||||
}],
|
||||
}
|
||||
|
||||
/** Deux contextes de shell : une page publique et une participation invitée. */
|
||||
const CONTEXTS = [
|
||||
{ name: 'public', path: '/', heading: /.+/ },
|
||||
{ name: 'workspace', path: `/share/${shareToken}`, heading: guestList.title },
|
||||
]
|
||||
|
||||
async function mockGuestApis(page: Page) {
|
||||
await page.route(`**/api/kdo/lists/shared/${shareToken}`, route => route.fulfill({ json: guestList }))
|
||||
await page.route('**/api/shared/comments/**', route => route.fulfill({ json: [] }))
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.removeItem('cookie_notice_dismissed'))
|
||||
})
|
||||
|
||||
for (const context of CONTEXTS) {
|
||||
test(`le chrome ${context.name} partage un seul axe de lecture sur les cinq largeurs`, async ({ page }, testInfo) => {
|
||||
await mockGuestApis(page)
|
||||
|
||||
for (const lang of ['fr', 'en']) {
|
||||
await page.goto('/')
|
||||
await page.evaluate(language => localStorage.setItem('kankwa_lang', language), lang)
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.goto(context.path)
|
||||
await expect(page.getByRole('heading', { level: 1, name: context.heading }).first()).toBeVisible()
|
||||
|
||||
const axes = await page.evaluate(() => {
|
||||
const read = (selector: string) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)
|
||||
if (!element) return null
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { left: Math.round(rect.left), right: Math.round(rect.right) }
|
||||
}
|
||||
return {
|
||||
navbar: read('nav .shell-container'),
|
||||
footer: read('footer .shell-container'),
|
||||
cookie: read('.fixed.bottom-0 .shell-container'),
|
||||
}
|
||||
})
|
||||
|
||||
expect(axes.navbar, 'la Navbar porte le conteneur du contexte').not.toBeNull()
|
||||
expect(axes.footer, 'le Footer porte le conteneur du contexte').not.toBeNull()
|
||||
expect(axes.cookie, 'le bandeau porte le conteneur du contexte').not.toBeNull()
|
||||
expect(axes.footer, `${lang} ${viewport.width}px`).toEqual(axes.navbar)
|
||||
expect(axes.cookie, `${lang} ${viewport.width}px`).toEqual(axes.navbar)
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
await testInfo.attach(`shell-${context.name}-${lang}-${viewport.width}px`, {
|
||||
body: await page.screenshot({ fullPage: false }),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('la rangée de services défile dans son conteneur sans entraîner la page', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 720 })
|
||||
await page.goto('/')
|
||||
|
||||
const row = page.locator('nav .overflow-x-auto')
|
||||
const metrics = await row.evaluate(element => ({
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
}))
|
||||
// À 320 px les six services ne tiennent pas : ils défilent, ils ne se
|
||||
// réduisent pas à des icônes ambiguës (VIS-AUD-06).
|
||||
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth)
|
||||
|
||||
const last = page.locator('nav [data-service-nav]').last()
|
||||
await expect(last).toHaveText(/Kount/)
|
||||
await last.scrollIntoViewIfNeeded()
|
||||
await expect(last).toBeInViewport()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
|
||||
test('le parcours clavier traverse le chrome dans l’ordre visuel avec un focus visible', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
|
||||
|
||||
const focused = async () => page.evaluate(() => {
|
||||
const element = document.activeElement as HTMLElement | null
|
||||
if (!element) return null
|
||||
return {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
label: (element.getAttribute('aria-label') ?? element.textContent ?? '').trim().slice(0, 24),
|
||||
inNav: !!element.closest('nav'),
|
||||
}
|
||||
})
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
expect((await focused())?.label).toBe('kankwa')
|
||||
|
||||
// Le chrome vient avant le contenu et dans l'ordre visuel : première ligne
|
||||
// (marque, langue, session) puis rangée de services.
|
||||
const labels: string[] = []
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.keyboard.press('Tab')
|
||||
const current = await focused()
|
||||
if (!current?.inNav) break
|
||||
labels.push(current.label)
|
||||
}
|
||||
// Trois contrôles de session, puis les six services dans l'ordre de la rangée.
|
||||
expect(labels).toHaveLength(9)
|
||||
expect(labels.slice(3)).toEqual(['Hub', 'Kwiz', 'Kal', 'Kdo', 'Kontrib', 'Kount'])
|
||||
|
||||
// L'anneau de focus doit être réellement peint : le style par défaut du
|
||||
// navigateur ne se voit ni sur la crème ni sur l'ardoise.
|
||||
await page.locator('nav [data-service-nav]').first().focus()
|
||||
const ring = await page.locator('nav [data-service-nav]').first().evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { outline: style.outlineStyle, boxShadow: style.boxShadow }
|
||||
})
|
||||
expect(ring.boxShadow === 'none' && ring.outline === 'none').toBe(false)
|
||||
})
|
||||
|
||||
test('le chrome complet ne présente aucune violation axe sérieuse ou critique', async ({ page }) => {
|
||||
await mockGuestApis(page)
|
||||
|
||||
for (const context of CONTEXTS) {
|
||||
for (const width of [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(context.path)
|
||||
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
|
||||
|
||||
// Portée : le chrome lui-même — Navbar, bandeau, pied de page. C'est la
|
||||
// dérogation de VIS-01 que ce lot avait mandat de fermer. Le récit des
|
||||
// pages publiques garde ses écarts G4/G6 jusqu'à VIS-08, qui a seul le
|
||||
// mandat d'y toucher ; les inclure ici reviendrait à élargir le lot.
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include('nav')
|
||||
.include('footer')
|
||||
.include('.fixed.bottom-0')
|
||||
.analyze()
|
||||
const blocking = results.violations.filter(v => v.impact === 'critical' || v.impact === 'serious')
|
||||
expect(
|
||||
JSON.stringify(blocking.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.html) })), null, 2),
|
||||
`${context.name} ${width}px`,
|
||||
).toBe('[]')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('l’authentification rappelle la marque en plein écran mobile et se ferme au clavier', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
|
||||
await page.locator('nav').getByRole('link', { name: /inscrire/i }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
// Plein écran mobile : la modale remplace la page, elle doit donc redire où
|
||||
// l'on se trouve (VIS-AUD-08).
|
||||
await expect(dialog.locator('[data-brand-recall]')).toBeVisible()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// Le même rappel disparaît dès que la modale redevient une fenêtre posée sur
|
||||
// la page : le contexte est alors visible autour d'elle.
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.locator('nav').getByRole('link', { name: /inscrire/i }).click()
|
||||
const desktopDialog = page.getByRole('dialog')
|
||||
await expect(desktopDialog).toBeVisible()
|
||||
await expect(desktopDialog.locator('[data-brand-recall]')).toBeHidden()
|
||||
})
|
||||
93
frontend/public/fonts/bricolage-grotesque-OFL.txt
Normal file
93
frontend/public/fonts/bricolage-grotesque-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/public/fonts/bricolage-grotesque-latin-600-800.woff2
Normal file
BIN
frontend/public/fonts/bricolage-grotesque-latin-600-800.woff2
Normal file
Binary file not shown.
|
|
@ -12,5 +12,6 @@ Disallow: /join
|
|||
Disallow: /compte
|
||||
Disallow: /admin/
|
||||
Disallow: /app.html
|
||||
Disallow: /style-tile
|
||||
|
||||
Sitemap: https://kankwa.fr/sitemap.xml
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const Terms = lazy(() => import('@shared/pages/Terms'))
|
|||
const NotFound = lazy(() => import('@shared/pages/NotFound'))
|
||||
const Account = lazy(() => import('@shared/pages/Account'))
|
||||
const Demo = lazy(() => import('@shared/pages/Demo'))
|
||||
// Surface interne de revue du contrat de design (VIS-01) : hors produit, hors
|
||||
// sitemap et hors pré-rendu, chargée à la demande pour ne rien peser au départ.
|
||||
const StyleTile = lazy(() => import('@shared/pages/StyleTile'))
|
||||
// Le catalogue `landing` est volumineux et propre à ces routes : il est chargé
|
||||
// avec le composant, jamais dans le chunk initial.
|
||||
const SeoLanding = lazy(async () => {
|
||||
|
|
@ -58,7 +61,7 @@ const LEGACY_REDIRECTS: Record<string, string> = {
|
|||
|
||||
const PUBLIC_PREFIXES = ['/', '/magic', '/login', '/register',
|
||||
'/share', '/cancel', '/event', '/join', '/set-password', '/email-preferences', '/legal', '/privacy', '/terms',
|
||||
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/demo',
|
||||
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/demo', '/style-tile',
|
||||
// Landing pages d'acquisition (G6) — clusters FR et EN
|
||||
'/organiser', '/outils', '/comparatif', '/plan', '/tools', '/compare']
|
||||
|
||||
|
|
@ -105,6 +108,7 @@ function AppRoutes() {
|
|||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/demo" element={<Demo />} />
|
||||
<Route path="/style-tile" element={<StyleTile />} />
|
||||
{Object.entries(LEGACY_REDIRECTS).map(([from, to]) => (
|
||||
<Route key={from} path={from} element={<Navigate to={to} replace />} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
"email_label": "Email address",
|
||||
"email_edit": "Edit",
|
||||
"email_cancel": "Cancel",
|
||||
"email_unverified": "⚠ Unverified",
|
||||
"email_unverified": "Unverified",
|
||||
"email_new_placeholder": "New email",
|
||||
"email_change_hint": "A confirmation link will be sent to the new address.",
|
||||
"email_change_sent": "Confirmation email sent!",
|
||||
|
|
@ -107,8 +107,8 @@
|
|||
"services_unlimited": "unlimited",
|
||||
"manage_subscription": "Manage subscription",
|
||||
"managing": "Redirecting…",
|
||||
"premium_soon_title": "Kankwa+",
|
||||
"premium_soon_desc": "Subscription coming soon — unlimited services, always ad-free.",
|
||||
"premium_title": "Kankwa+",
|
||||
"premium_desc": "Unlimited active services, always ad-free.",
|
||||
"delete_account": "Delete my account",
|
||||
"delete_modal_title": "Delete my account",
|
||||
"delete_modal_desc": "Permanently deletes your account and all your data. This action is <strong>irreversible</strong>.",
|
||||
|
|
|
|||
|
|
@ -124,12 +124,12 @@
|
|||
},
|
||||
"event": {
|
||||
"empty": "Type (optional)",
|
||||
"anniversaire": "🎂 Birthday",
|
||||
"mariage": "💍 Wedding",
|
||||
"soiree": "🎉 Party",
|
||||
"repas": "🍽️ Dinner",
|
||||
"voyage": "✈️ Trip",
|
||||
"autre": "📅 Other"
|
||||
"anniversaire": "Birthday",
|
||||
"mariage": "Wedding",
|
||||
"soiree": "Party",
|
||||
"repas": "Dinner",
|
||||
"voyage": "Trip",
|
||||
"autre": "Other"
|
||||
},
|
||||
"rsvp": {
|
||||
"yes": "✅ Available",
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"home": "Back to home"
|
||||
},
|
||||
"cookie": {
|
||||
"text": "This site uses essential local storage and configurable first-party audience measurement.",
|
||||
"text": "Essential local storage and configurable audience measurement.",
|
||||
"accept": "Got it"
|
||||
},
|
||||
"cancel_link": {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@
|
|||
"no_services": "No services yet.",
|
||||
"visible": "Visible",
|
||||
"hidden": "Hidden",
|
||||
"share_label": "Project link"
|
||||
"share_label": "Project link",
|
||||
"tools_title": "Project tools"
|
||||
},
|
||||
"hub_new": {
|
||||
"title": "New project",
|
||||
|
|
@ -131,7 +132,8 @@
|
|||
"kontrib": "Who brings what — {{event}}",
|
||||
"kdo": "Gift ideas — {{event}}",
|
||||
"kount": "Expenses — {{event}}"
|
||||
}
|
||||
},
|
||||
"recommended": "Suggested"
|
||||
},
|
||||
"hub_card": {
|
||||
"services_count": "{{count}} service",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"dates_title": "Dates",
|
||||
"dates_hint": "Click or drag · swipe freely",
|
||||
"time_range_empty": "No time range selected — the poll will only cover dates",
|
||||
"remove_time_range": "Remove the {{range}} time range",
|
||||
"location_placeholder": "Place (press Enter to confirm)",
|
||||
"location_add_placeholder": "Add…"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
"members_label": "Members involved in this expense:",
|
||||
"select_member_hint": "Select at least one member.",
|
||||
"total_label": "Total",
|
||||
"missing_pct": "— {{pct}}% missing",
|
||||
"missing_pct": "— {{percentage}} missing",
|
||||
"save_error": "Failed to save.",
|
||||
"delete_error": "Failed to delete.",
|
||||
"saving": "Saving…",
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
"title": "Default split",
|
||||
"hint": "Applies to all expenses without a specific split.",
|
||||
"total_label": "Total",
|
||||
"missing_pct": "— {{pct}}% missing",
|
||||
"missing_pct": "— {{percentage}} missing",
|
||||
"save_error": "Failed to save.",
|
||||
"delete_error": "Failed to delete.",
|
||||
"saving": "Saving…",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@
|
|||
"closed": "This poll is closed.",
|
||||
"results_hidden": "Results will be shown when the poll closes.",
|
||||
"vote_form_title": "Vote",
|
||||
"single_choice_hint": "Choose one option",
|
||||
"best_option": "Leading option",
|
||||
"your_name": "Your first name (optional)",
|
||||
"submitting": "Sending…",
|
||||
"submit": "Submit my vote",
|
||||
|
|
@ -129,7 +131,8 @@
|
|||
"options_input": {
|
||||
"required_placeholder": "Option {{index}} (required)",
|
||||
"add_placeholder": "Add an option",
|
||||
"count": "{{count}}/{{max}} options · min {{min}}"
|
||||
"count": "{{count}}/{{max}} options · min {{min}}",
|
||||
"remove": "Remove the {{option}} option"
|
||||
},
|
||||
"poll_card": {
|
||||
"votes_count": "{{count}} vote",
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
"email_label": "Adresse email",
|
||||
"email_edit": "Modifier",
|
||||
"email_cancel": "Annuler",
|
||||
"email_unverified": "⚠ Non vérifié",
|
||||
"email_unverified": "Non vérifié",
|
||||
"email_new_placeholder": "Nouvel email",
|
||||
"email_change_hint": "Un lien de confirmation sera envoyé à la nouvelle adresse.",
|
||||
"email_change_sent": "Email de confirmation envoyé !",
|
||||
|
|
@ -107,8 +107,8 @@
|
|||
"services_unlimited": "illimité",
|
||||
"manage_subscription": "Gérer mon abonnement",
|
||||
"managing": "Redirection…",
|
||||
"premium_soon_title": "Kankwa+",
|
||||
"premium_soon_desc": "L'abonnement arrive bientôt — services illimités, toujours sans pub.",
|
||||
"premium_title": "Kankwa+",
|
||||
"premium_desc": "Services actifs illimités, toujours sans publicité.",
|
||||
"delete_account": "Supprimer mon compte",
|
||||
"delete_modal_title": "Supprimer mon compte",
|
||||
"delete_modal_desc": "Supprime <strong>définitivement</strong> votre compte et toutes vos données. Action <strong>irréversible</strong>.",
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@
|
|||
"delete_confirm": "Supprimer ce commentaire ?",
|
||||
"delete_error": "Impossible de supprimer ce commentaire.",
|
||||
"add_title": "Ajouter un commentaire",
|
||||
"placeholder_name": "Ton prénom",
|
||||
"placeholder_text": "Écris ton commentaire…",
|
||||
"placeholder_name": "Votre prénom",
|
||||
"placeholder_text": "Écrivez votre commentaire…",
|
||||
"sending": "Envoi…",
|
||||
"submit": "Envoyer",
|
||||
"submit_error": "Une erreur est survenue. Réessaie."
|
||||
"submit_error": "Une erreur est survenue. Réessayez."
|
||||
},
|
||||
"share": {
|
||||
"copy": "Copier",
|
||||
|
|
@ -124,12 +124,12 @@
|
|||
},
|
||||
"event": {
|
||||
"empty": "Type (optionnel)",
|
||||
"anniversaire": "🎂 Anniversaire",
|
||||
"mariage": "💍 Mariage",
|
||||
"soiree": "🎉 Soirée",
|
||||
"repas": "🍽️ Repas",
|
||||
"voyage": "✈️ Voyage",
|
||||
"autre": "📅 Autre"
|
||||
"anniversaire": "Anniversaire",
|
||||
"mariage": "Mariage",
|
||||
"soiree": "Soirée",
|
||||
"repas": "Repas",
|
||||
"voyage": "Voyage",
|
||||
"autre": "Autre"
|
||||
},
|
||||
"rsvp": {
|
||||
"yes": "✅ Présent(e)",
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"home": "Retour à l'accueil"
|
||||
},
|
||||
"cookie": {
|
||||
"text": "Ce site utilise un stockage local essentiel et une mesure d’audience first-party configurable.",
|
||||
"text": "Stockage essentiel et mesure d’audience configurable.",
|
||||
"accept": "Compris"
|
||||
},
|
||||
"cancel_link": {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@
|
|||
"no_services": "Aucun service pour l'instant.",
|
||||
"visible": "Visible",
|
||||
"hidden": "Masqué",
|
||||
"share_label": "Lien de le projet"
|
||||
"share_label": "Lien du projet",
|
||||
"tools_title": "Outils du projet"
|
||||
},
|
||||
"hub_new": {
|
||||
"title": "Nouveau projet",
|
||||
|
|
@ -131,7 +132,8 @@
|
|||
"kontrib": "Qui apporte quoi — {{event}}",
|
||||
"kdo": "Idées cadeaux — {{event}}",
|
||||
"kount": "Dépenses — {{event}}"
|
||||
}
|
||||
},
|
||||
"recommended": "Conseillé"
|
||||
},
|
||||
"hub_card": {
|
||||
"services_count": "{{count}} service",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
"dates_title": "Dates",
|
||||
"dates_hint": "Cliquer ou glisser · swipe libre",
|
||||
"time_range_empty": "Aucune plage sélectionnée — le sondage portera uniquement sur les dates",
|
||||
"remove_time_range": "Supprimer la plage {{range}}",
|
||||
"location_placeholder": "Lieu (Entrée pour valider)",
|
||||
"location_add_placeholder": "Ajouter…"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
"members_label": "Membres concernés par cette dépense :",
|
||||
"select_member_hint": "Sélectionnez au moins un membre.",
|
||||
"total_label": "Total",
|
||||
"missing_pct": "— manque {{pct}} %",
|
||||
"missing_pct": "— manque {{percentage}}",
|
||||
"save_error": "Erreur lors de la sauvegarde.",
|
||||
"delete_error": "Erreur lors de la suppression.",
|
||||
"saving": "Sauvegarde…",
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
"title": "Répartition par défaut",
|
||||
"hint": "S'applique à toutes les dépenses sans ventilation spécifique.",
|
||||
"total_label": "Total",
|
||||
"missing_pct": "— manque {{pct}} %",
|
||||
"missing_pct": "— manque {{percentage}}",
|
||||
"save_error": "Erreur lors de la sauvegarde.",
|
||||
"delete_error": "Erreur lors de la suppression.",
|
||||
"saving": "Sauvegarde…",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@
|
|||
"closed": "Ce sondage est clôturé.",
|
||||
"results_hidden": "Les résultats seront affichés à la clôture du sondage.",
|
||||
"vote_form_title": "Voter",
|
||||
"single_choice_hint": "Choisissez une option",
|
||||
"best_option": "Option en tête",
|
||||
"your_name": "Ton prénom (optionnel)",
|
||||
"submitting": "Envoi…",
|
||||
"submit": "Envoyer mon vote",
|
||||
|
|
@ -129,7 +131,8 @@
|
|||
"options_input": {
|
||||
"required_placeholder": "Option {{index}} (obligatoire)",
|
||||
"add_placeholder": "Ajouter une option",
|
||||
"count": "{{count}}/{{max}} options · min {{min}}"
|
||||
"count": "{{count}}/{{max}} options · min {{min}}",
|
||||
"remove": "Supprimer l’option {{option}}"
|
||||
},
|
||||
"poll_card": {
|
||||
"votes_count": "{{count}} vote",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,57 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Police de titre (VIS-01) ───────────────────────────────────────────────
|
||||
Bricolage Grotesque, auto-hébergée, sous-ensemble latin, axe de graisse
|
||||
réduit à 600–800 : le produit n'utilise pas de titre plus léger. Aucun
|
||||
appel réseau tiers. La déclaration est gratuite tant qu'aucun élément rendu
|
||||
ne demande `font-display` — le fichier n'est téléchargé qu'à ce moment.
|
||||
Licence : `public/fonts/bricolage-grotesque-OFL.txt` (SIL OFL 1.1). */
|
||||
@font-face {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
src: url('/fonts/bricolage-grotesque-latin-600-800.woff2') format('woff2');
|
||||
font-weight: 600 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Réaligne les métriques du repli sur celles de Bricolage afin que la
|
||||
substitution `swap` ne déplace pas la mise en page. Valeurs mesurées, pas
|
||||
estimées : Bricolage rend le même échantillon à 100,85 % de la largeur
|
||||
d'Arial (Chromium), et sa table hhea donne ascent 93 % / descent 27 % /
|
||||
line-gap 0 % pour 1000 unités par em. Les overrides sont rapportés à l'em
|
||||
déjà mis à l'échelle (93 / 1,0085 et 27 / 1,0085).
|
||||
Méthode et relevés : `docs/DESIGN_SYSTEM.md`. */
|
||||
@font-face {
|
||||
font-family: 'Kankwa Display Fallback';
|
||||
src: local('Arial'), local('Helvetica Neue'), local('Liberation Sans');
|
||||
size-adjust: 100.85%;
|
||||
ascent-override: 92.22%;
|
||||
descent-override: 26.77%;
|
||||
line-gap-override: 0%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--z-navbar: 20;
|
||||
--z-sticky: 10;
|
||||
--z-modal: 50;
|
||||
--z-tooltip: 30;
|
||||
|
||||
/* ── Espacements de composition (VIS-01) ──────────────────────────────
|
||||
Résolus ici plutôt que dans chaque composant : `px-gutter` ou
|
||||
`gap-cards` suivent le viewport sans breakpoint local. Les valeurs
|
||||
mobiles reprennent la gouttière existante de `.page` (16 px), donc
|
||||
leur adoption ne déplace rien. */
|
||||
--space-gutter: 1rem;
|
||||
--space-section: 2rem;
|
||||
--space-cards: 0.75rem;
|
||||
--space-panel: 1rem;
|
||||
--space-compact: 0.5rem;
|
||||
|
||||
--color-primary-50: 245 224 210;
|
||||
--color-primary-100: 236 196 168;
|
||||
--color-primary-200: 224 152 112;
|
||||
|
|
@ -19,12 +64,76 @@
|
|||
--color-primary-800: 138 68 34;
|
||||
--color-primary-900: 110 48 24;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--space-gutter: 1.5rem;
|
||||
--space-section: 2.5rem;
|
||||
--space-cards: 1rem;
|
||||
--space-panel: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--space-gutter: 2rem;
|
||||
--space-section: 3.5rem;
|
||||
--space-cards: 1.25rem;
|
||||
--space-panel: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus clavier (VIS-03). Le parcours au clavier traverse d'abord le chrome :
|
||||
marque, langue, compte, rangée de services, pied de page. Ces éléments ne
|
||||
déclaraient aucun anneau et dépendaient donc du style par défaut du
|
||||
navigateur, invisible sur l'ardoise comme sur la crème. L'anneau est posé
|
||||
une fois ici, sur `:focus-visible` seulement, pour ne pas le montrer au
|
||||
clic. Un composant qui a besoin d'un anneau différent le redéclare. */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ink-accent ring-offset-2 ring-offset-surface-page;
|
||||
}
|
||||
|
||||
/* Mouvement réduit : contrainte transverse du contrat de design. L'état final
|
||||
reste identique, seule la transition disparaît. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Police de titre (déploiement VIS-03) ─────────────────────────────────
|
||||
La face est portée par le *rôle*, jamais par la page : appeler
|
||||
`text-page-title` suffit, et aucun écran ne peut se retrouver avec un titre
|
||||
dans une autre police. Les trois rôles de composition la reçoivent ;
|
||||
`card-title`, `body`, `body-sm` et `meta` restent sur la pile système pour
|
||||
que les listes denses gardent leur calme. `font-sans` reste l'échappatoire :
|
||||
déclaré en couche `utilities`, il l'emporte sur cette règle. */
|
||||
.text-display,
|
||||
.text-page-title,
|
||||
.text-section-title {
|
||||
@apply font-display;
|
||||
}
|
||||
|
||||
/* Lien textuel (VIS-03).
|
||||
Un lien dans une phrase ou une action tertiaire : accent du service et
|
||||
soulignement permanent — la couleur seule ne suffit pas à signaler un lien.
|
||||
`ink-accent` (primary-700) tient 5,0:1 sur les surfaces blanches ; sur une
|
||||
surface teintée comme `surface-muted` il descend à 4,3:1, d'où `btn-quiet`
|
||||
pour les actions du rail de collaboration. */
|
||||
.link {
|
||||
@apply text-ink-accent underline underline-offset-2 hover:text-ink-soft rounded-control transition-colors duration-micro ease-standard;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input {
|
||||
@apply w-full min-w-0 max-w-full border border-gray-300 rounded-component px-3 py-2 text-base sm:text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors;
|
||||
@apply w-full min-w-0 max-w-full border border-line-strong rounded-control px-3 py-2 text-base sm:text-sm text-ink placeholder-ink-faint bg-surface-raised focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* iOS Safari : bloquer le zoom au focus (font-size < 16px déclenche le zoom) */
|
||||
|
|
@ -44,33 +153,50 @@
|
|||
}
|
||||
|
||||
/* Buttons — style only, no size */
|
||||
/* Action principale (VIS-03). Le fond est pris au cran 700, pas 600 : sous la
|
||||
palette de marque — la seule qui s'applique hors d'un `ServiceThemeScope`,
|
||||
donc sur la Navbar et toutes les pages publiques — le blanc sur `600` ne
|
||||
donne que 3,5:1, sous le seuil AA du texte courant. Les six palettes de
|
||||
service sont plus sombres et passaient déjà ; le cran 700 les couvre toutes.
|
||||
`designTokens.test.ts` recalcule ce contraste pour les sept palettes. */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center bg-primary-600 text-white rounded-component text-sm font-medium hover:bg-primary-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all;
|
||||
@apply inline-flex items-center justify-center bg-primary-700 text-white rounded-control text-sm font-medium hover:bg-primary-800 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center border border-gray-300 text-gray-600 rounded-component text-sm font-medium hover:bg-gray-50 active:scale-95 disabled:opacity-50 transition-all;
|
||||
@apply inline-flex items-center justify-center border border-line-strong text-ink-muted rounded-control text-sm font-medium hover:bg-surface-muted active:scale-95 disabled:opacity-50 transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center bg-danger-600 text-white rounded-component text-sm font-medium hover:bg-danger-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all;
|
||||
@apply inline-flex items-center justify-center bg-danger-600 text-white rounded-control text-sm font-medium hover:bg-danger-700 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* Contextual — no background, subtly prominent on hover */
|
||||
.btn-quiet {
|
||||
@apply inline-flex items-center justify-center gap-1.5 text-ink-muted rounded-control text-sm font-medium hover:bg-surface-muted hover:text-ink active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* Icon-only — square target, accessible name required on the element */
|
||||
.btn-icon {
|
||||
@apply inline-flex items-center justify-center size-9 shrink-0 text-ink-faint rounded-control hover:bg-surface-muted hover:text-ink-muted active:scale-95 disabled:opacity-50 transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* Default size — only when btn-sm is NOT present */
|
||||
.btn-primary:not(.btn-sm),
|
||||
.btn-secondary:not(.btn-sm),
|
||||
.btn-danger:not(.btn-sm) {
|
||||
.btn-danger:not(.btn-sm),
|
||||
.btn-quiet:not(.btn-sm) {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
/* Small modifier — h-8 forces identical height for <a>, <button>, icon buttons */
|
||||
.btn-sm {
|
||||
@apply h-8 inline-flex items-center justify-center px-3 text-sm rounded-component font-medium transition-all;
|
||||
@apply h-8 inline-flex items-center justify-center px-3 text-sm rounded-control font-medium transition-all duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* Bouton × visible uniquement au survol souris (pas touch) */
|
||||
.delete-btn-hover {
|
||||
@apply opacity-0 transition-opacity duration-150;
|
||||
@apply opacity-0 transition-opacity duration-micro;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.group:hover .delete-btn-hover {
|
||||
|
|
@ -87,25 +213,56 @@
|
|||
border-right: none;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* Cards — base variants */
|
||||
.card {
|
||||
@apply bg-white border border-gray-200 rounded-card p-5 shadow-sm;
|
||||
@apply bg-surface-raised border border-line rounded-card p-5 shadow-raised;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
@apply bg-white border border-gray-200 rounded-card p-4;
|
||||
@apply bg-surface-raised border border-line rounded-card p-4;
|
||||
}
|
||||
|
||||
/* Help, context or restriction — uses the brand notice surface (sable) */
|
||||
.card-notice {
|
||||
@apply bg-surface-notice border border-line rounded-panel p-4;
|
||||
}
|
||||
|
||||
/* Selectable or actionable item — lifts on hover */
|
||||
.card-interactive {
|
||||
@apply bg-surface-raised border border-line rounded-card p-4 cursor-pointer hover:shadow-raised transition-shadow duration-control ease-standard;
|
||||
}
|
||||
|
||||
/* Result, balance or progress — compact, panel-radius */
|
||||
.card-summary {
|
||||
@apply bg-surface-raised border border-line rounded-panel p-4;
|
||||
}
|
||||
|
||||
/* Settings or structured form area */
|
||||
.card-configuration {
|
||||
@apply bg-surface-sunken border border-line rounded-panel p-4;
|
||||
}
|
||||
|
||||
/* Collaboration rail (VIS-03) — share, co-ownership and comments.
|
||||
A calm grouping rather than a white card: the rail assists the page's own
|
||||
object, it never competes with it. */
|
||||
.card-collaboration {
|
||||
@apply bg-surface-muted border border-line rounded-panel p-panel;
|
||||
}
|
||||
|
||||
.card-collaboration-title {
|
||||
@apply flex items-center gap-2 text-body-sm font-semibold text-ink-soft;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply font-semibold text-gray-900 truncate;
|
||||
@apply text-card-title font-semibold text-ink truncate;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
@apply flex items-center gap-2 mt-0.5 flex-wrap text-sm text-gray-500;
|
||||
@apply flex items-center gap-2 mt-0.5 flex-wrap text-body-sm text-ink-subtle;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
@apply flex items-center gap-2 pt-1;
|
||||
@apply flex items-center gap-compact pt-1;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
|
|
@ -125,11 +282,46 @@
|
|||
@apply text-sm text-info-700 bg-info-50 border border-info-100 p-3 rounded-component break-all;
|
||||
}
|
||||
|
||||
/* Restriction actionnable — limite freemium atteinte, capacité restante.
|
||||
La famille répétait la même chaîne Tailwind dans chaque modal de création
|
||||
(VIS-04) ; l'alerte la porte une fois, comme ses trois sœurs. */
|
||||
.alert-warning {
|
||||
@apply text-sm text-warning-700 bg-warning-50 border border-warning-100 p-3 rounded-component;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply text-xs px-2 py-0.5 rounded-full font-medium;
|
||||
}
|
||||
|
||||
/* Service identity — follows the active service palette */
|
||||
.badge-service {
|
||||
@apply badge bg-primary-50 text-primary-700;
|
||||
}
|
||||
|
||||
/* Semantic statuses */
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-700;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-700;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-600;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply badge bg-info-50 text-info-700;
|
||||
}
|
||||
|
||||
/* Neutral metadata */
|
||||
.badge-meta {
|
||||
@apply badge bg-surface-muted text-ink-subtle;
|
||||
}
|
||||
|
||||
/* Legacy aliases — kept for backward compat, prefer semantic names above */
|
||||
.badge-indigo {
|
||||
@apply badge bg-info-50 text-info-700;
|
||||
}
|
||||
|
|
@ -139,16 +331,46 @@
|
|||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-gray-100 text-gray-500;
|
||||
@apply badge bg-surface-muted text-ink-subtle;
|
||||
}
|
||||
|
||||
/* ── Shells (VIS-03) ────────────────────────────────────────────────────
|
||||
Navbar, contenu et Footer partagent le conteneur de leur contexte. Le fond
|
||||
des deux éléments de chrome reste pleine largeur ; seul leur contenu est
|
||||
ramené sur l'axe de lecture. `shellContainerClass()` (config/shellVariant)
|
||||
décide de la largeur, ce fichier ne porte que la gouttière commune. */
|
||||
.shell-container {
|
||||
@apply mx-auto w-full px-gutter;
|
||||
}
|
||||
|
||||
/* Page layout */
|
||||
.page {
|
||||
@apply w-full px-4 sm:px-6 pb-6 animate-fadein;
|
||||
@apply mx-auto w-full max-w-workspace px-gutter pb-section animate-fadein;
|
||||
}
|
||||
|
||||
/* Formulaire isolé (création, mot de passe) — conteneur `form` du design system */
|
||||
/* Lecture longue (VIS-03) — mentions légales, confidentialité, CGU.
|
||||
Ces pages répétaient la même composition section par section : un rang de
|
||||
titre, un corps de texte, un rythme vertical. Elles la prennent maintenant
|
||||
au même endroit, dans le conteneur de lecture `max-w-editorial`. */
|
||||
.page-editorial {
|
||||
@apply mx-auto w-full max-w-editorial px-gutter py-section;
|
||||
}
|
||||
|
||||
.editorial-section {
|
||||
@apply mb-8;
|
||||
}
|
||||
|
||||
.editorial-heading {
|
||||
@apply text-meta font-semibold uppercase tracking-widest text-ink-subtle mb-3;
|
||||
}
|
||||
|
||||
.editorial-body {
|
||||
@apply text-body-sm text-ink-muted leading-relaxed;
|
||||
}
|
||||
|
||||
.page-sm {
|
||||
@apply max-w-sm mx-auto px-4 py-16;
|
||||
@apply mx-auto w-full max-w-form px-gutter py-16;
|
||||
}
|
||||
|
||||
/* Layout : contenu principal + panneau latéral droit */
|
||||
|
|
@ -183,9 +405,17 @@
|
|||
top: var(--page-header-bottom, 188px);
|
||||
}
|
||||
|
||||
/* Skeleton shimmer */
|
||||
/* Style tile : bascule de comparaison entre la police de titre et la pile
|
||||
système, sans dupliquer la page de revue. Réservé à `/style-tile`.
|
||||
Depuis VIS-03 la police est portée par les rôles eux-mêmes : la bascule est
|
||||
donc devenue un retrait, pas un ajout. */
|
||||
.type-specimen-system :is(h1, h2, h3, .type-role) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer — uses exact surface-muted / line values (hex for gradient) */
|
||||
.skeleton {
|
||||
@apply bg-gray-100 rounded animate-shimmer;
|
||||
@apply bg-surface-muted rounded-panel animate-shimmer;
|
||||
background: linear-gradient(90deg, #F0EDE7 25%, #E5E3DD 50%, #F0EDE7 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe('CreateHubModal templates', () => {
|
|||
|
||||
fireEvent.change(screen.getByLabelText('Type (optionnel)'), { target: { value: 'soiree' } })
|
||||
fireEvent.change(screen.getByLabelText('Cas d’usage (optionnel)'), { target: { value: 'soiree-jeux' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Nom du projet *'), { target: { value: 'Soirée jeux' } })
|
||||
fireEvent.change(screen.getByLabelText('Nom du projet *'), { target: { value: 'Soirée jeux' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Créer le projet' }))
|
||||
|
||||
await waitFor(() => expect(createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createEvent } from '@services/hub/api/hub'
|
||||
import { Alert, DateInput, Modal } from '@shared/components'
|
||||
import { Alert, DateInput, Field, Modal } from '@shared/components'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import type { ApiError } from '@shared/types'
|
||||
import { getHubUseCase } from '@shared/config/landings'
|
||||
|
|
@ -54,23 +54,31 @@ export default function CreateHubModal({ onClose }: { onClose: () => void }) {
|
|||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
{limitReached && (
|
||||
<p className="text-sm text-warning-700 bg-warning-50 border border-warning-200 rounded-lg px-3 py-2.5">
|
||||
<p className="alert-warning">
|
||||
{limitReached}{' '}
|
||||
<Link to="/compte" onClick={onClose} className="underline font-medium hover:text-warning-800">
|
||||
<Link to="/compte" onClick={onClose} className="link">
|
||||
{tc('nav.account')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<input className="input" placeholder={t('create_modal.name_placeholder')} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<Field label={t('create_modal.name_placeholder')} id="hub-create-title">
|
||||
<input id="hub-create-title" className="input" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<HubTemplateFields
|
||||
eventType={eventType}
|
||||
templateKey={templateKey}
|
||||
onEventTypeChange={setEventType}
|
||||
onTemplateChange={setTemplateKey}
|
||||
/>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
<input className="input" placeholder={t('create_modal.location_placeholder')} value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} placeholder={t('create_modal.desc_placeholder')} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<Field label={t('create_modal.date_placeholder')}>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
</Field>
|
||||
<Field label={t('create_modal.location_placeholder')} id="hub-create-location">
|
||||
<input id="hub-create-location" className="input" value={location} onChange={e => setLocation(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('create_modal.desc_placeholder')} id="hub-create-description">
|
||||
<textarea id="hub-create-description" className="input" rows={3} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={loading || !title.trim()} className="btn-primary flex-1">
|
||||
{loading ? t('create_modal.creating') : t('create_modal.submit')}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { deleteEvent, updateEvent } from '@services/hub/api/hub'
|
||||
import type { EditModalProps } from '@shared/components/ServiceDashboard'
|
||||
import { Alert, DateInput, Modal } from '@shared/components'
|
||||
import { Alert, DateInput, Field, Modal } from '@shared/components'
|
||||
import { useConfirm } from '@shared/hooks/useConfirm'
|
||||
import { EVENT_TYPE_VALUES } from '@shared/types'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
|
|
@ -59,21 +59,34 @@ export default function EditHubModal({ item, onClose, onSaved, onDeleted }: Edit
|
|||
{confirmElement}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
<input className="input" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
<option value="">{tc('types.event.empty')}</option>
|
||||
{/* Le champ titre n'avait ni libellé ni placeholder : son identité tenait
|
||||
à sa position. Les autres reprennent en libellé le texte qu'ils
|
||||
portaient en placeholder (VIS-04). */}
|
||||
<Field label={t('create_modal.name_placeholder')} id="hub-edit-title">
|
||||
<input id="hub-edit-title" className="input" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label={t('create_modal.type_placeholder')} id="hub-edit-type">
|
||||
<select id="hub-edit-type" className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
|
||||
<option value="">{tc('types.event.empty')}</option>
|
||||
{EVENT_TYPE_VALUES.map(value => <option key={value} value={value}>{tc('types.event.' + value)}</option>)}
|
||||
</select>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
<input className="input" placeholder={t('create_modal.location_placeholder')} value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} placeholder={t('edit_modal.desc_placeholder')} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('create_modal.date_placeholder')}>
|
||||
<DateInput value={eventDate} onChange={setEventDate} />
|
||||
</Field>
|
||||
<Field label={t('create_modal.location_placeholder')} id="hub-edit-location">
|
||||
<input id="hub-edit-location" className="input" value={location} onChange={e => setLocation(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('edit_modal.desc_placeholder')} id="hub-edit-description">
|
||||
<textarea id="hub-edit-description" className="input" rows={3} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={saving} className="btn-primary flex-1">
|
||||
{saving ? t('edit_modal.saving') : t('edit_modal.save')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn-secondary">{tc('buttons.cancel')}</button>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="border-t border-line pt-3">
|
||||
<button type="button" onClick={handleDelete} className="btn-danger btn-sm w-full">
|
||||
{t('edit_modal.delete')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import i18n, { loadNamespace } from '../../../i18n/config'
|
||||
import { EVENT_TYPE_VALUES } from '@shared/types'
|
||||
import { Field, ServiceIcon, ServiceThemeScope } from '@shared/components'
|
||||
import { getServiceMetadata } from '@shared/config/serviceMetadata'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
import { getHubUseCase, hubUseCases } from '@shared/config/landings'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -66,23 +68,24 @@ export default function HubTemplateFields({
|
|||
|
||||
return (
|
||||
<>
|
||||
<select
|
||||
aria-label={t('create_modal.type_placeholder')}
|
||||
className="input"
|
||||
value={eventType}
|
||||
onChange={event => handleTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="">{tc('types.event.empty')}</option>
|
||||
{EVENT_TYPE_VALUES.map(value => (
|
||||
<option key={value} value={value}>{tc(`types.event.${value}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm font-medium text-gray-700">
|
||||
<span>{t('create_modal.template_label')}</span>
|
||||
<Field label={t('create_modal.type_placeholder')} id="hub-event-type">
|
||||
<select
|
||||
id="hub-event-type"
|
||||
className="input"
|
||||
value={eventType}
|
||||
onChange={event => handleTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="">{tc('types.event.empty')}</option>
|
||||
{EVENT_TYPE_VALUES.map(value => (
|
||||
<option key={value} value={value}>{tc(`types.event.${value}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t('create_modal.template_label')} help={t('create_modal.template_help')} id="hub-template">
|
||||
<select
|
||||
id="hub-template"
|
||||
className="input"
|
||||
aria-label={t('create_modal.template_label')}
|
||||
value={templateKey}
|
||||
disabled={!catalogAvailable || !eventType || compatibleUseCases.length === 0}
|
||||
onChange={event => handleTemplateChange(event.target.value)}
|
||||
|
|
@ -92,18 +95,27 @@ export default function HubTemplateFields({
|
|||
<option key={useCase.key} value={useCase.key}>{tl(`pages.${useCase.key}.short_title`)}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs font-normal text-gray-500">{t('create_modal.template_help')}</span>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
{/* Résumé du cas d'usage : de l'aide contextuelle, donc `notice` — un
|
||||
aplat de service se lirait comme une alerte (règle du remplissage).
|
||||
Chaque outil conseillé est nommé par son icône canonique et sa palette. */}
|
||||
{selectedTemplate && (
|
||||
<section className="rounded-xl border border-primary-200 bg-primary-50 p-3" aria-live="polite" aria-label={t('create_modal.template_summary')}>
|
||||
<p className="text-sm font-semibold text-gray-900">{tl(`pages.${selectedTemplate.key}.short_title`)}</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-2" aria-label={t('create_modal.template_recommendations')}>
|
||||
<section className="card-notice" aria-live="polite" aria-label={t('create_modal.template_summary')}>
|
||||
<p className="text-body-sm font-semibold text-ink">{tl(`pages.${selectedTemplate.key}.short_title`)}</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-compact" aria-label={t('create_modal.template_recommendations')}>
|
||||
{selectedTemplate.modules.map(serviceId => (
|
||||
<li key={serviceId} className="badge-gray">{getServiceMetadata(serviceId).label}</li>
|
||||
<li key={serviceId}>
|
||||
<ServiceThemeScope serviceId={serviceId as ServiceId}>
|
||||
<span className="badge bg-surface-raised text-ink-soft inline-flex items-center gap-1.5">
|
||||
<ServiceIcon serviceId={serviceId} className="h-3.5 w-3.5 text-ink-accent" />
|
||||
{getServiceMetadata(serviceId).label}
|
||||
</span>
|
||||
</ServiceThemeScope>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-gray-600">{t('create_modal.template_no_content')}</p>
|
||||
<p className="mt-2 text-meta text-ink-soft">{t('create_modal.template_no_content')}</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
43
frontend/src/services/hub/components/ModuleGroup.tsx
Normal file
43
frontend/src/services/hub/components/ModuleGroup.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { ServiceIcon, ServiceThemeScope } from '@shared/components'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
|
||||
interface Props {
|
||||
serviceId: ServiceId
|
||||
label: string
|
||||
count: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe de modules d'un même service dans un Hub (VIS-04).
|
||||
*
|
||||
* Le Hub assemble : sa cible visuelle est que chaque groupe soit **identifié par
|
||||
* son service**, sans que le Hub cesse d'être l'espace courant. La palette du
|
||||
* service est donc portée par un `ServiceThemeScope` local — elle marque l'icône,
|
||||
* le titre et les cartes du groupe — tandis que les actions structurelles de la
|
||||
* page (créer, ajouter, dupliquer) restent hors de ces groupes, donc dans la
|
||||
* palette Hub. C'est la règle « Hub peut montrer les accents des services sur les
|
||||
* modules » de `docs/DESIGN_SYSTEM.md`.
|
||||
*
|
||||
* Le titre reste un rôle de section : le masthead porte le `h1` de la ressource,
|
||||
* un groupe est un `h2`. Le décompte accompagne le nom parce que la couleur seule
|
||||
* ne dit pas combien d'outils vivent dans un groupe.
|
||||
*/
|
||||
export default function ModuleGroup({ serviceId, label, count, children }: Props) {
|
||||
const headingId = `hub-module-group-${serviceId}`
|
||||
return (
|
||||
<ServiceThemeScope serviceId={serviceId}>
|
||||
<section aria-labelledby={headingId}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ServiceIcon serviceId={serviceId} className="w-5 h-5 shrink-0 text-ink-accent" />
|
||||
<h2 id={headingId} className="text-section-title text-ink">{label}</h2>
|
||||
<span className="badge-meta tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-cards">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</ServiceThemeScope>
|
||||
)
|
||||
}
|
||||
40
frontend/src/services/hub/components/ServiceToolTile.tsx
Normal file
40
frontend/src/services/hub/components/ServiceToolTile.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { ServiceIcon, ServiceThemeScope } from '@shared/components'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
|
||||
interface Props {
|
||||
serviceId: ServiceId
|
||||
label: string
|
||||
recommended?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Choix d'un outil à ajouter au Hub (VIS-04).
|
||||
*
|
||||
* Le démarrage et le sélecteur présentaient cinq boutons pleins concurrents :
|
||||
* cinq actions dominantes sur une même région, alors que le système n'en admet
|
||||
* qu'une. Le choix devient donc une carte `interactive` — c'est un choix, pas
|
||||
* l'action de la page — identifiée par l'icône canonique du service et sa palette.
|
||||
*
|
||||
* L'outil conseillé se distingue par un **libellé**, pas seulement par son
|
||||
* anneau : la sélection ne peut pas reposer sur la couleur seule.
|
||||
*/
|
||||
export default function ServiceToolTile({ serviceId, label, recommended, onClick }: Props) {
|
||||
const { t } = useTranslation('hub')
|
||||
return (
|
||||
<ServiceThemeScope serviceId={serviceId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`card-interactive flex min-h-[5.5rem] flex-col items-center justify-center gap-1.5 text-center ${
|
||||
recommended ? 'border-line-accent ring-1 ring-line-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<ServiceIcon serviceId={serviceId} className="h-6 w-6 text-ink-accent" />
|
||||
<span className="text-body-sm font-semibold text-ink">{label}</span>
|
||||
{recommended && <span className="badge-service">{t('onboarding.recommended')}</span>}
|
||||
</button>
|
||||
</ServiceThemeScope>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link } from 'react-router-dom'
|
||||
import { createHubModules, type EventDetail } from '@services/hub/api/hub'
|
||||
import { getUsage } from '@shared/api/auth'
|
||||
import { Alert } from '@shared/components'
|
||||
import { Alert, ServiceIcon, ServiceThemeScope } from '@shared/components'
|
||||
import { getServiceMetadata } from '@shared/config/serviceMetadata'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
import type { HubUseCase } from '@shared/config/landings'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
|
||||
|
|
@ -78,48 +79,52 @@ export default function TemplateStarterPanel({ event, template, onCreated }: Pro
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-primary-200 bg-white/70 p-3">
|
||||
<p className="text-xs font-semibold text-primary-800">{t('onboarding.template_modules')}</p>
|
||||
<p className="mt-1 text-xs text-gray-600">{t('onboarding.template_batch_intro')}</p>
|
||||
<div className="card-configuration mt-4">
|
||||
<p className="text-body-sm font-semibold text-ink">{t('onboarding.template_modules')}</p>
|
||||
<p className="mt-1 text-meta text-ink-muted">{t('onboarding.template_batch_intro')}</p>
|
||||
|
||||
<ul className="mt-3 flex flex-col gap-1.5">
|
||||
<ul className="mt-3 flex flex-col gap-1">
|
||||
{starterModules.map(id => {
|
||||
const checked = selected.includes(id)
|
||||
const unavailable = !checked && atCapacity
|
||||
return (
|
||||
<li key={id}>
|
||||
<label className={`flex items-center gap-2 text-sm ${!checked && atCapacity ? 'text-gray-400' : 'text-gray-800'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
checked={checked}
|
||||
disabled={loading || capacity === null || (!checked && atCapacity)}
|
||||
onChange={() => toggle(id)}
|
||||
/>
|
||||
{getServiceMetadata(id).label}
|
||||
</label>
|
||||
<ServiceThemeScope serviceId={id as ServiceId}>
|
||||
<label className={`flex min-h-[2.25rem] items-center gap-2 text-body-sm ${unavailable ? 'text-ink-faint' : 'text-ink'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded-control border-line-strong text-primary-700"
|
||||
checked={checked}
|
||||
disabled={loading || capacity === null || unavailable}
|
||||
onChange={() => toggle(id)}
|
||||
/>
|
||||
<ServiceIcon serviceId={id} className={`h-4 w-4 ${unavailable ? 'text-ink-faint' : 'text-ink-accent'}`} />
|
||||
{getServiceMetadata(id).label}
|
||||
</label>
|
||||
</ServiceThemeScope>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{capacity !== null && remaining < starterModules.length && (
|
||||
<p className="mt-3 text-xs text-warning-700 bg-warning-50 border border-warning-100 rounded-component px-3 py-2">
|
||||
<p className="alert-warning mt-3">
|
||||
{remaining === 0
|
||||
? tc('premium.limit_reached')
|
||||
: t('onboarding.template_batch_capacity', { count: remaining })}{' '}
|
||||
<Link to="/compte" className="underline font-medium">{tc('premium.upgrade')}</Link>
|
||||
<Link to="/compte" className="link">{tc('premium.upgrade')}</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{manualModules.length > 0 && (
|
||||
<p className="mt-3 text-xs text-gray-600">
|
||||
<p className="mt-3 text-meta text-ink-muted">
|
||||
{t('onboarding.template_batch_manual', {
|
||||
services: manualModules.map(id => getServiceMetadata(id).label).join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-gray-600">{t('onboarding.template_no_content')}</p>
|
||||
<p className="mt-3 text-meta text-ink-muted">{t('onboarding.template_no_content')}</p>
|
||||
<Alert type="error" message={error} />
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -104,12 +104,12 @@ function CancelActionModal({ item, onClose, onCancelled }: {
|
|||
|
||||
return (
|
||||
<Modal serviceId="hub" onClose={onClose} title={t('my_contributions.cancel_title', { label: cfg.label.toLowerCase() })}>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-card px-4 py-3 -mt-2 mb-4">
|
||||
<p className="font-medium text-gray-900 text-sm">{preview.label}</p>
|
||||
{preview.detail && <p className="text-xs text-gray-500 mt-0.5">{preview.detail}</p>}
|
||||
<div className="card-configuration -mt-2 mb-4">
|
||||
<p className="text-body-sm font-medium text-ink">{preview.label}</p>
|
||||
{preview.detail && <p className="text-meta text-ink-subtle mt-0.5">{preview.detail}</p>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">{cfg.confirmDesc}</p>
|
||||
{error && <p className="text-sm text-danger-600 mb-4">{error}</p>}
|
||||
<p className="text-body-sm text-ink-muted mb-6">{cfg.confirmDesc}</p>
|
||||
{error && <p className="alert-error mb-4">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
|
|
@ -142,19 +142,22 @@ function ContributionCard({ item, onCancelled }: {
|
|||
onCancelled={onCancelled}
|
||||
/>
|
||||
)}
|
||||
<div className={`card relative flex flex-col gap-2 ${serviceUrl ? 'hover:border-gray-300 transition-colors' : ''}`}>
|
||||
<div className={`relative flex flex-col gap-2 ${serviceUrl ? 'card-interactive' : 'card-item'}`}>
|
||||
{serviceUrl && <Link to={serviceUrl} state={{ from: '/hub' }} className="absolute inset-0 rounded-card" aria-label={t('my_contributions.view_label', { name: item.item_name ?? item.service_title })} />}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full" style={cfg.badgeStyle}>{cfg.label}</span>
|
||||
{itemAmount(item) && <span className="text-xs text-gray-500 font-medium">{itemAmount(item)}</span>}
|
||||
{/* La couleur d'une participation vient de la palette de son service
|
||||
(source unique `serviceThemes.ts`), jamais d'un token sémantique
|
||||
choisi à la main : un badge marque, il n'annonce pas un état. */}
|
||||
<span className="badge" style={cfg.badgeStyle}>{cfg.label}</span>
|
||||
{itemAmount(item) && <span className="text-meta font-medium text-ink-muted tabular-nums">{itemAmount(item)}</span>}
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{item.item_name ?? item.service_title}</p>
|
||||
{item.item_name && <p className="text-xs text-gray-400 truncate">{item.service_title}</p>}
|
||||
<p className="text-body-sm font-semibold text-ink">{item.item_name ?? item.service_title}</p>
|
||||
{item.item_name && <p className="text-meta text-ink-subtle truncate">{item.service_title}</p>}
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<p className="text-xs text-gray-400">{cfg.dateLabel} {formatDate(item.created_at, 'long')}</p>
|
||||
<p className="text-meta text-ink-subtle">{cfg.dateLabel} {formatDate(item.created_at, 'long')}</p>
|
||||
<button
|
||||
onClick={() => setShowCancel(true)}
|
||||
className="relative z-10 text-xs text-danger-600 hover:text-danger-700 whitespace-nowrap shrink-0"
|
||||
className="btn-quiet btn-sm relative z-10 shrink-0 whitespace-nowrap text-danger-600 hover:text-danger-700"
|
||||
>
|
||||
{t('my_contributions.cancel')}
|
||||
</button>
|
||||
|
|
@ -177,16 +180,18 @@ function MyContributions() {
|
|||
setItems(prev => prev.filter(i => i.cancel_token !== cancelToken))
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<div className="flex items-center justify-between gap-4 mb-6 pt-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">{t('my_contributions.title')}</h1>
|
||||
/* Deuxième région du tableau de bord, pas une deuxième page : le titre est
|
||||
une section (`h2`), le `h1` reste celui du tableau de bord (VIS-04). */
|
||||
<section className="page" aria-labelledby="my-contributions-title">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 id="my-contributions-title" className="text-section-title text-ink">{t('my_contributions.title')}</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Spinner /></div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState message={t('my_contributions.empty')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-cards">
|
||||
{items.map(item => (
|
||||
<ContributionCard key={item.cancel_token} item={item} onCancelled={handleCancelled} />
|
||||
))}
|
||||
|
|
@ -209,13 +214,9 @@ function HubCard({ event, onDelete, onEdit, shareUrl }: {
|
|||
<div className="pr-5">
|
||||
<h2 className="card-title">{event.title}</h2>
|
||||
<div className="card-meta">
|
||||
{event.event_type && <span>{tc('types.event.' + event.event_type)}</span>}
|
||||
{event.event_date && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatDate(event.event_date)}
|
||||
</span>
|
||||
)}
|
||||
{event.location && <span className="text-xs text-gray-400 truncate">{event.location}</span>}
|
||||
{event.event_type && <span className="badge-meta">{tc('types.event.' + event.event_type)}</span>}
|
||||
{event.event_date && <span>{formatDate(event.event_date)}</span>}
|
||||
{event.location && <span className="truncate">{event.location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ import {
|
|||
updateServiceVisibility,
|
||||
type AttachedService, type EventDetail, type EventNotification,
|
||||
} from '@services/hub/api/hub'
|
||||
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, LocationChipIcon, MetaChip, Modal, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
|
||||
import { CalendarChipIcon, CollaborationPanel, CommentBlock, CoOwnerPanel, EmptyState, LocationChipIcon, MetaChip, Modal, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { EVENT_TYPE_ICONS } from '@shared/types'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import { getHubUseCase } from '@shared/config/landings'
|
||||
import EditHubModal from '@services/hub/components/EditHubModal'
|
||||
import ModuleGroup from '@services/hub/components/ModuleGroup'
|
||||
import ServiceToolTile from '@services/hub/components/ServiceToolTile'
|
||||
import TemplateStarterPanel from '@services/hub/components/TemplateStarterPanel'
|
||||
import { services } from '@shared/config/services'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
import { groupByServiceType } from '@services/hub/utils'
|
||||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
|
||||
|
|
@ -23,18 +25,19 @@ import { usePageTitle } from '@shared/hooks/usePageTitle'
|
|||
function NotificationFeed({ notifications }: { notifications: EventNotification[] }) {
|
||||
const { t } = useTranslation('hub')
|
||||
if (notifications.length === 0) return null
|
||||
// Le fil d'activité appartient au rail de collaboration : même surface que le
|
||||
// partage, la co-propriété et les commentaires (VIS-03).
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-800 mb-3 text-sm">{t('hub_edit_page.activity')}</h3>
|
||||
<CollaborationPanel title={t('hub_edit_page.activity')}>
|
||||
<div className="space-y-2">
|
||||
{[...notifications].reverse().map(n => (
|
||||
<div key={n.id} className="flex items-start gap-2 text-xs text-gray-600">
|
||||
<span className="text-gray-400 shrink-0 mt-0.5">{formatDate(n.created_at)}</span>
|
||||
<div key={n.id} className="flex items-start gap-2 text-body-sm text-ink-muted">
|
||||
<span className="text-meta text-ink-subtle shrink-0 mt-0.5">{formatDate(n.created_at)}</span>
|
||||
<span>{n.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollaborationPanel>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -47,56 +50,50 @@ function AttachedServicesPanel({ eventId, services: attached, onDetached, onVisi
|
|||
onVisibilityChange: (svc: AttachedService) => void
|
||||
}) {
|
||||
const { t } = useTranslation('hub')
|
||||
const { t: tc } = useTranslation('common')
|
||||
if (attached.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-16 text-sm">
|
||||
{t('hub_edit_page.no_services')}
|
||||
</div>
|
||||
)
|
||||
return <EmptyState message={t('hub_edit_page.no_services')} />
|
||||
}
|
||||
|
||||
const grouped = groupByServiceType(attached, services)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-section">
|
||||
{grouped.map(({ cfg, items }) => (
|
||||
<div key={cfg.id}>
|
||||
<div className="flex items-center gap-2 mb-3 text-gray-500">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<h3 className="text-sm font-semibold">{cfg.label}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map(svc => {
|
||||
const CardRenderer = cfg.renderAttachedCard
|
||||
if (CardRenderer) {
|
||||
return (
|
||||
<CardRenderer
|
||||
key={svc.id}
|
||||
resourceId={svc.resource_id}
|
||||
isVisible={svc.is_visible}
|
||||
onToggleVisibility={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
onDetach={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ModuleGroup key={cfg.id} serviceId={cfg.id as ServiceId} label={cfg.label} count={items.length}>
|
||||
{items.map(svc => {
|
||||
const CardRenderer = cfg.renderAttachedCard
|
||||
if (CardRenderer) {
|
||||
return (
|
||||
<div key={svc.id} className="card-item flex items-center gap-3">
|
||||
<span className="flex-1 text-sm text-gray-800">{svc.service_type}</span>
|
||||
<button
|
||||
onClick={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
className={`text-xs px-2 py-0.5 rounded whitespace-nowrap ${svc.is_visible ? 'badge-green' : 'badge-gray'}`}
|
||||
>
|
||||
{svc.is_visible ? t('hub_edit_page.visible') : t('hub_edit_page.hidden')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
className="text-sm text-danger-600 hover:text-danger-700 px-1"
|
||||
>×</button>
|
||||
</div>
|
||||
<CardRenderer
|
||||
key={svc.id}
|
||||
resourceId={svc.resource_id}
|
||||
isVisible={svc.is_visible}
|
||||
onToggleVisibility={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
onDetach={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div key={svc.id} className="card-item flex items-center gap-2">
|
||||
<span className="flex-1 text-body-sm text-ink">{cfg.label}</span>
|
||||
<button
|
||||
onClick={async () => onVisibilityChange(await updateServiceVisibility(eventId, svc.id, !svc.is_visible))}
|
||||
className={svc.is_visible ? 'badge-success' : 'badge-meta'}
|
||||
>
|
||||
{svc.is_visible ? t('hub_edit_page.visible') : t('hub_edit_page.hidden')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await detachService(eventId, svc.id); onDetached(svc.id) }}
|
||||
className="btn-icon hover:text-danger-600"
|
||||
aria-label={tc('buttons.delete')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ModuleGroup>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -106,6 +103,7 @@ function AttachedServicesPanel({ eventId, services: attached, onDetached, onVisi
|
|||
|
||||
export default function HubEdit() {
|
||||
const { t } = useTranslation('hub')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { user } = useAuth()
|
||||
const { eventId } = useParams<{ eventId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
|
@ -134,14 +132,14 @@ export default function HubEdit() {
|
|||
}, [searchParams, setSearchParams])
|
||||
|
||||
if (loading) return <main className="page flex items-center justify-center h-32"><Spinner /></main>
|
||||
if (error || !event) return <main className="page text-center"><p className="text-gray-500">{error || t('hub_edit_page.not_found')}</p></main>
|
||||
if (error || !event) return <main className="page pt-6"><EmptyState message={error || t('hub_edit_page.not_found')} /></main>
|
||||
|
||||
const publicUrl = `${window.location.origin}/hub/p/${event.share_token}`
|
||||
const template = getHubUseCase(searchParams.get('template'))
|
||||
const templateModuleIds = template?.modules ?? []
|
||||
const preferredService = searchParams.get('service') ?? templateModuleIds[0]
|
||||
const eventActions = services.filter(s => s.active && s.eventActions?.length)
|
||||
.flatMap(s => s.eventActions!.map(action => ({ ...action, serviceId: s.id, serviceIcon: s.icon, serviceLabel: s.label })))
|
||||
.flatMap(s => s.eventActions!.map(action => ({ ...action, serviceId: s.id, serviceLabel: s.label })))
|
||||
.sort((left, right) => {
|
||||
if (left.serviceId === preferredService) return -1
|
||||
if (right.serviceId === preferredService) return 1
|
||||
|
|
@ -175,9 +173,9 @@ export default function HubEdit() {
|
|||
<main className="page">
|
||||
<ServicePageHeader
|
||||
title={event.title}
|
||||
titleIcon={event.event_type ? EVENT_TYPE_ICONS[event.event_type] : undefined}
|
||||
backPath="/hub"
|
||||
meta={<>
|
||||
{event.event_type && <span className="badge-meta">{tc(`types.event.${event.event_type}`)}</span>}
|
||||
{event.event_date && <MetaChip icon={<CalendarChipIcon />}>{formatDate(event.event_date, 'long')}</MetaChip>}
|
||||
{event.location && <MetaChip icon={<LocationChipIcon />}>{event.location}</MetaChip>}
|
||||
</>}
|
||||
|
|
@ -196,10 +194,14 @@ export default function HubEdit() {
|
|||
<div className="layout-service">
|
||||
<div className="service-main">
|
||||
{!hasFirstModule ? (
|
||||
<section className="rounded-2xl border border-primary-200 bg-primary-50 p-5 sm:p-6" aria-labelledby="onboarding-title">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-success-700">✓ {t('onboarding.hub_created')}</p>
|
||||
<h2 id="onboarding-title" className="text-lg font-bold text-gray-900 mt-2">{t('onboarding.title')}</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{t('onboarding.description')}</p>
|
||||
/* Démarrage : de l'aide contextuelle, donc le rôle `notice` — un
|
||||
aplat de service se lirait comme une alerte sous Kontrib ou Kdo
|
||||
(règle du remplissage). Les cinq outils deviennent des choix, pas
|
||||
cinq actions dominantes concurrentes. */
|
||||
<section className="card-notice p-panel" aria-labelledby="onboarding-title">
|
||||
<span className="badge-success">{t('onboarding.hub_created')}</span>
|
||||
<h2 id="onboarding-title" className="text-section-title text-ink mt-2">{t('onboarding.title')}</h2>
|
||||
<p className="text-body-sm text-ink-soft mt-1">{t('onboarding.description')}</p>
|
||||
{template ? (
|
||||
<TemplateStarterPanel
|
||||
event={event}
|
||||
|
|
@ -207,25 +209,25 @@ export default function HubEdit() {
|
|||
onCreated={setEvent}
|
||||
/>
|
||||
) : preferredAction ? (
|
||||
<p className="mt-4 text-xs font-semibold text-primary-800">{t('onboarding.requested_module', { service: preferredAction.serviceLabel })}</p>
|
||||
<p className="mt-4 text-body-sm font-medium text-ink-soft">{t('onboarding.requested_module', { service: preferredAction.serviceLabel })}</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 mt-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-cards mt-4">
|
||||
{eventActions.map(action => (
|
||||
<button
|
||||
<ServiceToolTile
|
||||
key={action.serviceId}
|
||||
type="button"
|
||||
serviceId={action.serviceId as ServiceId}
|
||||
label={action.serviceLabel}
|
||||
recommended={action.serviceId === preferredService}
|
||||
onClick={() => openServiceModal(action.modal)}
|
||||
className={`btn-primary btn-sm justify-center gap-1.5 ${action.serviceId === preferredService ? 'ring-2 ring-primary-300 ring-offset-2' : ''}`}
|
||||
>
|
||||
<span>{action.serviceIcon}</span>{action.serviceLabel}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-4">
|
||||
<button type="button" onClick={() => setShowServicePicker(true)} className="btn-primary btn-sm">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-section-title text-ink">{t('hub_edit_page.tools_title')}</h2>
|
||||
<button type="button" onClick={() => setShowServicePicker(true)} className="btn-primary btn-sm shrink-0">
|
||||
+ {t('onboarding.add_tool')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -241,13 +243,9 @@ export default function HubEdit() {
|
|||
|
||||
<div className="service-right">
|
||||
<SharePanel url={publicUrl} label={t('hub_edit_page.share_label')} downloadName={event.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="hub" resourceId={event.id} isOwner={event.user_id === user?.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="hub" resourceId={event.id} isOwner={event.user_id === user?.id} />
|
||||
<NotificationFeed notifications={event.notifications} />
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="hub" resourceId={event.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="hub" resourceId={event.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -257,9 +255,9 @@ export default function HubEdit() {
|
|||
title={t('duplicate.title')}
|
||||
onClose={() => !duplicateLoading && setShowDuplicateModal(false)}
|
||||
>
|
||||
<p className="text-sm text-gray-600">{t('duplicate.description')}</p>
|
||||
<p className="mt-3 text-xs text-gray-500">{t('duplicate.excluded')}</p>
|
||||
{duplicateError && <p className="mt-3 text-sm text-danger-600">{duplicateError}</p>}
|
||||
<p className="text-body-sm text-ink-muted">{t('duplicate.description')}</p>
|
||||
<p className="card-notice mt-3 text-meta text-ink-soft">{t('duplicate.excluded')}</p>
|
||||
{duplicateError && <p className="alert-error mt-3">{duplicateError}</p>}
|
||||
<div className="modal-actions mt-6">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -290,16 +288,14 @@ export default function HubEdit() {
|
|||
)}
|
||||
{showServicePicker && (
|
||||
<Modal serviceId="hub" title={t('onboarding.add_tool')} onClose={() => setShowServicePicker(false)}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-cards">
|
||||
{eventActions.map(action => (
|
||||
<button
|
||||
<ServiceToolTile
|
||||
key={action.serviceId}
|
||||
type="button"
|
||||
serviceId={action.serviceId as ServiceId}
|
||||
label={action.serviceLabel}
|
||||
onClick={() => openServiceModal(action.modal)}
|
||||
className="btn-primary justify-center gap-2"
|
||||
>
|
||||
<span>{action.serviceIcon}</span>{action.serviceLabel}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createEvent } from '@services/hub/api/hub'
|
||||
import { Alert } from '@shared/components'
|
||||
import { Alert, Field } from '@shared/components'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import { serviceMetadata } from '@shared/config/serviceMetadata'
|
||||
import { getHubUseCase } from '@shared/config/landings'
|
||||
|
|
@ -51,20 +51,31 @@ export default function HubNew() {
|
|||
|
||||
return (
|
||||
<main className="page-sm py-10">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">{t("hub_new.title")}</h1>
|
||||
<h1 className="text-page-title text-ink mb-6">{t("hub_new.title")}</h1>
|
||||
<div className="card">
|
||||
{/* Libellés persistants (VIS-02) : le placeholder redisait le nom du
|
||||
champ et disparaissait à la saisie. Les textes visibles ne changent
|
||||
pas — ils passent seulement de l'attribut au libellé. */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3" aria-label={t("hub_new.title")}>
|
||||
<Alert type="error" message={error} />
|
||||
<input className="input" aria-label={t("hub_new.name_placeholder")} placeholder={t("hub_new.name_placeholder")} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
<Field label={t("hub_new.name_placeholder")} id="hub-new-title">
|
||||
<input id="hub-new-title" className="input" value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<HubTemplateFields
|
||||
eventType={eventType}
|
||||
templateKey={templateKey}
|
||||
onEventTypeChange={setEventType}
|
||||
onTemplateChange={setTemplateKey}
|
||||
/>
|
||||
<input className="input" type="date" aria-label={t("create_modal.date_placeholder")} value={eventDate} onChange={e => setEventDate(e.target.value)} />
|
||||
<input className="input" aria-label={t("hub_new.location_placeholder")} placeholder={t("hub_new.location_placeholder")} value={location} onChange={e => setLocation(e.target.value)} />
|
||||
<textarea className="input" rows={3} aria-label={t("hub_new.desc_placeholder")} placeholder={t("hub_new.desc_placeholder")} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<Field label={t("create_modal.date_placeholder")} id="hub-new-date">
|
||||
<input id="hub-new-date" className="input" type="date" value={eventDate} onChange={e => setEventDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t("hub_new.location_placeholder")} id="hub-new-location">
|
||||
<input id="hub-new-location" className="input" value={location} onChange={e => setLocation(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t("hub_new.desc_placeholder")} id="hub-new-description">
|
||||
<textarea id="hub-new-description" className="input" rows={3} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? t("hub_new.creating") : t("hub_new.submit")}</button>
|
||||
<button type="button" onClick={() => navigate("/hub")} className="btn-secondary">{tc("buttons.cancel")}</button>
|
||||
|
|
|
|||
223
frontend/src/services/hub/pages/HubPilot.test.tsx
Normal file
223
frontend/src/services/hub/pages/HubPilot.test.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Suspense } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import '../../../i18n/config'
|
||||
import i18n, { loadNamespace } from '../../../i18n/config'
|
||||
import { AuthProvider } from '@shared/hooks/useAuth'
|
||||
import {
|
||||
getEvent, getEvents, getMyContributions, getPublicEvent,
|
||||
type EventDetail, type EventPublic, type EventSummary, type MyContributionItem,
|
||||
} from '@services/hub/api/hub'
|
||||
import HubEdit from './HubEdit'
|
||||
import HubPublic from './HubPublic'
|
||||
import HubDashboard from './Dashboard'
|
||||
|
||||
vi.mock('@services/hub/api/hub', () => ({
|
||||
getEvent: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getPublicEvent: vi.fn(),
|
||||
getMyContributions: vi.fn(),
|
||||
detachService: vi.fn(),
|
||||
duplicateEvent: vi.fn(),
|
||||
updateServiceVisibility: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
createEvent: vi.fn(),
|
||||
createHubModules: vi.fn(),
|
||||
}))
|
||||
|
||||
// Les cartes attachées interrogent leur propre service : sans réponse elles
|
||||
// restent sur leur squelette, ce qui suffit ici — le lot VIS-04 porte sur
|
||||
// l'assemblage (groupes, titres, rôles de surface), pas sur leur contenu.
|
||||
vi.mock('@shared/api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => new Promise(() => {})),
|
||||
post: vi.fn(() => new Promise(() => {})),
|
||||
patch: vi.fn(() => new Promise(() => {})),
|
||||
put: vi.fn(() => new Promise(() => {})),
|
||||
delete: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
}))
|
||||
|
||||
const eventId = '00000000-0000-4000-8000-000000000042'
|
||||
|
||||
const detail = (services: EventDetail['services']): EventDetail => ({
|
||||
id: eventId,
|
||||
user_id: 'owner-1',
|
||||
title: 'Anniversaire de Léa',
|
||||
event_type: 'anniversaire',
|
||||
event_date: '2026-09-12',
|
||||
location: 'Lyon',
|
||||
description: null,
|
||||
share_token: 'share-token',
|
||||
created_at: '2026-08-01T10:00:00Z',
|
||||
services,
|
||||
notifications: [],
|
||||
})
|
||||
|
||||
const attached = (id: string, serviceType: string): EventDetail['services'][number] => ({
|
||||
id,
|
||||
service_type: serviceType,
|
||||
resource_id: `${serviceType}-resource`,
|
||||
is_visible: true,
|
||||
created_at: '2026-08-01T10:00:00Z',
|
||||
})
|
||||
|
||||
function renderHubEdit() {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={[`/hub/${eventId}`]}>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/hub/:eventId" element={<HubEdit />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await i18n.changeLanguage('fr')
|
||||
await loadNamespace('landing')
|
||||
// jsdom n'implémente pas ResizeObserver, utilisé par le masthead de service.
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Hub pilote — assemblage des services (VIS-04)', () => {
|
||||
it('démarre sur une aide contextuelle, sans aplat de service ni actions concurrentes', async () => {
|
||||
vi.mocked(getEvent).mockResolvedValue(detail([]))
|
||||
renderHubEdit()
|
||||
|
||||
const onboarding = await screen.findByRole('region', { name: 'Par quoi souhaitez-vous commencer ?' })
|
||||
// Règle du remplissage : l'aide contextuelle porte `notice`, jamais la
|
||||
// couleur d'un service.
|
||||
expect(onboarding).toHaveClass('card-notice')
|
||||
expect(onboarding.className).not.toMatch(/bg-primary/)
|
||||
|
||||
// Les cinq outils sont des choix de même poids, pas cinq actions dominantes.
|
||||
const tiles = within(onboarding).getAllByRole('button')
|
||||
expect(tiles.length).toBeGreaterThanOrEqual(5)
|
||||
tiles.forEach(tile => {
|
||||
expect(tile).toHaveClass('card-interactive')
|
||||
expect(tile).not.toHaveClass('btn-primary')
|
||||
})
|
||||
expect(within(onboarding).queryByRole('button', { name: /^\+/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('groupe les modules par service et laisse une seule action dominante', async () => {
|
||||
vi.mocked(getEvent).mockResolvedValue(detail([
|
||||
attached('svc-1', 'kdo'),
|
||||
attached('svc-2', 'kdo'),
|
||||
attached('svc-3', 'kal'),
|
||||
]))
|
||||
renderHubEdit()
|
||||
|
||||
const kdoGroup = await screen.findByRole('region', { name: /Kdo/ })
|
||||
const kalGroup = screen.getByRole('region', { name: /Kal/ })
|
||||
expect(within(kdoGroup).getByRole('heading', { level: 2, name: 'Kdo' })).toBeVisible()
|
||||
expect(within(kdoGroup).getByText('2')).toBeVisible()
|
||||
expect(within(kalGroup).getByText('1')).toBeVisible()
|
||||
|
||||
// Une seule action principale dans la colonne d'assemblage : ajouter un outil.
|
||||
// Le rail de collaboration garde la sienne, il n'est pas concerné.
|
||||
const assembly = screen.getByRole('main').querySelector('.service-main') as HTMLElement
|
||||
const dominant = within(assembly).getAllByRole('button')
|
||||
.filter(button => button.className.includes('btn-primary'))
|
||||
expect(dominant).toHaveLength(1)
|
||||
expect(dominant[0]).toHaveTextContent('Ajouter un outil')
|
||||
})
|
||||
|
||||
it('ne porte qu’un seul titre de page sur l’espace de travail', async () => {
|
||||
vi.mocked(getEvent).mockResolvedValue(detail([attached('svc-1', 'kdo')]))
|
||||
renderHubEdit()
|
||||
|
||||
await screen.findByRole('region', { name: /Kdo/ })
|
||||
const titles = screen.getAllByRole('heading', { level: 1 })
|
||||
expect(titles).toHaveLength(1)
|
||||
expect(titles[0]).toHaveTextContent('Anniversaire de Léa')
|
||||
})
|
||||
|
||||
it('présente la page publique avec les mêmes groupes de services', async () => {
|
||||
const publicEvent: EventPublic = {
|
||||
id: eventId,
|
||||
title: 'Anniversaire de Léa',
|
||||
event_type: 'anniversaire',
|
||||
event_date: '2026-09-12',
|
||||
location: 'Lyon',
|
||||
description: null,
|
||||
services: [
|
||||
{ id: 'svc-1', service_type: 'kdo', resource_id: 'kdo-1', public_token: 'kdo-token' },
|
||||
{ id: 'svc-2', service_type: 'kount', resource_id: 'kount-1', public_token: 'kount-token' },
|
||||
],
|
||||
}
|
||||
vi.mocked(getPublicEvent).mockResolvedValue(publicEvent)
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={['/hub/p/share-token']}>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/hub/p/:shareToken" element={<HubPublic />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
await screen.findByRole('region', { name: /Kdo/ })
|
||||
expect(screen.getByRole('region', { name: /Kount/ })).toBeVisible()
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('garde le tableau de bord sur un seul titre, les participations en section', async () => {
|
||||
const summary: EventSummary = {
|
||||
id: eventId,
|
||||
title: 'Anniversaire de Léa',
|
||||
event_type: 'anniversaire',
|
||||
event_date: '2026-09-12',
|
||||
location: 'Lyon',
|
||||
share_token: 'share-token',
|
||||
created_at: '2026-08-01T10:00:00Z',
|
||||
}
|
||||
const contribution = {
|
||||
type: 'reservation',
|
||||
service_title: 'Cadeaux de Léa',
|
||||
item_name: 'Un livre',
|
||||
amount: null,
|
||||
currency: null,
|
||||
created_at: '2026-08-02T10:00:00Z',
|
||||
cancel_token: 'cancel-1',
|
||||
public_token: 'public-1',
|
||||
} as MyContributionItem
|
||||
vi.mocked(getEvents).mockResolvedValue([summary])
|
||||
vi.mocked(getMyContributions).mockResolvedValue([contribution])
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={['/hub']}>
|
||||
<Suspense fallback={null}>
|
||||
<HubDashboard />
|
||||
</Suspense>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Un livre')).toBeVisible())
|
||||
const titles = screen.getAllByRole('heading', { level: 1 })
|
||||
expect(titles).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Mes contributions' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
|
|||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getPublicEvent, type EventPublic } from '@services/hub/api/hub'
|
||||
import { CalendarChipIcon, CommentBlock, LocationChipIcon, MetaChip, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { EVENT_TYPE_ICONS } from '@shared/types'
|
||||
import { CalendarChipIcon, CommentBlock, EmptyState, LocationChipIcon, MetaChip, ServicePageHeader, Spinner } from '@shared/components'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import { services } from '@shared/config/services'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
import ModuleGroup from '@services/hub/components/ModuleGroup'
|
||||
import { groupByServiceType } from '@services/hub/utils'
|
||||
|
||||
export default function HubPublicPage() {
|
||||
const { t } = useTranslation('hub')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { shareToken } = useParams<{ shareToken: string }>()
|
||||
const [event, setEvent] = useState<EventPublic | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -23,14 +25,14 @@ export default function HubPublicPage() {
|
|||
}, [shareToken])
|
||||
|
||||
if (loading) return <main className="page flex items-center justify-center h-32"><Spinner /></main>
|
||||
if (error || !event) return <main className="page text-center"><p className="text-gray-500">{error || t('hub_public.not_found')}</p></main>
|
||||
if (error || !event) return <main className="page pt-6"><EmptyState message={error || t('hub_public.not_found')} /></main>
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<ServicePageHeader
|
||||
title={event.title}
|
||||
titleIcon={event.event_type ? EVENT_TYPE_ICONS[event.event_type] : undefined}
|
||||
meta={<>
|
||||
{event.event_type && <span className="badge-meta">{tc(`types.event.${event.event_type}`)}</span>}
|
||||
{event.event_date && <MetaChip icon={<CalendarChipIcon />}>{formatDate(event.event_date, 'long')}</MetaChip>}
|
||||
{event.location && <MetaChip icon={<LocationChipIcon />}>{event.location}</MetaChip>}
|
||||
</>}
|
||||
|
|
@ -40,41 +42,32 @@ export default function HubPublicPage() {
|
|||
<div className="layout-service">
|
||||
<div className="service-main">
|
||||
{event.services.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-12 text-sm">{t('hub_public.no_services')}</p>
|
||||
<EmptyState message={t('hub_public.no_services')} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-section">
|
||||
{groupByServiceType(event.services, services).map(({ cfg, items }) => (
|
||||
<div key={cfg.id}>
|
||||
<div className="flex items-center gap-2 mb-3 text-gray-500">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<h3 className="text-sm font-semibold">{cfg.label}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map(svc => {
|
||||
if (cfg.renderPublicCard && svc.public_token) {
|
||||
const PublicCard = cfg.renderPublicCard
|
||||
return <PublicCard key={svc.id} publicToken={svc.public_token} />
|
||||
}
|
||||
const url = cfg.publicPath && svc.public_token ? cfg.publicPath(svc.public_token) : null
|
||||
return (
|
||||
<div key={svc.id} className="card-item flex items-center gap-3">
|
||||
<span className="flex-1 text-sm font-medium text-gray-800">{cfg.label}</span>
|
||||
{url && <Link to={url} className="btn-secondary btn-sm shrink-0">{t('hub_public.view')}</Link>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<ModuleGroup key={cfg.id} serviceId={cfg.id as ServiceId} label={cfg.label} count={items.length}>
|
||||
{items.map(svc => {
|
||||
if (cfg.renderPublicCard && svc.public_token) {
|
||||
const PublicCard = cfg.renderPublicCard
|
||||
return <PublicCard key={svc.id} publicToken={svc.public_token} />
|
||||
}
|
||||
const url = cfg.publicPath && svc.public_token ? cfg.publicPath(svc.public_token) : null
|
||||
return (
|
||||
<div key={svc.id} className="card-item flex items-center gap-2">
|
||||
<span className="flex-1 text-body-sm font-medium text-ink">{cfg.label}</span>
|
||||
{url && <Link to={url} className="btn-secondary btn-sm shrink-0">{t('hub_public.view')}</Link>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ModuleGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="service-right">
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="hub" resourceId={event.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="hub" resourceId={event.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function MultiDatePicker({ value, onChange, max = 30 }: Props) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={clearMonth}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 text-gray-400 hover:border-red-200 hover:text-red-400 transition-colors"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 text-gray-400 hover:border-danger-100 hover:text-danger-600 transition-colors"
|
||||
>
|
||||
{t('calendar.clear')}
|
||||
</button>
|
||||
|
|
@ -224,7 +224,7 @@ export default function MultiDatePicker({ value, onChange, max = 30 }: Props) {
|
|||
key={iso}
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter(v => v !== iso))}
|
||||
className="text-[11px] bg-primary-50 text-primary-600 px-1.5 py-0.5 rounded-full hover:bg-red-50 hover:text-red-400 transition-colors"
|
||||
className="text-[11px] bg-primary-50 text-primary-600 px-1.5 py-0.5 rounded-full hover:bg-danger-50 hover:text-danger-600 transition-colors"
|
||||
>
|
||||
{label} ×
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function PollCard({ poll, onDelete }: Props) {
|
|||
{t('poll_card.slots_count', { count: poll.slot_count })}
|
||||
{' · '}
|
||||
{t('poll_card.participants_count', { count: poll.participant_count })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_card.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_card.status_closed')}</span>}
|
||||
</p>
|
||||
{best && (
|
||||
<p className="text-xs text-primary-600 font-medium">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function PublicPollCard({ publicToken }: PublicCardProps) {
|
|||
<h2 className="card-title">{poll.title}</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('poll_card.participants_count', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1 text-red-400">· {t('poll_card.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1 text-gray-500">· {t('poll_card.status_closed')}</span>}
|
||||
</p>
|
||||
{best && bestScore > 0 && (
|
||||
<p className="text-xs text-primary-600 font-medium">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '../../../i18n/config'
|
||||
import TimeSlotSelector from './TimeSlotSelector'
|
||||
|
||||
describe('TimeSlotSelector', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('fr')
|
||||
})
|
||||
|
||||
it('utilise la palette du service et nomme la suppression de plage', () => {
|
||||
const { container } = render(
|
||||
<TimeSlotSelector
|
||||
value={[{ start: 9 * 60, end: 12 * 60 }]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const range = screen.getByText('09h–12h').parentElement?.parentElement
|
||||
expect(range).toHaveClass('bg-primary-600')
|
||||
expect(range).not.toHaveStyle({ background: '#C77548' })
|
||||
expect(screen.getByRole('button', { name: 'Supprimer la plage 09h–12h' })).toBeVisible()
|
||||
expect(container.querySelector('[style*="#C77548"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -122,12 +122,11 @@ export default function TimeSlotSelector({ value, onChange, maxRanges = 2 }: Pro
|
|||
{TICKS.map(h => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute top-0 bottom-0 w-px pointer-events-none"
|
||||
className={`absolute w-px pointer-events-none ${
|
||||
h % 3 === 0 ? 'inset-y-0 bg-gray-300' : 'top-[30%] h-[40%] bg-gray-200'
|
||||
}`}
|
||||
style={{
|
||||
left: pct(h * 60),
|
||||
background: h % 3 === 0 ? '#d1d5db' : '#e5e7eb',
|
||||
height: h % 3 === 0 ? '100%' : '40%',
|
||||
top: h % 3 === 0 ? 0 : '30%',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -136,8 +135,8 @@ export default function TimeSlotSelector({ value, onChange, maxRanges = 2 }: Pro
|
|||
{value.map((range, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute top-1.5 bottom-1.5 rounded-md flex items-center"
|
||||
style={{ left: pct(range.start), width: pct(range.end - range.start), background: '#C77548' }}
|
||||
className="absolute top-1.5 bottom-1.5 rounded-md flex items-center bg-primary-600"
|
||||
style={{ left: pct(range.start), width: pct(range.end - range.start) }}
|
||||
>
|
||||
{/* Poignée gauche — zone tactile élargie (24px), visuel inchangé */}
|
||||
<div
|
||||
|
|
@ -171,8 +170,15 @@ export default function TimeSlotSelector({ value, onChange, maxRanges = 2 }: Pro
|
|||
{/* Supprimer */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onChange(value.filter((_, i) => i !== idx)) }}
|
||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white border border-gray-300 text-gray-400 hover:text-red-500 flex items-center justify-center text-[10px] leading-none z-20"
|
||||
>×</button>
|
||||
aria-label={t('poll_edit.remove_time_range', {
|
||||
range: `${label(range.start)}–${label(range.end)}`,
|
||||
})}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-white border border-gray-300 text-gray-400 hover:text-danger-600 flex items-center justify-center z-20"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-2.5 w-2.5" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<path strokeLinecap="round" d="M2.5 2.5l7 7m0-7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const CLICK_CYCLE: Answer[] = ['yes', 'maybe', 'no']
|
|||
|
||||
const PILL_STYLE: Record<Answer, string> = {
|
||||
yes: 'bg-primary-600 text-white',
|
||||
maybe: 'bg-amber-400 text-white',
|
||||
maybe: 'bg-warning-100 text-warning-700',
|
||||
no: 'text-gray-300 hover:bg-primary-50',
|
||||
}
|
||||
const PILL_ICON: Record<Answer, string> = { yes: '✓', maybe: '~', no: '' }
|
||||
|
|
@ -112,10 +112,6 @@ export default function VoteSelectionGrid({ slots, dates, timeRanges, locations,
|
|||
position: 'sticky',
|
||||
top,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'white',
|
||||
// box-shadow au lieu de border-b : avec border-collapse:collapse, la bordure partagée
|
||||
// ne suit pas la cellule sticky → contenu visible en dessous. box-shadow suit la cellule.
|
||||
boxShadow: '0 1px 0 #e5e7eb',
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -125,7 +121,7 @@ export default function VoteSelectionGrid({ slots, dates, timeRanges, locations,
|
|||
{t('poll_edit.dates_hint')} ·{' '}
|
||||
<span className="text-primary-600 font-medium">✓ {t('poll_public.available')}</span>
|
||||
{' → '}
|
||||
<span className="text-amber-500 font-medium">~ {t('poll_public.maybe')}</span>
|
||||
<span className="text-warning-700 font-medium">~ {t('poll_public.maybe')}</span>
|
||||
{' → '}{t('poll_public.unavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -153,13 +149,13 @@ export default function VoteSelectionGrid({ slots, dates, timeRanges, locations,
|
|||
<th
|
||||
key={loc?.id ?? 'no-loc'}
|
||||
colSpan={cols.length}
|
||||
className={`text-center text-xs font-semibold text-gray-700 px-2 py-1.5 bg-white`}
|
||||
className="text-center text-xs font-semibold text-gray-700 px-2 py-1.5 bg-white shadow-[inset_0_-1px_0_0] shadow-gray-200"
|
||||
style={thSticky(STICKY_TOP_1)}
|
||||
>
|
||||
{loc?.label ?? ''}
|
||||
</th>
|
||||
))}
|
||||
<th className="bg-white" style={thSticky(STICKY_TOP_1)} />
|
||||
<th className="bg-white shadow-[inset_0_-1px_0_0] shadow-gray-200" style={thSticky(STICKY_TOP_1)} />
|
||||
</tr>
|
||||
)}
|
||||
{hasTimeRanges && (
|
||||
|
|
@ -169,14 +165,14 @@ export default function VoteSelectionGrid({ slots, dates, timeRanges, locations,
|
|||
return (
|
||||
<th
|
||||
key={i}
|
||||
className={`text-center text-xs text-gray-500 px-1 py-1 font-normal bg-white`}
|
||||
className="text-center text-xs text-gray-500 px-1 py-1 font-normal bg-white shadow-[inset_0_-1px_0_0] shadow-gray-200"
|
||||
style={thSticky(trTop)}
|
||||
>
|
||||
{tr ? `${formatTime(tr.start_time)}–${formatTime(tr.end_time)}` : '—'}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
<th className="bg-white" style={thSticky(trTop)} />
|
||||
<th className="bg-white shadow-[inset_0_-1px_0_0] shadow-gray-200" style={thSticky(trTop)} />
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default function PollEdit() {
|
|||
{t('poll_card.slots_count', { count: poll.slots.length })}
|
||||
{' · '}
|
||||
{t('poll_edit.responses_label', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_edit.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_edit.status_closed')}</span>}
|
||||
</span>
|
||||
}
|
||||
onEdit={isOwner ? () => setShowEditModal(true) : undefined}
|
||||
|
|
@ -177,12 +177,8 @@ export default function PollEdit() {
|
|||
{/* service-right — Panels génériques */}
|
||||
<div className="service-right">
|
||||
<SharePanel url={shareUrl} label={t('poll_edit.share_label')} downloadName={poll.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="kal" resourceId={poll.id} isOwner={isOwner} linkedHubEventId={poll.event_id ?? undefined} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kal" resourceId={poll.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="kal" resourceId={poll.id} isOwner={isOwner} linkedHubEventId={poll.event_id ?? undefined} />
|
||||
<CommentBlock resourceType="kal" resourceId={poll.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function PollPublic() {
|
|||
{t('poll_card.slots_count', { count: poll.slots.length })}
|
||||
{' · '}
|
||||
{t('poll_edit.responses_label', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_edit.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_edit.status_closed')}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
@ -252,9 +252,7 @@ export default function PollPublic() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kal" resourceId={poll.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="kal" resourceId={poll.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -81,12 +81,8 @@ export default function ListEdit() {
|
|||
|
||||
<div className="service-right">
|
||||
<SharePanel url={shareUrl} label={t('list_edit.share_label')} downloadName={list.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="kdo" resourceId={list.id} isOwner={list.user_id === user?.id} linkedHubEventId={list.event_id ?? undefined} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kdo" resourceId={list.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="kdo" resourceId={list.id} isOwner={list.user_id === user?.id} linkedHubEventId={list.event_id ?? undefined} />
|
||||
<CommentBlock resourceType="kdo" resourceId={list.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -117,10 +117,8 @@ export default function ListPublicPage() {
|
|||
</div>
|
||||
|
||||
<div className="service-right">
|
||||
<div className="card">
|
||||
<Alert type="error" message={error} />
|
||||
<CommentBlock resourceType="kdo" resourceId={list.id} />
|
||||
</div>
|
||||
<Alert type="error" message={error} />
|
||||
<CommentBlock resourceType="kdo" resourceId={list.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -115,12 +115,8 @@ export default function BringEdit() {
|
|||
|
||||
<div className="service-right">
|
||||
<SharePanel url={shareUrl} label={t('list_edit.share_label')} downloadName={list.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="kontrib" resourceId={list.id} isOwner={list.user_id === user?.id} linkedHubEventId={list.event_id ?? undefined} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kontrib" resourceId={list.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="kontrib" resourceId={list.id} isOwner={list.user_id === user?.id} linkedHubEventId={list.event_id ?? undefined} />
|
||||
<CommentBlock resourceType="kontrib" resourceId={list.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,9 +141,7 @@ export default function BringPublicPage() {
|
|||
</div>
|
||||
|
||||
<div className="service-right">
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kontrib" resourceId={list.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="kontrib" resourceId={list.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Modal } from '@shared/components'
|
||||
import { deleteDefaultSplits, saveDefaultSplits, type MemberOut, type SplitOut } from '../api/kount'
|
||||
import { formatPercentage } from '@shared/utils/formatPercentage'
|
||||
|
||||
interface Props {
|
||||
groupId: string
|
||||
|
|
@ -109,7 +110,10 @@ export default function DefaultSplitModal({ groupId, members, currentSplits, onC
|
|||
isValid ? 'border-success-100 text-success-700' : 'border-danger-100 text-danger-600'
|
||||
}`}>
|
||||
<span>{t('default_split_modal.total_label')}</span>
|
||||
<span>{totalRounded.toFixed(2)} % {isValid ? '✓' : t('default_split_modal.missing_pct', { pct: (100 - totalRounded).toFixed(2) })}</span>
|
||||
<span>
|
||||
{formatPercentage(totalRounded)}{' '}
|
||||
{isValid ? '✓' : t('default_split_modal.missing_pct', { percentage: formatPercentage(100 - totalRounded) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function MemberList({ groupId, members, onUpdate }: Props) {
|
|||
{pending && (
|
||||
<Modal onClose={() => { setPending(null); setDeleteError('') }} compact>
|
||||
<p className="text-gray-700 mb-3">{t('member_list.remove_confirm', { name: pending.name })}</p>
|
||||
{deleteError && <p className="text-sm text-red-500 mb-3">{deleteError}</p>}
|
||||
{deleteError && <p className="text-sm text-danger-600 mb-3">{deleteError}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => { setPending(null); setDeleteError('') }} className="btn-secondary btn-sm">{tc('buttons.cancel')}</button>
|
||||
<button onClick={handleRemove} disabled={deleting} className="btn-danger btn-sm">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Modal } from '@shared/components'
|
||||
import { deleteSplits, saveSplits, type ExpenseOut, type MemberOut } from '../api/kount'
|
||||
import { formatCurrency } from '@services/kount/utils/formatCurrency'
|
||||
import { formatPercentage } from '@shared/utils/formatPercentage'
|
||||
|
||||
interface Props {
|
||||
expense: ExpenseOut
|
||||
|
|
@ -152,7 +153,10 @@ export default function SplitModal({ expense, members, currency, onClose, onSave
|
|||
isValid ? 'border-success-100 text-success-700' : 'border-danger-100 text-danger-600'
|
||||
}`}>
|
||||
<span>{t('split_modal.total_label')}</span>
|
||||
<span>{totalRounded.toFixed(2)} % {isValid ? '✓' : t('split_modal.missing_pct', { pct: (100 - totalRounded).toFixed(2) })}</span>
|
||||
<span>
|
||||
{formatPercentage(totalRounded)}{' '}
|
||||
{isValid ? '✓' : t('split_modal.missing_pct', { percentage: formatPercentage(100 - totalRounded) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useAuth } from '@shared/hooks/useAuth'
|
|||
import { usePageTitle } from '@shared/hooks/usePageTitle'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import CurrencyInput from '@services/kount/components/CurrencyInput'
|
||||
import { formatPercentage } from '@shared/utils/formatPercentage'
|
||||
|
||||
export default function GroupEdit() {
|
||||
const { t } = useTranslation('kount')
|
||||
|
|
@ -75,7 +76,7 @@ export default function GroupEdit() {
|
|||
{group.default_splits.length > 0
|
||||
? group.default_splits.map(s => {
|
||||
const name = group.members.find(m => m.id === s.member_id)?.name ?? '?'
|
||||
return `${name} (${s.share_pct % 1 === 0 ? s.share_pct.toFixed(0) : s.share_pct}%)`
|
||||
return `${name} (${formatPercentage(s.share_pct)})`
|
||||
}).join(' / ')
|
||||
: t('group_edit.default_split_equal')}
|
||||
</span>
|
||||
|
|
@ -131,12 +132,8 @@ export default function GroupEdit() {
|
|||
|
||||
<div className="service-right">
|
||||
<SharePanel url={shareUrl} label={t('group_edit.share_label')} downloadName={group.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="kount" resourceId={group.id} isOwner={group.user_id === user?.id} linkedHubEventId={group.event_id ?? undefined} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kount" resourceId={group.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="kount" resourceId={group.id} isOwner={group.user_id === user?.id} linkedHubEventId={group.event_id ?? undefined} />
|
||||
<CommentBlock resourceType="kount" resourceId={group.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CommentBlock, Modal, ServicePageHeader, Spinner } from '@shared/compone
|
|||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { formatDate } from '@shared/utils/formatDate'
|
||||
import { formatCurrency } from '@services/kount/utils/formatCurrency'
|
||||
import { formatPercentage } from '@shared/utils/formatPercentage'
|
||||
|
||||
export default function GroupPublic() {
|
||||
const { t } = useTranslation('kount')
|
||||
|
|
@ -56,7 +57,7 @@ export default function GroupPublic() {
|
|||
{group.default_splits.length > 0
|
||||
? group.default_splits.map(s => {
|
||||
const name = group.members.find(m => m.id === s.member_id)?.name ?? '?'
|
||||
return `${name} (${s.share_pct % 1 === 0 ? s.share_pct.toFixed(0) : s.share_pct}%)`
|
||||
return `${name} (${formatPercentage(s.share_pct)})`
|
||||
}).join(' / ')
|
||||
: t('group_edit.default_split_equal')}
|
||||
</span>
|
||||
|
|
@ -109,9 +110,7 @@ export default function GroupPublic() {
|
|||
</div>
|
||||
|
||||
<div className="service-right">
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kount" resourceId={group.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="kount" resourceId={group.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export default function EditPollModal({ poll, onClose, onSaved }: Props) {
|
|||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">{t('poll_edit.options_title')}</p>
|
||||
{locked && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-2">
|
||||
<p className="text-xs text-warning-700 bg-warning-50 border border-warning-100 rounded-lg px-3 py-2 mb-2">
|
||||
{t('edit_modal.options_locked')}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,12 @@ export default function OptionsInput({ value, onChange, min = 2, max = 10, disab
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
className="text-gray-300 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100 text-base leading-none shrink-0"
|
||||
aria-label={t('options_input.remove', { option: opt })}
|
||||
className="text-gray-300 hover:text-danger-600 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 shrink-0"
|
||||
>
|
||||
×
|
||||
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<path strokeLinecap="round" d="M2.5 2.5l7 7m0-7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function PollCard({ poll, onDelete }: Props) {
|
|||
{t('poll_card.options_count', { count: poll.option_count })}
|
||||
{' · '}
|
||||
{t('poll_card.votes_count', { count: poll.participant_count })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_card.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_card.status_closed')}</span>}
|
||||
{poll.allow_multiple && <span className="ml-1.5 text-gray-400">· {t('poll_card.multiple_badge')}</span>}
|
||||
</p>
|
||||
{poll.best_option && poll.best_option.vote_count > 0 && (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function PublicPollCard({ publicToken }: PublicCardProps) {
|
|||
<h2 className="card-title">{poll.title}</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('poll_card.votes_count', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1 text-red-400">· {t('poll_card.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1 text-gray-500">· {t('poll_card.status_closed')}</span>}
|
||||
</p>
|
||||
{best && best.vote_count > 0 && poll.participant_votes && (
|
||||
<p className="text-xs text-primary-600 font-medium">★ {best.label}</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { OptionOut, ParticipantVoteOut } from '@services/kwiz/api/kwiz'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
options: OptionOut[]
|
||||
|
|
@ -8,6 +9,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function ResultsBar({ options, participantVotes, bestOptionId, totalParticipants }: Props) {
|
||||
const { t } = useTranslation('kwiz')
|
||||
const maxCount = Math.max(...options.map(o => o.vote_count), 1)
|
||||
|
||||
return (
|
||||
|
|
@ -26,11 +28,15 @@ export default function ResultsBar({ options, participantVotes, bestOptionId, to
|
|||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`text-sm font-medium ${isBest ? 'text-primary-700' : 'text-gray-700'}`}>
|
||||
{isBest && <span className="mr-1">★</span>}
|
||||
{isBest && (
|
||||
<svg aria-label={t('poll_public.best_option')} viewBox="0 0 20 20" className="mr-1 inline h-3.5 w-3.5" fill="currentColor">
|
||||
<path d="M10 1.75l2.36 4.78 5.27.77-3.82 3.72.9 5.25L10 13.79l-4.71 2.48.9-5.25L2.37 7.3l5.27-.77L10 1.75z" />
|
||||
</svg>
|
||||
)}
|
||||
{option.label}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold shrink-0 ${isBest ? 'text-primary-600' : 'text-gray-500'}`}>
|
||||
{option.vote_count} vote{option.vote_count !== 1 ? 's' : ''}{totalParticipants > 0 ? ` · ${pct}%` : ''}
|
||||
{t('poll_public.votes_label', { count: option.vote_count })}{totalParticipants > 0 ? ` · ${pct}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { OptionOut } from '@services/kwiz/api/kwiz'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
options: OptionOut[]
|
||||
|
|
@ -9,6 +10,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function VoteForm({ options, selectedIds, allowMultiple, onChange, disabled }: Props) {
|
||||
const { t } = useTranslation('kwiz')
|
||||
const toggle = (id: string) => {
|
||||
if (disabled) return
|
||||
if (allowMultiple) {
|
||||
|
|
@ -61,7 +63,7 @@ export default function VoteForm({ options, selectedIds, allowMultiple, onChange
|
|||
)
|
||||
})}
|
||||
{!allowMultiple && (
|
||||
<p className="text-xs text-gray-400 text-center">Choisissez une option</p>
|
||||
<p className="text-xs text-gray-400 text-center">{t('poll_public.single_choice_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '../../../i18n/config'
|
||||
import ResultsBar from './ResultsBar'
|
||||
import VoteForm from './VoteForm'
|
||||
|
||||
const options = [
|
||||
{ id: 'one', label: 'Randonnée', position: 0, vote_count: 1 },
|
||||
{ id: 'two', label: 'Pique-nique', position: 1, vote_count: 2 },
|
||||
]
|
||||
|
||||
describe('présentation du vote Kwiz', () => {
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await i18n.changeLanguage('fr')
|
||||
})
|
||||
|
||||
it('traduit l’aide du choix unique en anglais', async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
render(
|
||||
<VoteForm
|
||||
options={options}
|
||||
selectedIds={[]}
|
||||
allowMultiple={false}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Choose one option')).toBeVisible()
|
||||
})
|
||||
|
||||
it('utilise les pluriels i18n et nomme le repère de tête', async () => {
|
||||
await i18n.changeLanguage('fr')
|
||||
render(
|
||||
<ResultsBar
|
||||
options={options}
|
||||
participantVotes={null}
|
||||
bestOptionId="two"
|
||||
totalParticipants={2}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/1 vote · 50%/)).toBeVisible()
|
||||
expect(screen.getByText(/2 votes · 100%/)).toBeVisible()
|
||||
expect(screen.getByLabelText('Option en tête')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -53,7 +53,7 @@ export default function PollEdit() {
|
|||
{t('poll_card.options_count', { count: poll.options.length })}
|
||||
{' · '}
|
||||
{t('poll_card.votes_count', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_edit.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_edit.status_closed')}</span>}
|
||||
{poll.allow_multiple && <span className="ml-1.5 text-gray-400">· {t('poll_edit.multiple_badge')}</span>}
|
||||
</span>
|
||||
}
|
||||
|
|
@ -115,12 +115,8 @@ export default function PollEdit() {
|
|||
|
||||
<div className="service-right">
|
||||
<SharePanel url={shareUrl} label={t('poll_edit.share_label')} downloadName={poll.title} />
|
||||
<div className="card">
|
||||
<CoOwnerPanel resourceType="kwiz" resourceId={poll.id} isOwner={isOwner} linkedHubEventId={poll.event_id ?? undefined} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kwiz" resourceId={poll.id} />
|
||||
</div>
|
||||
<CoOwnerPanel resourceType="kwiz" resourceId={poll.id} isOwner={isOwner} linkedHubEventId={poll.event_id ?? undefined} />
|
||||
<CommentBlock resourceType="kwiz" resourceId={poll.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export default function PollPublic() {
|
|||
meta={
|
||||
<span className="text-xs text-gray-400">
|
||||
{t('poll_public.votes_label', { count: poll.participants.length })}
|
||||
{poll.is_closed && <span className="ml-1.5 text-red-400">· {t('poll_edit.status_closed')}</span>}
|
||||
{poll.is_closed && <span className="ml-1.5 text-gray-500">· {t('poll_edit.status_closed')}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
@ -231,9 +231,7 @@ export default function PollPublic() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<CommentBlock resourceType="kwiz" resourceId={poll.id} />
|
||||
</div>
|
||||
<CommentBlock resourceType="kwiz" resourceId={poll.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
32
frontend/src/shared/components/Badge.tsx
Normal file
32
frontend/src/shared/components/Badge.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
type BadgeVariant = 'service' | 'success' | 'warning' | 'danger' | 'info' | 'meta'
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClass: Record<BadgeVariant, string> = {
|
||||
service: 'badge-service',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
danger: 'badge-danger',
|
||||
info: 'badge-info',
|
||||
meta: 'badge-meta',
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge (VIS-02).
|
||||
* - service : couleur issue de la palette du service actif (ServiceThemeScope requis).
|
||||
* - success/warning/danger/info : token sémantique d'état.
|
||||
* - meta : métadonnée neutre, pas un état.
|
||||
*/
|
||||
export default function Badge({ variant = 'meta', children, className }: BadgeProps) {
|
||||
return (
|
||||
<span className={`${variantClass[variant]} ${className ?? ''}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useAuth } from '@shared/hooks/useAuth'
|
|||
import { useConfirm } from '@shared/hooks/useConfirm'
|
||||
import type { CoOwner } from '@shared/types'
|
||||
import Alert from './Alert'
|
||||
import CollaborationPanel from './CollaborationPanel'
|
||||
|
||||
interface Props {
|
||||
resourceType: string
|
||||
|
|
@ -86,21 +87,20 @@ export default function CoOwnerPanel({ resourceType, resourceId, isOwner, linked
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollaborationPanel title={t('coowner.title')} count={coOwners.length}>
|
||||
{confirmElement}
|
||||
<h3 className="text-sm font-semibold text-gray-800">{t('coowner.title')}</h3>
|
||||
|
||||
{coOwners.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">{t('coowner.empty')}</p>
|
||||
<p className="text-body-sm text-ink-subtle italic">{t('coowner.empty')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{coOwners.map(c => (
|
||||
<li key={c.user_id} className="flex items-center gap-2 bg-gray-50 border border-gray-100 rounded-lg px-4 py-2 min-w-0">
|
||||
<span className="text-sm text-gray-700 truncate flex-1 min-w-0">{c.email}</span>
|
||||
<li key={c.user_id} className="flex items-center gap-2 bg-surface-raised border border-line rounded-control px-3 py-2 min-w-0">
|
||||
<span className="text-body-sm text-ink-muted truncate flex-1 min-w-0">{c.email}</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleRemove(c.user_id, c.email)}
|
||||
className="text-xs text-danger-600 hover:text-danger-700 shrink-0"
|
||||
className="text-meta text-danger-600 hover:text-danger-700 shrink-0 transition-colors duration-micro ease-standard"
|
||||
>
|
||||
{t('coowner.remove')}
|
||||
</button>
|
||||
|
|
@ -113,16 +113,16 @@ export default function CoOwnerPanel({ resourceType, resourceId, isOwner, linked
|
|||
{isOwner && (
|
||||
<>
|
||||
{quickInviteCandidates.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-500">{t('coowner.hub_members')}</p>
|
||||
<div className="space-y-2 pt-3 border-t border-line">
|
||||
<p className="text-meta font-medium text-ink-subtle">{t('coowner.hub_members')}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{quickInviteCandidates.map(m => (
|
||||
<li key={m.user_id} className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-600 truncate flex-1 min-w-0">{m.email}</span>
|
||||
<span className="text-body-sm text-ink-muted truncate flex-1 min-w-0">{m.email}</span>
|
||||
<button
|
||||
onClick={() => handleQuickInvite(m.email)}
|
||||
disabled={quickSending === m.email}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 shrink-0 font-medium"
|
||||
className="btn-quiet btn-sm shrink-0"
|
||||
>
|
||||
{quickSending === m.email ? '…' : t('coowner.quick_invite')}
|
||||
</button>
|
||||
|
|
@ -132,8 +132,8 @@ export default function CoOwnerPanel({ resourceType, resourceId, isOwner, linked
|
|||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleInvite} className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-700">{t('coowner.invite_form_title')}</p>
|
||||
<form onSubmit={handleInvite} className="space-y-2 pt-3 border-t border-line">
|
||||
<p className="text-body-sm font-medium text-ink-soft">{t('coowner.invite_form_title')}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -151,12 +151,12 @@ export default function CoOwnerPanel({ resourceType, resourceId, isOwner, linked
|
|||
{sending ? t('coowner.invite_sending') : t('coowner.invite_submit')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{t('coowner.invite_hint')}</p>
|
||||
<p className="text-meta text-ink-subtle">{t('coowner.invite_hint')}</p>
|
||||
<Alert type="success" message={success} autoHide />
|
||||
<Alert type="error" message={error} />
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollaborationPanel>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
32
frontend/src/shared/components/CollaborationPanel.tsx
Normal file
32
frontend/src/shared/components/CollaborationPanel.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
title: ReactNode
|
||||
/** Effectif affiché à côté du titre (commentaires, co-gestionnaires). */
|
||||
count?: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface commune du rail de collaboration (VIS-03).
|
||||
*
|
||||
* Partage, co-propriété et commentaires forment une famille visuelle secondaire :
|
||||
* même surface `card-collaboration`, même rôle de titre, même rythme interne.
|
||||
* Le rail assiste l'objet de la page, il ne rivalise pas avec lui — c'est
|
||||
* pourquoi il ne porte ni la surface blanche ni l'élévation des cartes de
|
||||
* contenu. Les pages n'enveloppent plus ces blocs à la main : chaque composant
|
||||
* du rail apporte sa propre surface (cf. `docs/DESIGN_SYSTEM.md`).
|
||||
*/
|
||||
export default function CollaborationPanel({ title, count, children }: Props) {
|
||||
return (
|
||||
<section className="card-collaboration space-y-3">
|
||||
<h3 className="card-collaboration-title">
|
||||
<span>{title}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-ink-subtle font-normal">({count})</span>
|
||||
)}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useConfirm } from '@shared/hooks/useConfirm'
|
|||
import type { Comment } from '@shared/types'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import Alert from './Alert'
|
||||
import CollaborationPanel from './CollaborationPanel'
|
||||
|
||||
interface Props {
|
||||
resourceType: string
|
||||
|
|
@ -60,51 +61,42 @@ export default function CommentBlock({ resourceType, resourceId }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollaborationPanel title={t('comments.title')} count={comments.length}>
|
||||
{confirmElement}
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{t('comments.title')} {comments.length > 0 && <span className="text-gray-400 font-normal">({comments.length})</span>}
|
||||
</h3>
|
||||
|
||||
{comments.length === 0 && (
|
||||
<p className="text-xs text-gray-400 italic">{t('comments.empty')}</p>
|
||||
<p className="text-body-sm text-ink-subtle italic">{t('comments.empty')}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="group space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-700">{c.author_name}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="group/clock relative">
|
||||
<svg className="w-3 h-3 text-gray-300 hover:text-gray-500 transition-colors cursor-default" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" d="M12 7v5l3 3" />
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute bottom-full right-0 mb-1.5 whitespace-nowrap text-xs bg-gray-700 text-white px-2 py-1 rounded opacity-0 group-hover/clock:opacity-100 transition-opacity z-10">
|
||||
{formatDate(c.created_at, 'datetime')}
|
||||
</span>
|
||||
</div>
|
||||
{user && c.user_id === user.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(c)}
|
||||
title={t('confirm.delete_label')}
|
||||
className="text-gray-300 hover:text-danger-600 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-meta font-semibold text-ink-soft">{c.author_name}</span>
|
||||
{/* La date reste lisible sans survol : une info portée par le seul
|
||||
hover n'existe pas au doigt (VIS-03). */}
|
||||
<span className="text-meta text-ink-subtle">{formatDate(c.created_at, 'datetime')}</span>
|
||||
{user && c.user_id === user.id && (
|
||||
<button
|
||||
onClick={() => handleDelete(c)}
|
||||
title={t('confirm.delete_label')}
|
||||
aria-label={t('confirm.delete_label')}
|
||||
className="ml-auto text-ink-faint hover:text-danger-600 focus-visible:text-danger-600 transition-colors duration-micro ease-standard text-base leading-none shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-snug">{c.content}</p>
|
||||
<div className="bg-surface-raised border border-line rounded-control px-3 py-2">
|
||||
<p className="text-body-sm text-ink-muted whitespace-pre-wrap leading-snug">{c.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-2 pt-3 border-t border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-600">{t('comments.add_title')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-2 pt-3 border-t border-line">
|
||||
<p className="text-meta font-medium text-ink-subtle">{t('comments.add_title')}</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('comments.placeholder_name')}
|
||||
|
|
@ -132,6 +124,6 @@ export default function CommentBlock({ resourceType, resourceId }: Props) {
|
|||
{submitting ? t('comments.sending') : t('comments.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</CollaborationPanel>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function ConfirmModal({
|
|||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<Modal onClose={onCancel} compact>
|
||||
<p className="text-gray-700 mb-6">{message}</p>
|
||||
<p className="text-ink-muted mb-6">{message}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={onCancel} className="btn-secondary btn-sm">{cancelLabel ?? t('buttons.cancel')}</button>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { shellContainerClass } from '@shared/config/shellVariant'
|
||||
|
||||
const KEY = 'cookie_notice_dismissed'
|
||||
|
||||
/**
|
||||
* Bandeau de confidentialité (VIS-03).
|
||||
*
|
||||
* C'est le troisième élément de chrome, après la Navbar et le Footer : il suit
|
||||
* donc le même axe de lecture que son contexte au lieu de porter sa propre
|
||||
* largeur. Le fond inverse est un choix de chrome, pas un état sémantique — il
|
||||
* ne remplit rien d'autre que le bandeau lui-même.
|
||||
*/
|
||||
export default function CookieBanner() {
|
||||
const { t } = useTranslation('common')
|
||||
const { user } = useAuth()
|
||||
const location = useLocation()
|
||||
const [visible, setVisible] = useState(() => !localStorage.getItem(KEY))
|
||||
|
||||
if (!visible) return null
|
||||
|
|
@ -16,15 +28,15 @@ export default function CookieBanner() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 bg-gray-900 text-white px-4 py-3">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-xs text-gray-300 leading-relaxed">
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 bg-surface-inverse text-ink-inverse py-2 sm:py-3">
|
||||
<div className={`${shellContainerClass(location.pathname, !!user)} flex items-center justify-between gap-3 sm:gap-4`}>
|
||||
<p className="min-w-0 flex-1 text-meta text-ink-inverse/80 leading-snug sm:leading-relaxed">
|
||||
{t('cookie.text')}{' '}
|
||||
<Link to="/privacy" className="underline hover:text-white">{t('footer.privacy')}</Link>
|
||||
<Link to="/privacy" className="underline underline-offset-2 hover:text-ink-inverse transition-colors duration-micro ease-standard">{t('footer.privacy')}</Link>
|
||||
</p>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-xs bg-white text-gray-900 font-medium px-4 py-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="shrink-0 min-h-11 sm:min-h-0 text-body-sm font-medium bg-surface-raised text-ink px-3 py-2 sm:px-4 sm:py-1.5 rounded-control hover:bg-surface-muted transition-colors duration-micro ease-standard"
|
||||
>
|
||||
{t('cookie.accept')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function DateInput({ value, onChange, required, className = '' }:
|
|||
required={required}
|
||||
className={`input sm:pr-9 ${className}`}
|
||||
/>
|
||||
<span className="hidden sm:flex absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
<span className="hidden sm:flex absolute right-3 top-1/2 -translate-y-1/2 text-ink-faint pointer-events-none">
|
||||
<CalendarIcon />
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,17 @@ interface Props {
|
|||
action?: { label: string; to: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôle `empty` du système (VIS-01, implémenté en VIS-04) : l'absence de contenu
|
||||
* est une zone en creux bordée de pointillés, pas un texte perdu au centre de la
|
||||
* page. Le message reste porté par un rôle de texte — `ink-faint` ne tient pas
|
||||
* le contraste AA et ne peut donc pas porter l'information.
|
||||
*/
|
||||
export default function EmptyState({ message, sub, action }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400 gap-3">
|
||||
<p className="text-lg text-gray-500">{message}</p>
|
||||
{sub && <p className="text-sm">{sub}</p>}
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-card border border-dashed border-line-strong bg-surface-sunken px-gutter py-12 text-center">
|
||||
<p className="text-body font-medium text-ink-muted">{message}</p>
|
||||
{sub && <p className="text-body-sm text-ink-subtle">{sub}</p>}
|
||||
{action && (
|
||||
<Link to={action.to} className="btn-primary mt-2">
|
||||
{action.label}
|
||||
|
|
|
|||
50
frontend/src/shared/components/Field.tsx
Normal file
50
frontend/src/shared/components/Field.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface FieldProps {
|
||||
label?: string
|
||||
help?: string
|
||||
error?: string
|
||||
success?: string
|
||||
required?: boolean
|
||||
count?: { value: number; max: number }
|
||||
id?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition de champ de formulaire (VIS-02).
|
||||
* Enveloppe un contrôle avec libellé, aide, erreur/succès et compteur.
|
||||
* Le placeholder reste un exemple — le libellé est la source d'identité du champ.
|
||||
*/
|
||||
export default function Field({ label, help, error, success, required, count, id, className, children }: FieldProps) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className ?? ''}`}>
|
||||
{(label || count) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-body-sm font-medium text-ink-muted">
|
||||
{label}
|
||||
{required && <span className="text-danger-600 ml-0.5" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{count && (
|
||||
<span className={`text-meta tabular-nums shrink-0 ${count.value > count.max ? 'text-danger-600' : 'text-ink-faint'}`}>
|
||||
{count.value}/{count.max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{help && !error && !success && (
|
||||
<p className="text-meta text-ink-faint">{help}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-meta text-danger-600" role="alert">{error}</p>
|
||||
)}
|
||||
{success && !error && (
|
||||
<p className="text-meta text-success-700">{success}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { serviceMetadata } from '@shared/config/serviceMetadata'
|
||||
import { shellContainerClass } from '@shared/config/shellVariant'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const services = [
|
||||
{ to: '/hub', label: 'Hub' },
|
||||
{ to: '/kdo', label: 'Kdo' },
|
||||
{ to: '/kal', label: 'Kal' },
|
||||
{ to: '/kwiz', label: 'Kwiz' },
|
||||
{ to: '/kontrib', label: 'Kontrib' },
|
||||
{ to: '/kount', label: 'Kount' },
|
||||
]
|
||||
const { user } = useAuth()
|
||||
const location = useLocation()
|
||||
// Même axe de lecture que la Navbar et le contenu du contexte courant.
|
||||
const container = shellContainerClass(location.pathname, !!user)
|
||||
|
||||
const resources = [
|
||||
{ to: '/legal', label: t('footer.legal') },
|
||||
|
|
@ -20,35 +18,35 @@ export default function Footer() {
|
|||
]
|
||||
|
||||
return (
|
||||
<footer className="border-t border-gray-100 bg-white mt-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 text-sm text-gray-400">
|
||||
<footer className="border-t border-line bg-surface-raised mt-auto">
|
||||
<div className={`${container} py-section text-body-sm text-ink-muted`}>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8">
|
||||
<div className="max-w-xs">
|
||||
<span className="font-semibold text-gray-500">kankwa</span>
|
||||
<p className="mt-2 leading-relaxed">{t('footer.tagline')}</p>
|
||||
<span className="font-display font-semibold text-ink">kankwa</span>
|
||||
<p className="mt-2 text-ink-subtle">{t('footer.tagline')}</p>
|
||||
</div>
|
||||
<nav>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3">{t('footer.services')}</p>
|
||||
<p className="text-meta font-semibold uppercase tracking-widest text-ink-subtle mb-3">{t('footer.services')}</p>
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2 sm:flex-col sm:gap-y-2">
|
||||
{services.map(l => (
|
||||
<li key={l.to}>
|
||||
<Link to={l.to} className="hover:text-gray-600 transition-colors">{l.label}</Link>
|
||||
{serviceMetadata.map(service => (
|
||||
<li key={service.path}>
|
||||
<Link to={service.path} className="hover:text-ink-accent transition-colors duration-micro ease-standard">{service.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3">{t('footer.resources')}</p>
|
||||
<p className="text-meta font-semibold uppercase tracking-widest text-ink-subtle mb-3">{t('footer.resources')}</p>
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2 sm:flex-col sm:gap-y-2">
|
||||
{resources.map(l => (
|
||||
<li key={l.to}>
|
||||
<Link to={l.to} className="hover:text-gray-600 transition-colors">{l.label}</Link>
|
||||
<Link to={l.to} className="hover:text-ink-accent transition-colors duration-micro ease-standard">{l.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<p className="mt-8 pt-6 border-t border-gray-100">© {new Date().getFullYear()} kankwa</p>
|
||||
<p className="mt-8 pt-6 border-t border-line-subtle text-meta text-ink-subtle">© {new Date().getFullYear()} kankwa</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ interface Props {
|
|||
|
||||
export default function MetaChip({ icon, children }: Props) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 bg-gray-100 text-gray-600 rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
<span className="inline-flex items-center gap-1.5 bg-surface-muted text-ink-muted rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useId, useRef } from 'react'
|
|||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getServiceTheme, type ServiceId } from '@shared/config/serviceThemes'
|
||||
import { KankwaLogo } from './KankwaLogo'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
|
|
@ -10,9 +11,16 @@ interface Props {
|
|||
disableBackdropClose?: boolean
|
||||
compact?: boolean
|
||||
serviceId?: ServiceId
|
||||
/**
|
||||
* Rappelle la marque au-dessus du titre. Réservé aux modales qui occupent
|
||||
* tout l'écran sur mobile en sortant du produit — l'authentification —, où la
|
||||
* surface blanche efface sinon tout contexte (VIS-AUD-08). Le rappel
|
||||
* disparaît dès que la modale redevient une carte posée sur la page.
|
||||
*/
|
||||
brand?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({ onClose, children, title, disableBackdropClose, compact = false, serviceId }: Props) {
|
||||
export default function Modal({ onClose, children, title, disableBackdropClose, compact = false, serviceId, brand = false }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const serviceTheme = serviceId ? getServiceTheme(serviceId) : undefined
|
||||
const backdropRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -67,8 +75,8 @@ export default function Modal({ onClose, children, title, disableBackdropClose,
|
|||
? 'fixed inset-0 z-50 overflow-y-auto bg-black/40 flex items-center justify-center p-4'
|
||||
: 'fixed inset-0 z-50 overflow-y-auto flex items-start sm:bg-black/40 sm:items-center sm:justify-center sm:p-4'
|
||||
const contentCls = compact
|
||||
? 'relative bg-white rounded-2xl p-6 w-full max-w-sm shadow-xl my-auto'
|
||||
: 'relative bg-white w-full min-h-screen p-4 pb-10 sm:min-h-0 sm:rounded-2xl sm:p-6 sm:max-w-sm sm:shadow-xl sm:my-auto'
|
||||
? 'relative bg-surface-raised rounded-card p-6 w-full max-w-sm shadow-floating my-auto'
|
||||
: 'relative bg-surface-raised w-full min-h-screen p-4 pb-10 sm:min-h-0 sm:rounded-card sm:p-6 sm:max-w-sm sm:shadow-floating sm:my-auto'
|
||||
|
||||
return createPortal(
|
||||
<div ref={backdropRef} className={backdropCls} onMouseDown={handleBackdropMouseDown} onClick={handleBackdropClick}>
|
||||
|
|
@ -84,10 +92,16 @@ export default function Modal({ onClose, children, title, disableBackdropClose,
|
|||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{serviceTheme && <div className="absolute inset-x-0 top-0 h-1.5 bg-primary-600 sm:rounded-t-2xl" aria-hidden="true" />}
|
||||
{serviceTheme && <div className="absolute inset-x-0 top-0 h-1.5 bg-primary-600 sm:rounded-t-card" aria-hidden="true" />}
|
||||
{brand && !compact && (
|
||||
<div data-brand-recall className="flex items-center gap-2 mb-4 text-ink-accent sm:hidden" aria-hidden="true">
|
||||
<KankwaLogo className="h-7 w-7 shrink-0" />
|
||||
<span className="font-display font-bold tracking-tight">kankwa</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
{title ? <h2 id={titleId} className="flex items-center gap-2 text-lg font-semibold text-gray-900 pr-2">{serviceTheme && <span className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary-600" aria-hidden="true" />}{title}</h2> : <span />}
|
||||
<button onClick={onClose} className="shrink-0 text-gray-400 hover:text-gray-600 p-1 -mt-1 -mr-1 rounded-lg hover:bg-gray-100 transition-colors" aria-label={t('buttons.close')}>
|
||||
{title ? <h2 id={titleId} className="flex items-center gap-2 text-lg font-semibold text-ink pr-2">{serviceTheme && <span className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary-600" aria-hidden="true" />}{title}</h2> : <span />}
|
||||
<button onClick={onClose} className="shrink-0 text-ink-faint hover:text-ink-muted p-1 -mt-1 -mr-1 rounded-control hover:bg-surface-muted transition-colors duration-micro" aria-label={t('buttons.close')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className="w-5 h-5">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,19 @@ import { setLang as persistLang } from '@shared/api/auth'
|
|||
import { KankwaLogo } from './KankwaLogo'
|
||||
import { getLocale, LOCALES, switchLang, type Lang } from '../../i18n/config'
|
||||
import { getLocalizedLandingPath } from '@shared/config/landings'
|
||||
import { shellContainerClass } from '@shared/config/shellVariant'
|
||||
|
||||
const AUTH_ROUTES = ['/login', '/register']
|
||||
|
||||
function avatarColor(_email: string): string {
|
||||
return '#1A1F2E'
|
||||
}
|
||||
/**
|
||||
* Onglet de service (VIS-03). L'état actif porte l'accent du service — un aplat
|
||||
* `surface-accent` reste admis ici parce que ses frères non sélectionnés sont
|
||||
* visibles : il signifie « celui-ci », jamais « attention »
|
||||
* (cf. règle du remplissage, `docs/DESIGN_SYSTEM.md`).
|
||||
*/
|
||||
const SERVICE_TAB = 'flex shrink-0 items-center gap-1.5 px-3 py-1.5 rounded-control text-body-sm font-medium whitespace-nowrap transition-colors duration-micro ease-standard'
|
||||
const SERVICE_TAB_ACTIVE = 'bg-surface-accent text-ink-accent ring-1 ring-inset ring-line-accent'
|
||||
const SERVICE_TAB_IDLE = 'text-ink-muted hover:text-ink hover:bg-surface-muted'
|
||||
|
||||
function initials(email: string): string {
|
||||
return email.split('@')[0].slice(0, 2).toUpperCase()
|
||||
|
|
@ -55,7 +62,7 @@ function LangSwitcher() {
|
|||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm px-2 py-1.5 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1 text-ink-muted hover:text-ink text-body-sm px-2 py-1.5 rounded-control hover:bg-surface-muted transition-colors duration-micro ease-standard"
|
||||
aria-label={t('lang.switch_label')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
|
||||
|
|
@ -63,20 +70,20 @@ function LangSwitcher() {
|
|||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium uppercase">{currentLang}</span>
|
||||
<span className="text-meta font-medium uppercase">{currentLang}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-9 min-w-36 bg-white border border-gray-200 rounded-xl shadow-lg py-1 z-50">
|
||||
<div className="absolute right-0 top-9 min-w-36 bg-surface-raised border border-line rounded-panel shadow-floating py-1 z-50">
|
||||
{LOCALES.map(locale => (
|
||||
<button
|
||||
key={locale.code}
|
||||
onClick={() => handleSwitch(locale.code)}
|
||||
className={[
|
||||
'w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2',
|
||||
'w-full text-left px-3 py-2 text-body-sm transition-colors duration-micro ease-standard flex items-center gap-2',
|
||||
currentLang === locale.code
|
||||
? 'text-primary-600 font-medium bg-primary-50'
|
||||
: 'text-gray-700 hover:bg-gray-50',
|
||||
? 'text-ink-accent font-medium bg-surface-accent'
|
||||
: 'text-ink-muted hover:bg-surface-muted hover:text-ink',
|
||||
].join(' ')}
|
||||
>
|
||||
{locale.flag && <span aria-hidden="true">{locale.flag}</span>}
|
||||
|
|
@ -104,7 +111,6 @@ function UserMenu({ email, onLogout }: { email: string; onLogout: () => void })
|
|||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const bg = avatarColor(email)
|
||||
const ini = initials(email)
|
||||
|
||||
return (
|
||||
|
|
@ -115,28 +121,27 @@ function UserMenu({ email, onLogout }: { email: string; onLogout: () => void })
|
|||
aria-label={t('nav.user_menu_label')}
|
||||
>
|
||||
<span
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold select-none shrink-0"
|
||||
style={{ backgroundColor: bg }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center bg-surface-inverse text-ink-inverse text-meta font-semibold select-none shrink-0"
|
||||
>
|
||||
{ini}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-10 w-52 bg-white border border-gray-200 rounded-xl shadow-lg py-1.5 z-50">
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<p className="text-xs text-gray-400 truncate">{email}</p>
|
||||
<div className="absolute right-0 top-10 w-52 bg-surface-raised border border-line rounded-panel shadow-floating py-1.5 z-50">
|
||||
<div className="px-3 py-2 border-b border-line-subtle">
|
||||
<p className="text-meta text-ink-subtle truncate">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setOpen(false); navigate('/compte') }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-primary-600 transition-colors"
|
||||
className="w-full text-left px-3 py-2 text-body-sm text-ink-muted hover:bg-surface-muted hover:text-ink transition-colors duration-micro ease-standard"
|
||||
>
|
||||
{t('nav.account')}
|
||||
</button>
|
||||
<div className="border-t border-gray-100 mt-1 pt-1">
|
||||
<div className="border-t border-line-subtle mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => { setOpen(false); onLogout() }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 hover:text-danger-600 transition-colors"
|
||||
className="w-full text-left px-3 py-2 text-body-sm text-ink-muted hover:bg-surface-muted hover:text-danger-600 transition-colors duration-micro ease-standard"
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
|
|
@ -154,100 +159,107 @@ export default function Navbar() {
|
|||
const location = useLocation()
|
||||
|
||||
const handleLogout = () => { logout(); navigate('/') }
|
||||
// Navbar, contenu et Footer partagent l'axe de lecture de leur contexte. Une
|
||||
// racine de service rend la vitrine hors session et le tableau de bord en
|
||||
// session : l'état d'authentification fait partie du contexte.
|
||||
const container = shellContainerClass(location.pathname, !!user)
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||
<nav className="bg-surface-raised border-b border-line sticky top-0 z-20">
|
||||
|
||||
{/* Ligne 1 — brand + lang + compte */}
|
||||
<div className="w-full px-6 h-12 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 text-primary-600 font-bold text-lg tracking-tight">
|
||||
<div className={`${container} h-12 flex items-center justify-between gap-2`}>
|
||||
<Link to="/" aria-label="kankwa" className="flex min-w-0 items-center gap-2 text-ink-accent font-display font-bold text-lg tracking-tight">
|
||||
<KankwaLogo className="h-8 w-8 shrink-0" />
|
||||
kankwa
|
||||
<span aria-hidden="true" className="hidden min-[360px]:inline">kankwa</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<LangSwitcher />
|
||||
{user ? (
|
||||
<UserMenu email={user.email} onLogout={handleLogout} />
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
state={AUTH_ROUTES.includes(location.pathname) ? location.state : { background: location }}
|
||||
className="text-sm text-gray-600 hover:text-primary-600"
|
||||
className="text-body-sm text-ink-muted hover:text-ink-accent transition-colors duration-micro ease-standard"
|
||||
>{t('nav.login')}</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
state={AUTH_ROUTES.includes(location.pathname) ? location.state : { background: location }}
|
||||
className="text-sm bg-primary-600 text-white px-3 py-1.5 rounded-lg hover:bg-primary-700"
|
||||
className="btn-primary btn-sm"
|
||||
>{t('nav.register')}</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 2 — services */}
|
||||
<div className="w-full px-4 h-11 flex items-center gap-1">
|
||||
{/* Ligne 2 — services. Les noms restent lisibles à toutes les largeurs :
|
||||
la rangée défile horizontalement plutôt que de réduire les six services
|
||||
à des icônes ambiguës (VIS-AUD-06). La page, elle, ne défile pas. */}
|
||||
<div className="border-t border-line-subtle overflow-x-auto">
|
||||
<div className={`${container} h-11 flex items-center gap-1`}>
|
||||
|
||||
{/* Hub (hub: true) */}
|
||||
{serviceMetadata.filter(s => s.hub).map(s => {
|
||||
const isActive = s.active && location.pathname.startsWith(s.path)
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={s.path}
|
||||
style={isActive ? getServiceTheme(s.id).style : undefined}
|
||||
data-service-nav={s.id}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-primary-50 text-primary-800 ring-1 ring-inset ring-primary-100' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ServiceIcon serviceId={s.id} />
|
||||
<span className="hidden sm:inline">{s.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* Hub (hub: true) */}
|
||||
{serviceMetadata.filter(s => s.hub).map(s => {
|
||||
const isActive = s.active && location.pathname.startsWith(s.path)
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={s.path}
|
||||
style={isActive ? getServiceTheme(s.id).style : undefined}
|
||||
data-service-nav={s.id}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`${SERVICE_TAB} ${isActive ? SERVICE_TAB_ACTIVE : SERVICE_TAB_IDLE}`}
|
||||
>
|
||||
<ServiceIcon serviceId={s.id} />
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="w-px h-5 bg-gray-200 mx-1 shrink-0" />
|
||||
{/* Séparateur */}
|
||||
<div className="w-px h-5 bg-line mx-1 shrink-0" />
|
||||
|
||||
{/* Services principaux */}
|
||||
{serviceMetadata.filter(s => !s.hub).map(s => {
|
||||
const isActive = s.active && location.pathname.startsWith(s.path)
|
||||
return (
|
||||
<div key={s.id} className="relative group">
|
||||
{s.active ? (
|
||||
{/* Services principaux */}
|
||||
{serviceMetadata.filter(s => !s.hub).map(s => {
|
||||
const isActive = s.active && location.pathname.startsWith(s.path)
|
||||
return (
|
||||
s.active ? (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={s.path}
|
||||
style={isActive ? getServiceTheme(s.id).style : undefined}
|
||||
data-service-nav={s.id}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-primary-50 text-primary-800 ring-1 ring-inset ring-primary-100' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
|
||||
}`}
|
||||
className={`${SERVICE_TAB} ${isActive ? SERVICE_TAB_ACTIVE : SERVICE_TAB_IDLE}`}
|
||||
>
|
||||
<ServiceIcon serviceId={s.id} />
|
||||
<span className="hidden sm:inline">{s.label}</span>
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-gray-300 cursor-not-allowed"
|
||||
>
|
||||
<ServiceIcon serviceId={s.id} />
|
||||
<span className="hidden sm:inline">{s.label}</span>
|
||||
<span className="text-[10px] bg-gray-100 text-gray-400 px-1 py-0.5 rounded font-normal leading-none">{t('nav.coming_soon_badge')}</span>
|
||||
</button>
|
||||
<div className="absolute left-0 top-full mt-1 bg-gray-800 text-white text-xs rounded-md px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-30">
|
||||
{t('nav.coming_soon_tooltip')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
/* L'explication reste sur le bouton lui-même : la rangée défile
|
||||
horizontalement, donc une bulle en surplomb serait rognée, et
|
||||
un contenu accessible au seul survol n'existe pas au doigt. */
|
||||
<button
|
||||
key={s.id}
|
||||
disabled
|
||||
title={t('nav.coming_soon_tooltip')}
|
||||
className={`${SERVICE_TAB} text-ink-faint cursor-not-allowed`}
|
||||
>
|
||||
<ServiceIcon serviceId={s.id} />
|
||||
<span>{s.label}</span>
|
||||
<span className="badge-meta font-normal leading-none">{t('nav.coming_soon_badge')}</span>
|
||||
</button>
|
||||
)
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Gouttière de fin de défilement : sans elle le dernier onglet colle
|
||||
au bord une fois la rangée défilée. */}
|
||||
<div aria-hidden="true" className="w-gutter shrink-0" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -3,20 +3,30 @@ interface Props {
|
|||
max: number
|
||||
label?: string
|
||||
className?: string
|
||||
/**
|
||||
* `progress` : atteindre le maximum est l'objectif (succès à 100 %).
|
||||
* `quota` : le maximum est une limite (alerte à 100 %). Même barre, sens
|
||||
* inverse — c'est la seule chose qui change entre une progression et un
|
||||
* quota, elle n'a donc pas à donner lieu à une seconde barre (VIS-03).
|
||||
*/
|
||||
tone?: 'progress' | 'quota'
|
||||
}
|
||||
|
||||
export default function ProgressBar({ value, max, label, className = '' }: Props) {
|
||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
||||
const color = pct === 100 ? 'bg-success-600' : pct >= 50 ? 'bg-primary-500' : 'bg-primary-300'
|
||||
export default function ProgressBar({ value, max, label, className = '', tone = 'progress' }: Props) {
|
||||
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
|
||||
const full = pct === 100
|
||||
const color = tone === 'quota'
|
||||
? (full ? 'bg-danger-600' : 'bg-primary-500')
|
||||
: (full ? 'bg-success-600' : pct >= 50 ? 'bg-primary-500' : 'bg-primary-300')
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center justify-between text-body-sm text-ink-subtle">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium tabular-nums">{value} / {max}</span>
|
||||
<span className={`font-medium tabular-nums ${tone === 'quota' && full ? 'text-danger-600' : ''}`}>{value} / {max}</span>
|
||||
</div>
|
||||
<div className="h-2 sm:h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-300 ${color}`} style={{ width: `${pct}%` }} />
|
||||
<div className="h-2 sm:h-1.5 bg-surface-muted rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-panel ease-standard ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function ResourceCard({ onDelete, children, className }: Props) {
|
|||
<div className={`card-item relative flex flex-col gap-2 ${className ?? ''}`}>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="delete-btn-hover absolute top-2 right-2 p-1 text-gray-400 hover:text-danger-600 rounded leading-none text-sm"
|
||||
className="delete-btn-hover absolute top-2 right-2 p-1 text-ink-faint hover:text-danger-600 rounded-control leading-none text-sm"
|
||||
aria-label={t('buttons.delete')}
|
||||
>
|
||||
✕
|
||||
|
|
|
|||
|
|
@ -86,19 +86,19 @@ export default function ServiceDashboard<T extends { id: string }>({
|
|||
<main className="page">
|
||||
{confirmElement}
|
||||
<div className="flex items-center justify-between gap-4 mb-6 pt-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">{title}</h1>
|
||||
<h1 className="text-page-title text-ink">{title}</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary btn-sm shrink-0">
|
||||
{createLabel ?? t('dashboard.new')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-danger-600 mb-4">{error}</p>}
|
||||
{error && <p className="alert-error mb-4">{error}</p>}
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
message={emptyMessage}
|
||||
sub={emptySub ?? t('dashboard.empty_default_sub')}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-cards">
|
||||
{items.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
{renderCard(item, handleDelete, onEdit, getUrl ? getUrl(item) : undefined)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useEffect, useRef } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { serviceMetadata } from '@shared/config/serviceMetadata'
|
||||
import type { ServiceId } from '@shared/config/serviceThemes'
|
||||
import ServiceIcon from './ServiceIcon'
|
||||
import { useServiceThemeId } from './ServiceThemeScope'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
|
|
@ -10,15 +14,28 @@ interface Props {
|
|||
description?: string
|
||||
backPath?: string
|
||||
onEdit?: () => void
|
||||
/** Service affiché dans le fil de contexte ; par défaut celui du `ServiceThemeScope` courant. */
|
||||
serviceId?: ServiceId
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const NAVBAR_HEIGHT = 92 // px — h-12 (48) + h-11 (44)
|
||||
const HEADER_GAP = 16 // px — mb-4, espace entre header et colonnes (identique en scrollé et non-scrollé)
|
||||
|
||||
export default function ServicePageHeader({ title, titleIcon, meta, description, backPath, onEdit, children }: Props) {
|
||||
/**
|
||||
* Masthead de service (VIS-03).
|
||||
*
|
||||
* Composition arrêtée par `docs/DESIGN_SYSTEM.md` : retour, fil de contexte
|
||||
* (icône + nom du service), titre de la ressource au rôle `page-title`,
|
||||
* métadonnée, une action principale, et un accent de service qui **marque**
|
||||
* sans remplir. Les gouttières négatives reprennent le token `gutter` du parent
|
||||
* plutôt qu'une valeur fixe : c'est la cause du débordement mobile VIS-AUD-05.
|
||||
*/
|
||||
export default function ServicePageHeader({ title, titleIcon, meta, description, backPath, onEdit, serviceId, children }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const scopedServiceId = useServiceThemeId()
|
||||
const service = serviceMetadata.find(s => s.id === (serviceId ?? scopedServiceId))
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
|
@ -38,26 +55,32 @@ export default function ServicePageHeader({ title, titleIcon, meta, description,
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="sticky z-20 bg-white border-b border-gray-100 -mx-6 px-6 pt-4 pb-3 mb-4"
|
||||
className="sticky z-20 bg-surface-raised border-b border-line -mx-gutter px-gutter pt-4 pb-3 mb-4"
|
||||
style={{ top: NAVBAR_HEIGHT }}
|
||||
>
|
||||
{backPath && (
|
||||
<Link to={backPath} className="text-xs text-gray-400 hover:text-gray-600 mb-2 inline-block">
|
||||
<Link to={backPath} className="text-meta text-ink-subtle hover:text-ink mb-1 inline-block transition-colors duration-micro ease-standard">
|
||||
{t('buttons.back')}
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{service && (
|
||||
<p className="flex items-center gap-1.5 text-meta font-medium text-ink-accent">
|
||||
<ServiceIcon serviceId={service.id} className="w-4 h-4 shrink-0" />
|
||||
{service.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{titleIcon && <span className="text-2xl leading-none">{titleIcon}</span>}
|
||||
<h1 className="text-xl font-bold text-gray-900 leading-tight">{title}</h1>
|
||||
<h1 className="text-page-title text-ink min-w-0">{title}</h1>
|
||||
</div>
|
||||
{meta && (
|
||||
<div className="flex items-center gap-2 mt-0.5 text-sm text-gray-500 flex-wrap">
|
||||
<div className="flex items-center gap-2 mt-1 text-body-sm text-ink-subtle flex-wrap">
|
||||
{meta}
|
||||
</div>
|
||||
)}
|
||||
{description && <p className="mt-1 text-sm text-gray-500 max-w-xl">{description}</p>}
|
||||
{description && <p className="mt-1 text-body-sm text-ink-muted max-w-xl">{description}</p>}
|
||||
{children && <div className="mt-2">{children}</div>}
|
||||
</div>
|
||||
{onEdit && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { getServiceTheme, type ServiceId } from '@shared/config/serviceThemes'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -6,6 +6,17 @@ interface Props {
|
|||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Service couvrant l'arbre courant, `null` hors d'un espace métier (compte,
|
||||
* pages système). Le masthead s'en sert pour nommer son contexte sans que chaque
|
||||
* page ait à répéter son propre identifiant (VIS-03).
|
||||
*/
|
||||
const ServiceThemeContext = createContext<ServiceId | null>(null)
|
||||
|
||||
export function useServiceThemeId(): ServiceId | null {
|
||||
return useContext(ServiceThemeContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagates a service palette through existing primary-* utility classes.
|
||||
* display: contents keeps page layout ownership in the routed component.
|
||||
|
|
@ -13,8 +24,10 @@ interface Props {
|
|||
*/
|
||||
export default function ServiceThemeScope({ serviceId, children }: Props) {
|
||||
return (
|
||||
<div className="contents" style={getServiceTheme(serviceId).style} data-service-theme-scope={serviceId}>
|
||||
{children}
|
||||
</div>
|
||||
<ServiceThemeContext.Provider value={serviceId}>
|
||||
<div className="contents" style={getServiceTheme(serviceId).style} data-service-theme-scope={serviceId}>
|
||||
{children}
|
||||
</div>
|
||||
</ServiceThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useRef } from 'react'
|
|||
import QRCode from 'react-qr-code'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CopyButton from './CopyButton'
|
||||
import CollaborationPanel from './CollaborationPanel'
|
||||
import { downloadQRCode } from '@shared/utils/downloadQR'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,6 +11,13 @@ interface Props {
|
|||
downloadName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Partage d'une ressource (VIS-03).
|
||||
*
|
||||
* Le lien est l'action dominante du rail : il occupe la pleine largeur et porte
|
||||
* la seule action colorée du bloc. Le QR reste une aide — cadre discret, taille
|
||||
* réduite, téléchargement en action tertiaire — et non le centre de la page.
|
||||
*/
|
||||
export default function SharePanel({ url, label, downloadName }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const qrWrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -23,30 +31,28 @@ export default function SharePanel({ url, label, downloadName }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{label ?? t('share.share_link')}</h3>
|
||||
|
||||
<CollaborationPanel title={label ?? t('share.share_link')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
className="flex-1 min-w-0 text-sm bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600 select-all truncate"
|
||||
// Le titre du panneau nomme le champ visuellement mais pas pour les
|
||||
// technologies d'assistance : le champ reprend donc ce même libellé.
|
||||
aria-label={label ?? t('share.share_link')}
|
||||
className="flex-1 min-w-0 text-body-sm bg-surface-raised border border-line rounded-control px-3 py-2 text-ink-muted select-all truncate"
|
||||
onFocus={e => e.target.select()}
|
||||
/>
|
||||
<CopyButton text={url} label={t('share.copy')} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div ref={qrWrapperRef} className="p-3 bg-white border border-gray-100 rounded-lg">
|
||||
<QRCode value={url} size={160} />
|
||||
<div className="flex flex-col items-center gap-2 pt-1">
|
||||
<div ref={qrWrapperRef} className="p-3 bg-white border border-line-subtle rounded-panel">
|
||||
<QRCode value={url} size={140} />
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadQR}
|
||||
className="text-sm text-primary-600 hover:text-primary-800 underline underline-offset-2"
|
||||
>
|
||||
<button onClick={downloadQR} className="btn-quiet btn-sm">
|
||||
{t('share.download_qr')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CollaborationPanel>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export default function SwipeToDelete({ onDelete, disabled = false, children }:
|
|||
|
||||
return (
|
||||
// Le wrapper fournit bordure + coins arrondis pour les deux éléments (carte + bouton)
|
||||
<div className="relative group self-start overflow-hidden rounded-card border border-gray-200">
|
||||
<div className="relative group self-start overflow-hidden rounded-card border border-line">
|
||||
{!disabled && (
|
||||
<button
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-center bg-danger-600 text-white text-sm font-semibold"
|
||||
|
|
|
|||
72
frontend/src/shared/components/VerifyCodeForm.tsx
Normal file
72
frontend/src/shared/components/VerifyCodeForm.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resendVerification, verifyEmail } from '@shared/api/auth'
|
||||
import type { TokenResponse } from '@shared/types'
|
||||
import Alert from './Alert'
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
onVerified: (data: TokenResponse) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Saisie du code à six chiffres (VIS-03).
|
||||
*
|
||||
* Connexion, inscription et vérification différée aboutissaient à trois copies
|
||||
* du même formulaire, avec trois habillages qui divergeaient. Une seule
|
||||
* implémentation ici : les appelants ne décident que du contexte d'affichage
|
||||
* (`VerifyCodeStep` pour la modale, la page `/verify-email` pour l'accès direct)
|
||||
* et de ce qu'ils font du jeton obtenu.
|
||||
*/
|
||||
export default function VerifyCodeForm({ email, onVerified }: Props) {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: te } = useTranslation('errors')
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [resendLoading, setResendLoading] = useState(false)
|
||||
const [resendSent, setResendSent] = useState(false)
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
await onVerified(await verifyEmail(email, code))
|
||||
} catch {
|
||||
setError(te('invalid_or_expired_code'))
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
setResendLoading(true); setResendSent(false)
|
||||
try { await resendVerification(email); setResendSent(true) }
|
||||
catch { setError(te('email_send_error')) }
|
||||
finally { setResendLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
<Alert type="error" message={error} />
|
||||
{resendSent && <Alert type="success" message={t('verify.resent')} autoHide />}
|
||||
<form onSubmit={handleVerify} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="input text-center text-2xl tracking-widest font-mono"
|
||||
aria-label={t('verify.title')}
|
||||
placeholder="000000"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={loading || code.length !== 6} className="btn-primary">
|
||||
{loading ? t('verify.submitting') : t('verify.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={handleResend} disabled={resendLoading} className="btn-quiet btn-sm self-center">
|
||||
{resendLoading ? t('verify.resending') : t('verify.resend')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/shared/components/VerifyCodeStep.tsx
Normal file
32
frontend/src/shared/components/VerifyCodeStep.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { TokenResponse } from '@shared/types'
|
||||
import Modal from './Modal'
|
||||
import VerifyCodeForm from './VerifyCodeForm'
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
onClose: () => void
|
||||
onVerified: (data: TokenResponse) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Étape de vérification dans le parcours modal (VIS-03).
|
||||
*
|
||||
* Connexion et inscription y aboutissent de la même manière : même composition,
|
||||
* mêmes libellés, même comportement d'erreur. Les deux appelants ne diffèrent
|
||||
* que par ce qu'ils font du jeton obtenu.
|
||||
*/
|
||||
export default function VerifyCodeStep({ email, onClose, onVerified }: Props) {
|
||||
const { t } = useTranslation('auth')
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title={t('verify.title')} disableBackdropClose brand>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-body-sm text-ink-muted">
|
||||
<Trans i18nKey="verify.code_sent" t={t} values={{ email }} components={{ 1: <strong className="text-ink" /> }} />
|
||||
</p>
|
||||
<VerifyCodeForm email={email} onVerified={onVerified} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export { default as ResourceCard } from './ResourceCard'
|
|||
export { default as ServiceDashboard } from './ServiceDashboard'
|
||||
export { default as ServicePageHeader } from './ServicePageHeader'
|
||||
export { default as AuthGuard } from './AuthGuard'
|
||||
export { default as CollaborationPanel } from './CollaborationPanel'
|
||||
export { default as CommentBlock } from './CommentBlock'
|
||||
export { default as ConfirmModal } from './ConfirmModal'
|
||||
export { default as CoOwnerPanel } from './CoOwnerPanel'
|
||||
|
|
@ -16,6 +17,8 @@ export { default as Footer } from './Footer'
|
|||
export { default as Navbar } from './Navbar'
|
||||
export { default as ProgressBar } from './ProgressBar'
|
||||
export { default as SharePanel } from './SharePanel'
|
||||
export { default as VerifyCodeForm } from './VerifyCodeForm'
|
||||
export { default as VerifyCodeStep } from './VerifyCodeStep'
|
||||
export { default as Spinner } from './Spinner'
|
||||
export { default as CookieBanner } from './CookieBanner'
|
||||
export { default as CancelLinkDisplay } from './CancelLinkDisplay'
|
||||
|
|
@ -24,9 +27,12 @@ export { default as CancelableActionConfirm } from './CancelableActionConfirm'
|
|||
export { default as InfoTooltip } from './InfoTooltip'
|
||||
export { default as ServiceRoute } from './ServiceRoute'
|
||||
export { default as ServiceThemeScope } from './ServiceThemeScope'
|
||||
export { default as ServiceIcon } from './ServiceIcon'
|
||||
export { default as SwipeToDelete } from './SwipeToDelete'
|
||||
export { default as ServiceVitrine } from './ServiceVitrine'
|
||||
export { default as PublicRedirectGuard } from './PublicRedirectGuard'
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary"
|
||||
export { default as GuestConversionCTA } from './GuestConversionCTA'
|
||||
export { default as CurrencyInput } from './CurrencyInput'
|
||||
export { default as Field } from './Field'
|
||||
export { default as Badge } from './Badge'
|
||||
|
|
|
|||
77
frontend/src/shared/components/primitives.test.tsx
Normal file
77
frontend/src/shared/components/primitives.test.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { render, screen, within } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Field from './Field'
|
||||
import Badge from './Badge'
|
||||
|
||||
describe('Field', () => {
|
||||
it('affiche un libellé et un enfant', () => {
|
||||
render(
|
||||
<Field label="Titre" id="title">
|
||||
<input id="title" />
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByText('Titre')).toBeDefined()
|
||||
expect(screen.getByRole('textbox')).toBeDefined()
|
||||
})
|
||||
|
||||
it('affiche le marqueur obligatoire sans le lire à voix haute', () => {
|
||||
render(
|
||||
<Field label="Email" required id="email">
|
||||
<input id="email" />
|
||||
</Field>,
|
||||
)
|
||||
const marker = document.querySelector('[aria-hidden="true"]')
|
||||
expect(marker?.textContent).toBe('*')
|
||||
})
|
||||
|
||||
it("affiche le message d'erreur avec role=alert", () => {
|
||||
render(
|
||||
<Field error="Ce champ est requis">
|
||||
<input />
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByRole('alert').textContent).toBe('Ce champ est requis')
|
||||
})
|
||||
|
||||
it('affiche le compteur en rouge au dépassement', () => {
|
||||
const { container } = render(
|
||||
<Field count={{ value: 120, max: 100 }}>
|
||||
<input />
|
||||
</Field>,
|
||||
)
|
||||
const counter = container.querySelector('.text-danger-600')
|
||||
expect(counter?.textContent).toBe('120/100')
|
||||
})
|
||||
|
||||
it("n'affiche pas l'aide si une erreur est présente", () => {
|
||||
const { container } = render(
|
||||
<Field help="Format attendu : JJ/MM" error="Date invalide">
|
||||
<input />
|
||||
</Field>,
|
||||
)
|
||||
expect(within(container).queryByText('Format attendu : JJ/MM')).toBeNull()
|
||||
expect(within(container).getByRole('alert').textContent).toBe('Date invalide')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Badge', () => {
|
||||
it('rend la variante meta par défaut', () => {
|
||||
const { container } = render(<Badge>Brouillon</Badge>)
|
||||
expect(container.firstChild).toHaveClass('badge-meta')
|
||||
})
|
||||
|
||||
it('rend la variante service', () => {
|
||||
const { container } = render(<Badge variant="service">Kwiz</Badge>)
|
||||
expect(container.firstChild).toHaveClass('badge-service')
|
||||
})
|
||||
|
||||
it('rend la variante danger', () => {
|
||||
const { container } = render(<Badge variant="danger">Expiré</Badge>)
|
||||
expect(container.firstChild).toHaveClass('badge-danger')
|
||||
})
|
||||
|
||||
it('applique une className supplémentaire', () => {
|
||||
const { container } = render(<Badge className="ml-2">Test</Badge>)
|
||||
expect(container.firstChild).toHaveClass('ml-2')
|
||||
})
|
||||
})
|
||||
170
frontend/src/shared/components/shell.test.tsx
Normal file
170
frontend/src/shared/components/shell.test.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import '../../i18n/config'
|
||||
import i18n from '../../i18n/config'
|
||||
import { AuthProvider } from '@shared/hooks/useAuth'
|
||||
import { serviceMetadata } from '@shared/config/serviceMetadata'
|
||||
import CommentBlock from './CommentBlock'
|
||||
import Footer from './Footer'
|
||||
import Navbar from './Navbar'
|
||||
import SharePanel from './SharePanel'
|
||||
|
||||
const sessionUser = {
|
||||
id: 'u1', email: 'organisatrice@example.com', lang: 'fr', email_verified: true,
|
||||
post_event_emails_enabled: false, is_premium: false, premium_until: null,
|
||||
subscription_plan: null, subscription_status: null, cancel_at_period_end: false,
|
||||
has_password: true, created_at: '2026-08-01T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mock('@shared/api/auth', () => ({
|
||||
getMe: vi.fn(async () => sessionUser),
|
||||
setLang: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('@shared/api/comments', () => ({
|
||||
getComments: vi.fn(async () => ([{
|
||||
id: 'c1',
|
||||
author_name: 'Camille',
|
||||
content: 'On part à quelle heure ?',
|
||||
created_at: '2026-08-11T18:30:00Z',
|
||||
user_id: null,
|
||||
}])),
|
||||
addComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
}))
|
||||
|
||||
function renderShell(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AuthProvider>
|
||||
<Navbar />
|
||||
<main />
|
||||
<Footer />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
const containers = (root: HTMLElement | Document, selector: string) =>
|
||||
[...root.querySelectorAll<HTMLElement>(`${selector} .shell-container`)]
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear()
|
||||
await i18n.changeLanguage('fr')
|
||||
})
|
||||
|
||||
/**
|
||||
* Contrat de shell (VIS-03). VIS-AUD-06 relevait une Navbar pleine largeur, un
|
||||
* contenu en `max-w-6xl` et un Footer en `max-w-4xl` : trois axes de lecture sur
|
||||
* une même page. Navbar et Footer doivent désormais partager le conteneur du
|
||||
* contexte courant.
|
||||
*/
|
||||
describe('Shell public et workspace', () => {
|
||||
it('aligne la Navbar et le Footer sur le même axe en contexte public', () => {
|
||||
const { container } = renderShell('/')
|
||||
const nav = containers(container, 'nav')
|
||||
const footer = containers(container, 'footer')
|
||||
|
||||
expect(nav.length).toBeGreaterThan(0)
|
||||
expect(footer).toHaveLength(1)
|
||||
for (const element of [...nav, ...footer]) {
|
||||
expect(element).toHaveClass('max-w-public')
|
||||
expect(element).not.toHaveClass('max-w-workspace')
|
||||
}
|
||||
})
|
||||
|
||||
it('bascule les deux extrémités ensemble en contexte workspace', () => {
|
||||
const { container } = renderShell('/hub/new')
|
||||
for (const element of [...containers(container, 'nav'), ...containers(container, 'footer')]) {
|
||||
expect(element).toHaveClass('max-w-workspace')
|
||||
expect(element).not.toHaveClass('max-w-public')
|
||||
}
|
||||
})
|
||||
|
||||
it('suit ServiceRoute sur une racine de service ouverte en session', async () => {
|
||||
// `/kdo` rend la vitrine hors session et le tableau de bord en session : les
|
||||
// deux extrémités du shell suivent le contenu réellement affiché.
|
||||
localStorage.setItem('token', 'session')
|
||||
const { container } = renderShell('/kdo')
|
||||
await waitFor(() => {
|
||||
for (const element of [...containers(container, 'nav'), ...containers(container, 'footer')]) {
|
||||
expect(element).toHaveClass('max-w-workspace')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('nomme les six services plutôt que de les réduire à des icônes', () => {
|
||||
renderShell('/')
|
||||
for (const service of serviceMetadata) {
|
||||
expect(screen.getAllByText(service.label).length, service.id).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('fait défiler la rangée de services dans son propre conteneur', () => {
|
||||
const { container } = renderShell('/')
|
||||
// La page ne défile pas horizontalement : c'est la rangée qui porte le
|
||||
// débordement (VIS-AUD-06).
|
||||
const row = container.querySelector('nav .overflow-x-auto')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.querySelector('[data-service-nav]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('signale le service actif sans supprimer ses frères', () => {
|
||||
const { container } = renderShell('/kwiz/polls')
|
||||
const active = container.querySelectorAll('[aria-current="page"]')
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].textContent).toContain('Kwiz')
|
||||
expect(container.querySelectorAll('[data-service-nav]').length).toBe(
|
||||
serviceMetadata.filter(service => service.active).length,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Rail de collaboration (VIS-AUD-07) : une seule famille de surface, et aucune
|
||||
* information portée par le seul survol.
|
||||
*/
|
||||
describe('Rail de collaboration', () => {
|
||||
it('donne la même surface au partage et aux commentaires', async () => {
|
||||
const { container } = render(
|
||||
<AuthProvider>
|
||||
<SharePanel url="https://kankwa.fr/share/abc" />
|
||||
<CommentBlock resourceType="kdo_list" resourceId="42" />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Camille')).toBeVisible())
|
||||
const panels = container.querySelectorAll('.card-collaboration')
|
||||
expect(panels).toHaveLength(2)
|
||||
for (const panel of panels) {
|
||||
expect(panel.tagName).toBe('SECTION')
|
||||
expect(panel.querySelector('.card-collaboration-title')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('affiche la date d’un commentaire sans survol', async () => {
|
||||
const { container } = render(
|
||||
<AuthProvider>
|
||||
<CommentBlock resourceType="kdo_list" resourceId="42" />
|
||||
</AuthProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Camille')).toBeVisible())
|
||||
expect(screen.getByText(/2026/)).toBeVisible()
|
||||
expect(container.querySelector('.group-hover\\:opacity-100')).toBeNull()
|
||||
expect(container.querySelector('.opacity-0')).toBeNull()
|
||||
})
|
||||
|
||||
it('donne au lien de partage l’action dominante et au QR un rôle d’appoint', () => {
|
||||
const { container } = render(<SharePanel url="https://kankwa.fr/share/abc" />)
|
||||
|
||||
const field = screen.getByDisplayValue('https://kankwa.fr/share/abc')
|
||||
expect(field).toHaveAttribute('readonly')
|
||||
expect(container.querySelector('svg[height="140"], svg[width="140"]')).not.toBeNull()
|
||||
expect(container.querySelector('.btn-quiet')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
184
frontend/src/shared/config/designTokens.test.ts
Normal file
184
frontend/src/shared/config/designTokens.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import tailwindConfig from '../../../tailwind.config'
|
||||
import { serviceThemes } from './serviceThemes'
|
||||
|
||||
/**
|
||||
* Contrat de tokens VIS-01.
|
||||
*
|
||||
* `docs/DESIGN_SYSTEM.md` fixe le nombre de rôles typographiques, de rayons,
|
||||
* d'élévations et de durées. Ce test empêche qu'un lot de migration ultérieur
|
||||
* en supprime un discrètement ou en ajoute un sans mettre le document à jour :
|
||||
* la refonte doit absorber un besoin répété dans un token existant, pas
|
||||
* multiplier les valeurs.
|
||||
*/
|
||||
|
||||
const theme = tailwindConfig.theme?.extend ?? {}
|
||||
|
||||
/** Luminance relative WCAG 2.1 d'une couleur `#rrggbb`. */
|
||||
function luminance(hex: string): number {
|
||||
const channels = [1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255)
|
||||
const [r, g, b] = channels.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
function contrast(foreground: string, background: string): number {
|
||||
const [light, dark] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
|
||||
return (light + 0.05) / (dark + 0.05)
|
||||
}
|
||||
|
||||
const stylesheet = readFileSync(resolve(__dirname, '../../index.css'), 'utf8')
|
||||
|
||||
/** Palette de marque : la seule qui s'applique hors d'un `ServiceThemeScope`. */
|
||||
function brandPalette(): Record<number, string> {
|
||||
const entries = [...stylesheet.matchAll(/--color-primary-(\d+):\s*(\d+) (\d+) (\d+);/g)]
|
||||
return Object.fromEntries(entries.map(([, shade, r, g, b]) => [
|
||||
Number(shade),
|
||||
`#${[r, g, b].map(channel => Number(channel).toString(16).padStart(2, '0')).join('')}`,
|
||||
]))
|
||||
}
|
||||
|
||||
describe('tokens de la direction visuelle', () => {
|
||||
it('expose exactement les sept rôles typographiques du contrat de design', () => {
|
||||
expect(Object.keys(theme.fontSize ?? {})).toEqual([
|
||||
'display', 'page-title', 'section-title', 'card-title', 'body', 'body-sm', 'meta',
|
||||
])
|
||||
})
|
||||
|
||||
it('déclare les quatre niveaux de rayon, `component` restant un alias de `control`', () => {
|
||||
const radius = theme.borderRadius as Record<string, string>
|
||||
expect(Object.keys(radius)).toEqual(['control', 'component', 'panel', 'card', 'editorial'])
|
||||
expect(radius.component).toBe(radius.control)
|
||||
// Ordre croissant : contrôle < panneau < carte < composition éditoriale.
|
||||
const scale = [radius.control, radius.panel, radius.card, radius.editorial].map(parseFloat)
|
||||
expect(scale).toEqual([...scale].sort((a, b) => a - b))
|
||||
expect(new Set(scale).size).toBe(scale.length)
|
||||
})
|
||||
|
||||
it('limite l’élévation à trois niveaux teintés à l’encre, jamais au noir pur', () => {
|
||||
const shadow = theme.boxShadow as Record<string, string>
|
||||
expect(Object.keys(shadow)).toEqual(['flat', 'raised', 'floating'])
|
||||
expect(shadow.flat).toBe('none')
|
||||
for (const level of [shadow.raised, shadow.floating]) {
|
||||
expect(level).toContain('rgb(26 31 46')
|
||||
expect(level).not.toContain('rgb(0 0 0')
|
||||
}
|
||||
})
|
||||
|
||||
it('limite le mouvement aux trois durées 120 / 180 / 240 ms', () => {
|
||||
expect(theme.transitionDuration).toEqual({ micro: '120ms', control: '180ms', panel: '240ms' })
|
||||
})
|
||||
|
||||
it('résout les espacements de composition par variable CSS', () => {
|
||||
const spacing = theme.spacing as Record<string, string>
|
||||
expect(Object.keys(spacing)).toEqual(['gutter', 'section', 'cards', 'panel', 'compact'])
|
||||
for (const value of Object.values(spacing)) expect(value).toMatch(/^var\(--space-[a-z]+\)$/)
|
||||
})
|
||||
|
||||
it('aligne le conteneur public sur la largeur dominante de G4/G6', () => {
|
||||
const widths = theme.maxWidth as Record<string, string>
|
||||
expect(Object.keys(widths)).toEqual(['public', 'workspace', 'editorial', 'form'])
|
||||
// 72rem = max-w-6xl, la classe déjà employée par la homepage et les vitrines :
|
||||
// adopter le token ne déplace aucune page publique.
|
||||
expect(widths.public).toBe('72rem')
|
||||
})
|
||||
|
||||
it('nomme les neutres fonctionnels par rôle sans introduire de couleur nouvelle', () => {
|
||||
// `sable` est une couleur plate : le cast passe par `unknown` plutôt que
|
||||
// d'imposer une forme uniforme à toute la palette.
|
||||
const colors = theme.colors as unknown as Record<string, Record<string, string>>
|
||||
expect(Object.keys(colors.ink)).toEqual(['DEFAULT', 'soft', 'muted', 'subtle', 'faint', 'inverse', 'accent'])
|
||||
expect(Object.keys(colors.surface)).toEqual(['page', 'raised', 'muted', 'sunken', 'inverse', 'notice', 'accent'])
|
||||
expect(Object.keys(colors.line)).toEqual(['DEFAULT', 'subtle', 'strong', 'accent'])
|
||||
|
||||
// Les rôles neutres réutilisent les valeurs de la palette chaude existante,
|
||||
// à l'exception documentée de `ink.subtle` (contraste, cf. test suivant).
|
||||
expect(colors.ink.DEFAULT).toBe(colors.gray[900])
|
||||
expect(colors.ink.soft).toBe(colors.gray[800])
|
||||
expect(colors.ink.muted).toBe(colors.gray[600])
|
||||
expect(colors.ink.faint).toBe(colors.gray[400])
|
||||
expect(colors.surface.page).toBe(colors.gray[50])
|
||||
expect(colors.surface.muted).toBe(colors.gray[100])
|
||||
expect(colors.line.DEFAULT).toBe(colors.gray[200])
|
||||
expect(colors.line.strong).toBe(colors.gray[300])
|
||||
|
||||
// Les variantes d'accent suivent la palette de service, jamais un hexadécimal figé.
|
||||
for (const accent of [colors.ink.accent, colors.surface.accent, colors.line.accent]) {
|
||||
expect(accent).toMatch(/var\(--color-primary-\d+\)/)
|
||||
}
|
||||
})
|
||||
|
||||
it('garantit que tout rôle de texte reste lisible sur toute surface claire', () => {
|
||||
const colors = theme.colors as unknown as Record<string, Record<string, string>>
|
||||
const surfaces = ['page', 'raised', 'muted', 'sunken'].map(role => colors.surface[role])
|
||||
|
||||
// Un rôle de texte doit être lisible partout où il peut être posé, sinon le
|
||||
// choix redevient une décision par écran — exactement ce que la refonte
|
||||
// supprime. Seuil AA texte courant : 4,5:1.
|
||||
for (const role of ['DEFAULT', 'soft', 'muted', 'subtle'] as const) {
|
||||
for (const surface of surfaces) {
|
||||
expect(contrast(colors.ink[role], surface), `ink.${role} sur ${surface}`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
}
|
||||
|
||||
// `faint` n'est pas un rôle de texte : désactivé, placeholder, repère non
|
||||
// essentiel. Il tient le seuil des composants non textuels (3:1) sur les
|
||||
// deux surfaces où il est autorisé, et rien de plus.
|
||||
for (const surface of [colors.surface.page, colors.surface.raised]) {
|
||||
expect(contrast(colors.ink.faint, surface)).toBeGreaterThanOrEqual(3)
|
||||
}
|
||||
expect(contrast(colors.ink.faint, colors.surface.page)).toBeLessThan(4.5)
|
||||
|
||||
// Le texte inversé doit rester lisible sur la surface inversée.
|
||||
expect(contrast(colors.ink.inverse, colors.surface.inverse)).toBeGreaterThanOrEqual(4.5)
|
||||
|
||||
// `surface-notice` est teintée : la règle « le rôle remonte d'un cran » doit
|
||||
// y être réellement praticable, donc `ink-muted` passe et `ink-subtle` non.
|
||||
expect(contrast(colors.ink.muted, colors.surface.notice)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(contrast(colors.ink.subtle, colors.surface.notice)).toBeLessThan(4.5)
|
||||
})
|
||||
|
||||
it('garde l’action principale lisible sous les sept palettes', () => {
|
||||
// Le fond de `.btn-primary` est lu dans la feuille elle-même : le test suit
|
||||
// la décision de composition, il ne la duplique pas. L'action principale
|
||||
// porte du texte courant, donc le seuil est 4,5:1 — c'est ce qu'axe reprochait
|
||||
// à la Navbar avant VIS-03.
|
||||
const shade = stylesheet.match(/\.btn-primary\s*\{[^}]*bg-primary-(\d+)/)?.[1]
|
||||
expect(shade, 'la classe .btn-primary doit déclarer un fond de palette').toBeDefined()
|
||||
|
||||
const palettes = { brand: brandPalette(), ...Object.fromEntries(
|
||||
Object.entries(serviceThemes).map(([id, theme]) => [id, theme.palette]),
|
||||
) } as Record<string, Record<number, string>>
|
||||
|
||||
for (const [name, palette] of Object.entries(palettes)) {
|
||||
expect(contrast('#FFFFFF', palette[Number(shade)]), `blanc sur ${name}-${shade}`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
})
|
||||
|
||||
it('donne à l’aide contextuelle une teinte qu’aucun service ne peut revendiquer', () => {
|
||||
const colors = theme.colors as unknown as Record<string, Record<string, string>>
|
||||
|
||||
// `info-*` est disqualifié : ses deux nuances claires sont exactement celles
|
||||
// de Kal. Une aide bleue serait indiscernable de l'identité du service.
|
||||
expect(colors.info[50]).toBe(serviceThemes.kal.palette[50])
|
||||
expect(colors.info[100]).toBe(serviceThemes.kal.palette[100])
|
||||
|
||||
// La teinte retenue appartient à la marque, à aucun service, et se distingue
|
||||
// nettement de chaque nuance claire de service comme du fond de page.
|
||||
expect(colors.surface.notice).toBe(theme.colors?.sable)
|
||||
for (const theme_ of Object.values(serviceThemes)) {
|
||||
expect(colors.surface.notice).not.toBe(theme_.palette[50])
|
||||
}
|
||||
expect(contrast(colors.surface.notice, colors.surface.page)).toBeGreaterThan(1.2)
|
||||
})
|
||||
|
||||
it('garde la police de titre auto-hébergée derrière un repli système stable', () => {
|
||||
const families = theme.fontFamily as Record<string, string[]>
|
||||
expect(families.display[0]).toBe('Bricolage Grotesque')
|
||||
expect(families.display[1]).toBe('Kankwa Display Fallback')
|
||||
expect(families.display).toContain('sans-serif')
|
||||
expect(families.sans).not.toContain('Bricolage Grotesque')
|
||||
})
|
||||
})
|
||||
76
frontend/src/shared/config/shellVariant.test.ts
Normal file
76
frontend/src/shared/config/shellVariant.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { landings } from './landings'
|
||||
import { serviceMetadata } from './serviceMetadata'
|
||||
import { SHELL_CONTAINER, getShellVariant, shellContainerClass } from './shellVariant'
|
||||
|
||||
/**
|
||||
* Contrat de shell (VIS-03). L'audit VIS-AUD-06 relevait trois axes de lecture
|
||||
* sur une même page : ce test verrouille la bascule et l'unicité du conteneur.
|
||||
*/
|
||||
describe('getShellVariant', () => {
|
||||
it('classe les pages d’acquisition dans le shell public', () => {
|
||||
expect(getShellVariant('/')).toBe('public')
|
||||
expect(getShellVariant('/demo')).toBe('public')
|
||||
for (const landing of landings) {
|
||||
for (const path of Object.values(landing.path)) {
|
||||
expect(getShellVariant(path), path).toBe('public')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('classe les pages légales dans le shell public', () => {
|
||||
expect(getShellVariant('/legal')).toBe('public')
|
||||
expect(getShellVariant('/privacy')).toBe('public')
|
||||
expect(getShellVariant('/terms')).toBe('public')
|
||||
})
|
||||
|
||||
it('suit ServiceRoute sur une racine de service : vitrine hors session, travail en session', () => {
|
||||
for (const service of serviceMetadata) {
|
||||
expect(getShellVariant(service.path), service.path).toBe('public')
|
||||
expect(getShellVariant(service.path, true), service.path).toBe('workspace')
|
||||
}
|
||||
})
|
||||
|
||||
it('ne dépend pas de la session sur une page publique ou de travail', () => {
|
||||
expect(getShellVariant('/', true)).toBe('public')
|
||||
expect(getShellVariant('/organiser/anniversaire', true)).toBe('public')
|
||||
expect(getShellVariant('/compte', false)).toBe('workspace')
|
||||
})
|
||||
|
||||
it('classe l’espace de travail, la participation et le compte dans le shell workspace', () => {
|
||||
expect(getShellVariant('/hub/new')).toBe('workspace')
|
||||
expect(getShellVariant('/kdo/lists/42/edit')).toBe('workspace')
|
||||
expect(getShellVariant('/share/00000000-0000-4000-8000-000000000101')).toBe('workspace')
|
||||
expect(getShellVariant('/compte')).toBe('workspace')
|
||||
expect(getShellVariant('/login')).toBe('workspace')
|
||||
})
|
||||
|
||||
it('ne confond pas une vitrine avec ses sous-routes de travail', () => {
|
||||
// `/kdo` est la vitrine hors session, `/kdo/lists` un espace de travail
|
||||
// dans tous les cas : le contrat compare des chemins exacts, jamais des
|
||||
// préfixes.
|
||||
expect(getShellVariant('/kdo')).toBe('public')
|
||||
expect(getShellVariant('/kdo/lists')).toBe('workspace')
|
||||
expect(getShellVariant('/kdo/lists', true)).toBe('workspace')
|
||||
})
|
||||
|
||||
it('normalise la barre oblique finale', () => {
|
||||
expect(getShellVariant('/demo/')).toBe('public')
|
||||
expect(getShellVariant('/')).toBe('public')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shellContainerClass', () => {
|
||||
it('nomme un seul conteneur par contexte', () => {
|
||||
expect(shellContainerClass('/')).toBe(`shell-container ${SHELL_CONTAINER.public}`)
|
||||
expect(shellContainerClass('/hub/new')).toBe(`shell-container ${SHELL_CONTAINER.workspace}`)
|
||||
expect(shellContainerClass('/hub', true)).toBe(`shell-container ${SHELL_CONTAINER.workspace}`)
|
||||
})
|
||||
|
||||
it('utilise deux largeurs distinctes et documentées', () => {
|
||||
expect(SHELL_CONTAINER.public).toBe('max-w-public')
|
||||
expect(SHELL_CONTAINER.workspace).toBe('max-w-workspace')
|
||||
expect(SHELL_CONTAINER.public).not.toBe(SHELL_CONTAINER.workspace)
|
||||
})
|
||||
})
|
||||
60
frontend/src/shared/config/shellVariant.ts
Normal file
60
frontend/src/shared/config/shellVariant.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { landings } from './landings'
|
||||
import { serviceMetadata } from './serviceMetadata'
|
||||
|
||||
/**
|
||||
* Contrat de shell (VIS-03).
|
||||
*
|
||||
* `docs/DESIGN_SYSTEM.md` décrit deux shells : le shell public (homepage,
|
||||
* vitrines, landings, démo, pages légales) et le shell workspace (dashboards,
|
||||
* édition, participation, compte). Ils ne diffèrent que par leur axe de lecture :
|
||||
* l'audit VIS-AUD-06 relevait une Navbar pleine largeur, des pages publiques en
|
||||
* `max-w-6xl`/`max-w-7xl` et un Footer en `max-w-4xl`, donc trois axes distincts
|
||||
* sur une même page.
|
||||
*
|
||||
* Ce fichier est la source unique de cette bascule. Il ne décide d'aucune route,
|
||||
* d'aucun droit et d'aucun rendu : il ne fait que nommer le conteneur commun à la
|
||||
* Navbar, au contenu et au Footer d'un même contexte.
|
||||
*/
|
||||
export type ShellVariant = 'public' | 'workspace'
|
||||
|
||||
/**
|
||||
* Le shell public s'applique à des pages *exactes*, jamais à un préfixe :
|
||||
* `/kdo/lists/:id/edit` est un espace de travail. La participation par lien de
|
||||
* partage appartient au shell workspace, conformément à la définition du
|
||||
* document de direction.
|
||||
*/
|
||||
const PUBLIC_PATHS: ReadonlySet<string> = new Set([
|
||||
'/',
|
||||
'/demo',
|
||||
'/legal',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
...landings.flatMap(landing => Object.values(landing.path)),
|
||||
])
|
||||
|
||||
/**
|
||||
* Racines de service : `ServiceRoute` y rend la vitrine hors session et le
|
||||
* tableau de bord en session. Le chemin seul ne décide donc pas — c'est la seule
|
||||
* ambiguïté du contrat, et elle se résout avec l'état d'authentification.
|
||||
*/
|
||||
const SERVICE_ROOTS: ReadonlySet<string> = new Set(serviceMetadata.map(service => service.path))
|
||||
|
||||
/** Largeur maximale documentée de chaque shell (`docs/DESIGN_SYSTEM.md`). */
|
||||
export const SHELL_CONTAINER: Record<ShellVariant, string> = {
|
||||
public: 'max-w-public',
|
||||
workspace: 'max-w-workspace',
|
||||
}
|
||||
|
||||
export function getShellVariant(pathname: string, authenticated = false): ShellVariant {
|
||||
const normalized = pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname
|
||||
if (PUBLIC_PATHS.has(normalized)) return 'public'
|
||||
if (SERVICE_ROOTS.has(normalized)) return authenticated ? 'workspace' : 'public'
|
||||
return 'workspace'
|
||||
}
|
||||
|
||||
/** Classe de conteneur du shell courant : `mx-auto`, gouttière de page, largeur du contexte. */
|
||||
export function shellContainerClass(pathname: string, authenticated = false): string {
|
||||
return `shell-container ${SHELL_CONTAINER[getShellVariant(pathname, authenticated)]}`
|
||||
}
|
||||
|
|
@ -34,16 +34,16 @@ export default function AcceptOwnership() {
|
|||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<p className="text-gray-600">{t('accept_ownership.invalid')}</p>
|
||||
<a href="/" className="text-primary-600 underline text-sm">{t('accept_ownership.back')}</a>
|
||||
</div>
|
||||
<main className="page-sm flex flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-body text-ink-muted">{t('accept_ownership.invalid')}</p>
|
||||
<a href="/" className="link text-body-sm">{t('accept_ownership.back')}</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<p className="text-gray-400">{t('accept_ownership.loading')}</p>
|
||||
</div>
|
||||
<main className="page-sm flex items-center justify-center">
|
||||
<p className="text-body-sm text-ink-subtle">{t('accept_ownership.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { getIntlLocale } from '../../i18n/config'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@shared/hooks/useAuth'
|
||||
import { usePageMeta } from '@shared/hooks/usePageMeta'
|
||||
import { Alert, Modal, ServicePageHeader } from '@shared/components'
|
||||
import { Alert, Badge, Modal, ProgressBar, ServicePageHeader } from '@shared/components'
|
||||
import { extractApiError } from '@shared/utils/error'
|
||||
import {
|
||||
changePassword, requestEmailChange, confirmEmailChange,
|
||||
|
|
@ -14,7 +14,30 @@ import { openPortal } from '@shared/api/billing'
|
|||
import { serviceMetadata } from '@shared/config/serviceMetadata'
|
||||
import ServiceIcon from '@shared/components/ServiceIcon'
|
||||
|
||||
// ─── Profil ───────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Section du compte (VIS-03). Toutes les sections partagent la même carte, le
|
||||
* même rôle de titre et le même rythme : la hiérarchie de la page vient de leur
|
||||
* ordre — identité, usage, abonnement, sécurité — pas d'un traitement visuel
|
||||
* différent pour chacune.
|
||||
*/
|
||||
function AccountSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="card flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-section-title text-ink">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Libellé d'un champ en lecture seule — même rôle partout dans le compte. */
|
||||
function FieldLabel({ children }: { children: ReactNode }) {
|
||||
return <p className="text-meta uppercase tracking-wide text-ink-subtle mb-1">{children}</p>
|
||||
}
|
||||
|
||||
// ─── Identité ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfilSection() {
|
||||
const { t } = useTranslation('auth')
|
||||
|
|
@ -39,15 +62,14 @@ function ProfilSection() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card flex flex-col gap-4">
|
||||
<h2 className="font-semibold text-gray-900">{t('account.profile')}</h2>
|
||||
<AccountSection title={t('account.profile')}>
|
||||
<Alert type="success" message={success} />
|
||||
<Alert type="error" message={error} />
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">{t('account.email_label')}</p>
|
||||
<FieldLabel>{t('account.email_label')}</FieldLabel>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{user.email}</span>
|
||||
<span className="text-body font-medium text-ink truncate">{user.email}</span>
|
||||
<button
|
||||
onClick={() => { setShowForm(v => !v); setError('') }}
|
||||
className="btn-secondary btn-sm shrink-0"
|
||||
|
|
@ -56,32 +78,30 @@ function ProfilSection() {
|
|||
</button>
|
||||
</div>
|
||||
{!user.email_verified && (
|
||||
<span className="mt-1 inline-flex text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-0.5">
|
||||
{t('account.email_unverified')}
|
||||
</span>
|
||||
<Badge variant="warning" className="mt-1.5 inline-flex">{t('account.email_unverified')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 border-t border-gray-100 pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 border-t border-line-subtle pt-3">
|
||||
<input
|
||||
type="email" className="input" value={newEmail} autoFocus required
|
||||
onChange={e => setNewEmail(e.target.value)} placeholder={t('account.email_new_placeholder')}
|
||||
/>
|
||||
<p className="text-xs text-gray-400">{t('account.email_change_hint')}</p>
|
||||
<p className="text-body-sm text-ink-subtle">{t('account.email_change_hint')}</p>
|
||||
<button type="submit" disabled={loading || !newEmail} className="btn-primary self-start">
|
||||
{loading ? t('account.email_change_confirming') : t('account.email_change_confirm')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">{t('account.member_since')}</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<div className="border-t border-line-subtle pt-3">
|
||||
<FieldLabel>{t('account.member_since')}</FieldLabel>
|
||||
<p className="text-body-sm text-ink-muted">
|
||||
{new Date(user.created_at).toLocaleDateString(getIntlLocale(), { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccountSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -114,16 +134,15 @@ function SecuriteSection() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card flex flex-col gap-4">
|
||||
<h2 className="font-semibold text-gray-900">{t('account.security')}</h2>
|
||||
<AccountSection title={t('account.security')}>
|
||||
<Alert type="success" message={success} />
|
||||
<Alert type="error" message={error} />
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">{t('account.password_label')}</p>
|
||||
<FieldLabel>{t('account.password_label')}</FieldLabel>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{user.has_password ? '••••••••••' : <span className="text-gray-400 font-normal">{t('account.password_not_set')}</span>}
|
||||
<span className="text-body font-medium text-ink">
|
||||
{user.has_password ? '••••••••••' : <span className="text-ink-subtle font-normal">{t('account.password_not_set')}</span>}
|
||||
</span>
|
||||
<button onClick={() => { setShowForm(v => !v); setError('') }} className="btn-secondary btn-sm shrink-0">
|
||||
{showForm ? t('account.password_cancel') : user.has_password ? t('account.password_edit') : t('account.password_define')}
|
||||
|
|
@ -132,7 +151,7 @@ function SecuriteSection() {
|
|||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 border-t border-gray-100 pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 border-t border-line-subtle pt-3">
|
||||
{user.has_password && (
|
||||
<input type="password" className="input" value={current} autoFocus required
|
||||
placeholder={t('account.password_current')} onChange={e => setCurrent(e.target.value)} />
|
||||
|
|
@ -141,25 +160,69 @@ function SecuriteSection() {
|
|||
placeholder={t('account.password_new')} onChange={e => setNext(e.target.value)} />
|
||||
<input type="password" className="input" value={confirm} required
|
||||
placeholder={t('account.password_confirm')} onChange={e => setConfirm(e.target.value)} />
|
||||
<p className="text-xs text-gray-400">{t('account.password_hint')}</p>
|
||||
<p className="text-body-sm text-ink-subtle">{t('account.password_hint')}</p>
|
||||
<button type="submit" disabled={loading} className="btn-primary self-start">
|
||||
{loading ? t('account.password_saving') : t('account.password_save')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">{t('account.login_method')}</p>
|
||||
<p className="text-sm text-gray-700">{t('account.login_method_desc')}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t('account.login_method_magic')}</p>
|
||||
<div className="border-t border-line-subtle pt-3">
|
||||
<FieldLabel>{t('account.login_method')}</FieldLabel>
|
||||
<p className="text-body-sm text-ink-muted">{t('account.login_method_desc')}</p>
|
||||
<p className="text-body-sm text-ink-subtle mt-0.5">{t('account.login_method_magic')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccountSection>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Usage ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ce que le compte contient aujourd'hui. Séparé de l'abonnement : l'usage se lit
|
||||
* avant de décider d'un plan, et un compte Kankwa+ n'a plus de quota à afficher.
|
||||
*/
|
||||
function UsageSection({ usage }: { usage: UsageStats | null }) {
|
||||
const { t } = useTranslation('auth')
|
||||
const { user } = useAuth()
|
||||
if (!user || !usage) return null
|
||||
|
||||
const atLimit = usage.total >= usage.max
|
||||
|
||||
return (
|
||||
<AccountSection title={t('account.services_active')}>
|
||||
{user.is_premium ? (
|
||||
<p className="text-body-sm text-ink-muted">
|
||||
{usage.total} · {t('account.services_unlimited')}
|
||||
</p>
|
||||
) : (
|
||||
<ProgressBar value={usage.total} max={usage.max} label={t('account.services_active')} tone="quota" />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{usage.services.map(svc => {
|
||||
const cfg = serviceMetadata.find(s => s.id === svc.icon_id)
|
||||
return (
|
||||
<div key={svc.key} className="card-summary text-center">
|
||||
<div className="flex justify-center mb-1 text-ink-subtle"><ServiceIcon serviceId={svc.icon_id} /></div>
|
||||
<p className="text-card-title font-semibold text-ink tabular-nums">{svc.count}</p>
|
||||
<p className="text-body-sm text-ink-subtle mt-0.5 leading-tight">{cfg?.label ?? svc.label}</p>
|
||||
{!svc.counted && <p className="text-meta text-ink-subtle mt-0.5">{t('account.services_unlimited')}</p>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{atLimit && !user.is_premium && (
|
||||
<p className="text-body-sm text-danger-600">{t('common:premium.limit_reached')}</p>
|
||||
)}
|
||||
</AccountSection>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Abonnement ───────────────────────────────────────────────────────────────
|
||||
|
||||
function AbonnementSection({ usage }: { usage: UsageStats | null }) {
|
||||
function AbonnementSection() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: te } = useTranslation('errors')
|
||||
const { user } = useAuth()
|
||||
|
|
@ -167,8 +230,6 @@ function AbonnementSection({ usage }: { usage: UsageStats | null }) {
|
|||
const [error, setError] = useState('')
|
||||
if (!user) return null
|
||||
|
||||
const atLimit = usage ? usage.total >= usage.max : false
|
||||
const pct = usage ? Math.min(100, (usage.total / usage.max) * 100) : 0
|
||||
const periodDate = user.premium_until
|
||||
? new Date(user.premium_until).toLocaleDateString(getIntlLocale(), { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: null
|
||||
|
|
@ -185,88 +246,44 @@ function AbonnementSection({ usage }: { usage: UsageStats | null }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">{t('account.subscription')}</h2>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
user.is_premium
|
||||
? 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||
: 'bg-gray-100 text-gray-600 border border-gray-200'
|
||||
}`}>
|
||||
<AccountSection
|
||||
title={t('account.subscription')}
|
||||
action={
|
||||
<Badge variant={user.is_premium ? 'service' : 'meta'}>
|
||||
{user.is_premium ? t('account.plan_premium') : t('account.plan_free')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{user.is_premium && (
|
||||
<div className="-mt-3 flex flex-col gap-1 text-sm">
|
||||
<div className="flex flex-col gap-1 text-body-sm">
|
||||
{user.subscription_plan && (
|
||||
<p className="text-gray-600">
|
||||
<p className="text-ink-muted">
|
||||
{user.subscription_plan === 'annual' ? t('account.plan_annual') : t('account.plan_monthly')}
|
||||
<span className="text-gray-400"> · {user.subscription_plan === 'annual' ? t('account.plan_annual_price') : t('account.plan_monthly_price')}</span>
|
||||
<span className="text-ink-subtle"> · {user.subscription_plan === 'annual' ? t('account.plan_annual_price') : t('account.plan_monthly_price')}</span>
|
||||
</p>
|
||||
)}
|
||||
{periodDate && (
|
||||
user.subscription_status === 'past_due' ? (
|
||||
<p className="text-danger-600">{t('account.plan_past_due')}</p>
|
||||
) : user.cancel_at_period_end ? (
|
||||
<p className="text-amber-700">{t('account.plan_cancelled', { date: periodDate })}</p>
|
||||
<p className="text-warning-700">{t('account.plan_cancelled', { date: periodDate })}</p>
|
||||
) : (
|
||||
<p className="text-gray-500">{t('account.plan_renews', { date: periodDate })}</p>
|
||||
<p className="text-ink-subtle">{t('account.plan_renews', { date: periodDate })}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage */}
|
||||
{usage && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500">{t('account.services_active')}</span>
|
||||
<span className={atLimit && !user.is_premium ? 'text-danger-600 font-semibold' : 'text-gray-500'}>
|
||||
{usage.total}{!user.is_premium ? ` / ${usage.max}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{!user.is_premium && (
|
||||
<div className="bg-gray-100 rounded-full h-1.5 mb-3">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all ${atLimit ? 'bg-danger-600' : 'bg-primary-600'}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{usage.services.map(svc => {
|
||||
const cfg = serviceMetadata.find(s => s.id === svc.icon_id)
|
||||
return (
|
||||
<div key={svc.key} className="bg-gray-50 rounded-xl p-3 text-center">
|
||||
<div className="flex justify-center mb-1 text-gray-400"><ServiceIcon serviceId={svc.icon_id} /></div>
|
||||
<p className="text-xl font-bold text-gray-800">{svc.count}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-tight">{cfg?.label ?? svc.label}</p>
|
||||
{!svc.counted && <p className="text-xs text-gray-300 mt-0.5">{t('account.services_unlimited')}</p>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{atLimit && !user.is_premium && (
|
||||
<p className="text-xs text-danger-600 mt-2">{t('common:premium.limit_reached')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert type="error" message={error} />
|
||||
|
||||
{/* Premium — bientôt disponible */}
|
||||
{/* Présentation de l'offre Kankwa+ — aide contextuelle, pas une alerte. */}
|
||||
{!user.is_premium && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="text-gray-300 text-xl leading-none mt-0.5">✦</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-500">{t('account.premium_soon_title')}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t('account.premium_soon_desc')}</p>
|
||||
</div>
|
||||
<div className="card-notice">
|
||||
<p className="text-body font-semibold text-ink">{t('account.premium_title')}</p>
|
||||
<p className="text-body-sm text-ink-muted mt-1">{t('account.premium_desc')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gestion — premium */}
|
||||
{user.is_premium && (
|
||||
<button
|
||||
onClick={() => go('portal')}
|
||||
|
|
@ -276,8 +293,7 @@ function AbonnementSection({ usage }: { usage: UsageStats | null }) {
|
|||
{loading === 'portal' ? t('account.managing') : t('account.manage_subscription')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AccountSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -304,24 +320,21 @@ function PostEventEmailsSection() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900">{t('account.post_event_title')}</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('account.post_event_description')}</p>
|
||||
</div>
|
||||
<AccountSection title={t('account.post_event_title')}>
|
||||
<p className="-mt-2 text-body-sm text-ink-muted">{t('account.post_event_description')}</p>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={loading}
|
||||
onChange={event => void update(event.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600"
|
||||
className="mt-0.5 h-4 w-4 rounded border-line-strong text-primary-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{t('account.post_event_consent')}</span>
|
||||
<span className="text-body-sm text-ink-muted">{t('account.post_event_consent')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-400">{t('account.post_event_frequency')}</p>
|
||||
{error && <p className="text-xs text-danger-600">{error}</p>}
|
||||
</div>
|
||||
<p className="text-body-sm text-ink-subtle">{t('account.post_event_frequency')}</p>
|
||||
{error && <p className="text-body-sm text-danger-600">{error}</p>}
|
||||
</AccountSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -352,11 +365,11 @@ function DeleteModal({ onClose }: { onClose: () => void }) {
|
|||
<Modal onClose={onClose} title={t('account.delete_modal_title')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Alert type="error" message={error} />
|
||||
<p className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: t('account.delete_modal_desc') }} />
|
||||
<p className="text-body-sm text-ink-muted" dangerouslySetInnerHTML={{ __html: t('account.delete_modal_desc') }} />
|
||||
<form onSubmit={handleDelete} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">
|
||||
<Trans i18nKey="account.delete_confirm_label" t={t} components={{ 1: <span className="font-mono font-semibold text-gray-700" /> }} />
|
||||
<label className="text-body-sm text-ink-muted block mb-1">
|
||||
<Trans i18nKey="account.delete_confirm_label" t={t} components={{ 1: <span className="font-mono font-semibold text-ink" /> }} />
|
||||
</label>
|
||||
<input className="input" value={input} onChange={e => setInput(e.target.value)} autoFocus placeholder={t('account.delete_placeholder')} />
|
||||
</div>
|
||||
|
|
@ -401,24 +414,32 @@ export default function AccountPage() {
|
|||
<main className="page">
|
||||
<ServicePageHeader title={t('account.title')} />
|
||||
|
||||
{confirmMessage && (
|
||||
<div className="mb-4 p-3 bg-success-50 border border-success-200 text-success-700 rounded-xl text-sm">✓ {confirmMessage}</div>
|
||||
)}
|
||||
{confirmError && (
|
||||
<div className="mb-4 p-3 bg-danger-50 border border-danger-200 text-danger-700 rounded-xl text-sm">{confirmError}</div>
|
||||
{(confirmMessage || confirmError) && (
|
||||
<div className="mb-4">
|
||||
<Alert type="success" message={confirmMessage} />
|
||||
<Alert type="error" message={confirmError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5 items-start">
|
||||
<ProfilSection />
|
||||
<SecuriteSection />
|
||||
<AbonnementSection usage={usage} />
|
||||
<PostEventEmailsSection />
|
||||
{/* Hiérarchie arrêtée par l'audit visuel : identité, puis ce que le compte
|
||||
contient, puis ce qu'il coûte, puis sa sécurité et ses préférences.
|
||||
L'ordre du DOM est celui de la lecture à toutes les largeurs. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-cards items-start">
|
||||
<div className="lg:col-span-2 flex flex-col gap-cards">
|
||||
<ProfilSection />
|
||||
<UsageSection usage={usage} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-cards">
|
||||
<AbonnementSection />
|
||||
<SecuriteSection />
|
||||
<PostEventEmailsSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<div className="mt-section flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="text-xs text-gray-300 hover:text-danger-400 transition-colors"
|
||||
className="btn-quiet btn-sm text-ink-subtle hover:text-danger-600"
|
||||
>
|
||||
{t('account.delete_account')}
|
||||
</button>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue