feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y): - Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source for layout/shell (index.css), shadows (design-system.css), durations/easing. - Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500 replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes. - Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls, AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item, TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable. - ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary. - Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts. - Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories. - .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification. - apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b1ed46b142
commit
39b2b642d2
176 changed files with 3525 additions and 362 deletions
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
## 1. Standards Tailwind & Layout
|
||||
|
||||
- **Interdiction formelle** d'utiliser des valeurs arbitraires (ex: `w-[300px]`) pour les espacements, tailles ou marges.
|
||||
- **Interdiction formelle** d'utiliser des valeurs arbitraires (ex: `w-[300px]`, `gap-[7px]`, `rounded-[12px]`, `shadow-[...]`) pour les espacements, tailles, rayons ou ombres sans justification (voir exceptions dans `apps/web/docs/DESIGN_TOKENS.md`).
|
||||
- Utilise exclusivement l'échelle de spacing native de Tailwind ou les **Layout Primitives** définies dans `apps/web/src/index.css` :
|
||||
- `max-w-layout-content` (1600px)
|
||||
- `min-h-layout-main` (calc(100vh - 4rem))
|
||||
- `min-h-layout-page` (600px)
|
||||
- `min-h-layout-page-sm` (400px)
|
||||
- `min-h-layout-story` (192px, pour les stories)
|
||||
- Référence unique : **DESIGN_TOKENS.md** et **APP_SHELL.md** dans `apps/web/docs/` pour layout, espacements, ombres, typo, transitions.
|
||||
|
||||
## 2. Cycle de Vie des Composants (Storybook-First)
|
||||
|
||||
|
|
@ -29,3 +30,9 @@
|
|||
## 4. Audit & Qualité
|
||||
|
||||
- Avant de finaliser une tâche, lance `npm run test:storybook` (depuis `apps/web`) après build + serve sur 6007 pour garantir 0 erreur réseau/console.
|
||||
|
||||
## 5. Tests visuels et valeurs arbitraires
|
||||
|
||||
- **Commandes visuelles** (depuis `apps/web`) : `npm run visual:capture` (capture écrans → `visual-tests/current/`), `npm run visual:compare` (diff vs baselines), `npm run visual:update` (mise à jour des baselines). Procédure détaillée dans `apps/web/visual-tests/README.md`.
|
||||
- **Rapport des valeurs arbitraires** : `node scripts/report-arbitrary-values.mjs` (optionnel `--json` ou `--dir src/features`) pour lister les patterns à migrer ; pas de remplacement automatique sans revue.
|
||||
- **Nouvelle page full layout** : suivre `apps/web/docs/FULL_LAYOUT_PAGE.md` (route, story, MSW, viewport).
|
||||
|
|
|
|||
4
apps/web/.gitignore
vendored
Normal file
4
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Test / E2E artifacts (generated by Playwright visual tests)
|
||||
e2e/test-results-visual/
|
||||
e2e/playwright-report-visual/
|
||||
e2e/playwright-report-visual/**/data/
|
||||
280
apps/web/AUDIT_STRATEGIQUE_FRONTEND_2025.md
Normal file
280
apps/web/AUDIT_STRATEGIQUE_FRONTEND_2025.md
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Audit stratégique frontend — Veza
|
||||
|
||||
**Date** : 2025-02-08
|
||||
**Équipe fictive** : Staff Frontend Engineer, Principal Product Designer, Architecte logiciel senior, Tech Lead perf/a11y/DX, Product Manager.
|
||||
**Objectif** : Diagnostic sans complaisance, écart vs Discord/Spotify, feuille de route et recommandations actionnables.
|
||||
|
||||
---
|
||||
|
||||
## A. Diagnostic brutal mais honnête
|
||||
|
||||
### Niveau global de l’application
|
||||
|
||||
**Verdict : intermédiaire à senior (6/10), avec des poches de niveau amateur et quelques bases solides.**
|
||||
|
||||
Justification en une phrase : *Vous avez mis en place une structure feature-based, un design system documenté, des patterns (skeletons, optimistic UI, MSW), et une batterie de tests/stories, mais l’exécution est inégale, les règles du projet sont régulièrement violées (valeurs arbitraires, double source d’auth), et le ressenti “produit fini” est entamé par des correctifs visibles (fix-*.css, patches dans main.tsx) et une cohérence a11y/UX partielle.*
|
||||
|
||||
- **Architecture** : Correcte en intention (features/, services/, stores/, router lazy), mais **artisanale** dans les détails (double source d’auth, callbacks no-op en route config, stores globaux vs feature stores).
|
||||
- **Qualité du code** : Bonne sur les parties auditées (auth, playlists, player), **dette structurelle** (duplication auth Context vs Store, ~130 fichiers avec valeurs Tailwind arbitraires malgré une règle explicite).
|
||||
- **Performance** : Bonne base (lazy routes, React Query, skeletons), mais pas de stratégie Suspense/streaming ni de priorisation explicite perçue vs mesurée. Voir *Checklist priorisation performance* ci-dessous.
|
||||
- **Design system** : **Solide** sur le papier (KŌDŌ v3, tokens, layout primitives, dark mode), **fragile** en pratique (règles de layout violées massivement, pas de lint pour les primitives).
|
||||
- **UX / micro-interactions** : Suffisante pour un produit interne, **insuffisante** pour un produit “premium” (feedback incohérent, états vides/erreur pas toujours traités de façon homogène).
|
||||
- **Accessibilité** : Présente (aria, roles sur une partie des composants), mais **bonus** et non pilier (a11y Storybook en `todo`, pas de politique WCAG claire).
|
||||
- **DX** : **Correcte** (docs internes, MSW, Storybook centralisé), mais **terrain miné** sur certains sujets (deux façons d’accéder à l’auth, nombreux fix-* à comprendre, seuils de coverage peut‑être non tenus).
|
||||
|
||||
---
|
||||
|
||||
### 5 forces réelles
|
||||
|
||||
1. **Design system pensé et documenté**
|
||||
`index.css` : palette sémantique, layout primitives (`max-w-layout-content`, `min-h-layout-main`, etc.), easings, keyframes, dark mode. Les règles du projet interdisent les valeurs arbitraires et imposent ces primitives — même si non respectées partout, la cible est claire.
|
||||
|
||||
2. **Storybook-first et MSW bien intégrés**
|
||||
Preview avec MSW en mode strict (`onUnhandledRequest: 'error'`), décorateur global (Theme, QueryClient, Router, Toast, Audio, Auth, i18n), pas d’import de providers dans les stories. Handlers MSW très complets (~1600 lignes). Environ 324 stories : bonne couverture de surface.
|
||||
|
||||
3. **Patterns de chargement et d’erreur explicites**
|
||||
`LOADING_STATES_PATTERN.md`, composant `LoadingState` (spinner / inline / skeleton / minimal), skeletons dédiés par page (UserProfilePage, CloudFileBrowser, CourseDetailView, etc.). Données servies par React Query avec états loading/error gérés au niveau page.
|
||||
|
||||
4. **Optimistic UI et utilitaires métier**
|
||||
`OPTIMISTIC_UPDATES.md` et `optimisticUpdates.ts` (createOptimisticUpdate, createArrayOptimisticUpdate, createToggleOptimisticUpdate). Utilisation dans playlists, likes, commentaires. Rollback et invalidation documentés.
|
||||
|
||||
5. **Surface de tests unitaires et d’intégration**
|
||||
~186 fichiers `*.test.tsx` et ~100 `*.test.ts`, intégration auth/playlists/player, Vitest avec coverage (seuils 80 % configurés). E2E Playwright et visuels présents. Base saine pour monter en rigueur.
|
||||
|
||||
---
|
||||
|
||||
### 5 faiblesses structurelles majeures
|
||||
|
||||
1. **Double source de vérité pour l’auth**
|
||||
`AuthContext` + `useAuth()` (Storybook, ProtectedRoute, quelques pages) vs `authStore` (Zustand) + `useAuthStore()` (App, Sidebar, Login, etc.). `App.tsx` et hydratation utilisent le store ; le décorateur Storybook utilise le Context. Risque de désync, double appel à l’API, et confusion pour tout nouveau dev. Dette d’architecture prioritaire.
|
||||
|
||||
2. **Règles de layout systématiquement violées**
|
||||
La règle “pas de valeurs arbitraires (w-[…], z-[…], etc.)” et “utiliser les layout primitives” est explicite dans `.cursorrules` et `index.css`, mais environ **130 fichiers** utilisent `w-[`, `h-[`, `z-[`, etc. La Sidebar elle‑même utilise `w-64`, `z-[90]`, `z-[95]`, `left-6`, `top-20`. Le design system est sous‑exploité et la cohérence visuelle fragilisée.
|
||||
|
||||
3. **Correctifs applicatifs visibles et fragiles**
|
||||
`main.tsx` et `App.tsx` chargent plusieurs correctifs : `fix-input-focus.css`, `fix-login-form.css`, `fixDisplayIssues`, `fixInputFocus`. Cela indique des problèmes de conception (focus, formulaires) résolus par patch plutôt qu’à la source. Coût de maintenance et risque de régression élevé.
|
||||
|
||||
4. **Route config et couplage produit**
|
||||
`routeConfig.tsx` injecte des callbacks vides dans les éléments de route : `onCreateProduct={() => {}}`, `onNavigateTrack={() => {}}`. La routing ne doit pas porter de callbacks métier ; ces props devraient remonter d’un layout ou d’un contexte. Signe que la frontière entre routing et feature n’est pas claire.
|
||||
|
||||
5. **Accessibilité et qualité perçue en option**
|
||||
Storybook : `a11y: { test: 'todo' }`. Beaucoup de composants ont des `aria-*` ou `role`, mais de façon non systématique (ex. `KodoEmptyState` sans role/aria pour l’état vide, Sidebar sans `role="navigation"` ni `aria-label`). Pas de politique WCAG ni de critères de sortie a11y. L’accessibilité reste un “plus” et non un pilier, ce qui limite la maturité produit et l’inclusivité.
|
||||
|
||||
---
|
||||
|
||||
## B. Écart avec Discord & Spotify
|
||||
|
||||
Tableau comparatif **technique et concret** (pas d’idéalisation).
|
||||
|
||||
| Dimension | Discord (référence) | Spotify (référence) | Veza (état actuel) | Écart principal |
|
||||
|----------|--------------------|--------------------|--------------------|------------------|
|
||||
| **Architecture** | Domains clairs, state par feature, peu de global ; routing découplé du métier. | App shell léger, données par view, cache agressif. | Features + stores globaux + double auth (Context + Store). | Une seule source d’auth ; pas de callbacks dans la route config ; frontière nette layout/feature. |
|
||||
| **UX** | Feedback immédiat, états loading/empty/error partout, modales et transitions prévisibles. | Transitions fluides, skeletons systématiques, pas de “trous” visuels. | Skeletons et LoadingState présents mais pas partout ; empty/error variables ; correctifs CSS. | Checklist loading/empty/error par écran ; suppression des fix-* par refonte des composants concernés. |
|
||||
| **Performance** | Time to interactive court, lazy lourd, prioritisation du above-the-fold. | Streaming / progressive, cache et préchargement. | Lazy routes OK ; pas de Suspense boundary au niveau route ; pas de stratégie “perçu vs mesuré”. | Suspense + skeletons aux limites de route ; métriques perçues (LCP, INP) et objectifs chiffrés. |
|
||||
| **Design** | Cohérence stricte, tokens partout, pas de valeurs en dur. | Design system unique, thème et spacing prévisibles. | Tokens et primitives définis mais ~130 fichiers en arbitraire ; primitives peu utilisées. | Lint (ou rule) qui interdit les arbitraires ; migration progressive vers primitives. |
|
||||
| **Sensation de qualité** | App “qui ne bugue pas”, erreurs gracieuses, pas de demi-mesures visibles. | Polish, micro-interactions, pas de formulaires “cassés” en prod. | Correctifs visibles (fix-*), callbacks no-op, incohérences a11y. | Réduire la dette visible : un correctif = une issue de refonte ; zero no-op dans la config. |
|
||||
|
||||
En résumé : **Veza a les briques (design system, patterns, tests, Storybook)** mais manque de **rigueur d’exécution** (une seule auth, respect des règles de layout, accessibilité et UX traitées comme non négociables) et de **finition** (plus de polish, moins de patches) pour se rapprocher de Discord/Spotify.
|
||||
|
||||
---
|
||||
|
||||
### Checklist priorisation performance
|
||||
|
||||
À traiter dans cet ordre pour maximiser l’impact ressenti sans tout refactorer :
|
||||
|
||||
1. **First meaningful paint**
|
||||
- Éviter tout JS bloquant avant le premier rendu (splitting par route déjà en place).
|
||||
- S’assurer que le shell (header + sidebar ou placeholder) + un skeleton de contenu s’affichent sans attendre les données.
|
||||
|
||||
2. **Skeletons systématiques**
|
||||
- Chaque page qui fetch des données doit afficher un skeleton dédié (pas un spinner générique) pendant le chargement.
|
||||
- Déjà partiellement fait ; compléter les écrans manquants (voir Phase 2).
|
||||
|
||||
3. **Optimistic UI pour les actions fréquentes**
|
||||
- Like, follow, add to playlist, etc. : mise à jour immédiate + rollback si erreur.
|
||||
- Déjà documenté et utilisé ; étendre aux actions qui n’ont pas encore le pattern.
|
||||
|
||||
4. **Réduction des re-renders inutiles**
|
||||
- Vérifier les composants lourds (listes, tableaux) : memo, sélecteurs Zustand fins, ou découpage pour limiter les sous-arbre qui se re-rendent.
|
||||
- React Query évite déjà des refetch inutiles si `staleTime` est configuré ; auditer les queryKeys et invalidation pour éviter des cascades.
|
||||
|
||||
5. **Métriques et objectifs**
|
||||
- Mesurer LCP, INP (ou FID), TTI sur les parcours critiques (login, dashboard, lecture).
|
||||
- Fixer des objectifs (ex. LCP < 2.5 s sur 3G) et un process de revue en cas de régression.
|
||||
|
||||
6. **Suspense aux limites de route**
|
||||
- Envelopper les arbres de route dans `<Suspense fallback={<PageSkeleton />}>` pour que le navigateur puisse afficher le fallback sans attendre tout le bundle enfant.
|
||||
- Optionnel mais utile dès que le bundle par route grossit.
|
||||
|
||||
---
|
||||
|
||||
## C. Feuille de route de transformation
|
||||
|
||||
### Phase 1 — Fondations (ce qui doit être corrigé avant tout)
|
||||
|
||||
**Objectifs**
|
||||
- Une seule source de vérité pour l’auth.
|
||||
- Arrêt des correctifs “magiques” et renforcement du respect du design system.
|
||||
|
||||
**Actions concrètes**
|
||||
1. **Unifier l’auth**
|
||||
- Décider : soit tout sur Zustand (authStore), soit tout sur Context (AuthProvider). Recommandation : **Zustand** (déjà utilisé par App, hydratation, Sidebar).
|
||||
- Migrer tous les usages de `useAuth()` vers `useAuthStore()` (ou l’inverse si vous choisissez Context).
|
||||
- Faire du AuthProvider un simple consommateur du store (s’il est gardé pour Storybook/compat).
|
||||
- Supprimer la duplication et documenter la décision dans un ADR.
|
||||
|
||||
2. **Supprimer les callbacks de la route config**
|
||||
- Retirer `onCreateProduct`, `onNavigateTrack`, etc. des éléments de route.
|
||||
- Fournir ces comportements via layout (contexte ou props depuis un parent commun) ou navigation programmatique + state.
|
||||
|
||||
3. **Remplacer les fix-* par des corrections à la source**
|
||||
- Pour chaque `fix-input-focus`, `fix-login-form`, etc. : identifier le composant (formulaire, input, focus trap) et corriger le focus, les états et le style dans le composant ou le design system.
|
||||
- Supprimer les imports de fix-* une fois le comportement correct par défaut.
|
||||
|
||||
4. **Lint / règle pour les valeurs arbitraires**
|
||||
- Règle ESLint (ou plugin Tailwind) interdisant les classes du type `w-[...]`, `h-[...]`, `z-[...]` pour layout/spacing (avec liste d’exceptions si besoin).
|
||||
- Introduire les exceptions progressivement et traiter les ~130 fichiers par batch (par répertoire ou par feature).
|
||||
|
||||
**Risques**
|
||||
- Régression auth si la migration n’est pas testée (E2E login/logout, refresh, Storybook).
|
||||
- Régressions visuelles si on supprime les fix-* sans vérifier tous les écrans.
|
||||
|
||||
**Indicateurs de réussite**
|
||||
- Un seul mécanisme d’auth utilisé partout ; plus d’import de fix-* dans `main.tsx`/`App.tsx`.
|
||||
- Aucun callback métier dans `routeConfig.tsx`.
|
||||
- Lint en place et au moins un premier batch (ex. `components/layout`) sans violations.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Stabilisation et cohérence
|
||||
|
||||
**Objectifs**
|
||||
- Loading / empty / error systématiques.
|
||||
- Accessibilité traitée comme exigence, pas comme option.
|
||||
- Design system respecté (primitives, tokens).
|
||||
|
||||
**Actions concrètes**
|
||||
1. **Checklist par écran**
|
||||
- Pour chaque page/feature : état Loading (skeleton dédié ou générique), Empty (KodoEmptyState ou équivalent), Error (ErrorDisplay + retry).
|
||||
- Documenter la checklist et l’appliquer aux nouvelles features ; remédier les écrans existants sans état manquant.
|
||||
|
||||
2. **Accessibilité**
|
||||
- Remplacer `a11y: { test: 'todo' }` par une config réelle dans Storybook (règles axe-core, seuils).
|
||||
- Définir un niveau cible (ex. WCAG 2.1 AA) et une liste de composants critiques (formulaires, navigation, player).
|
||||
- Corriger Sidebar (role="navigation", aria-label), KodoEmptyState (role/aria pour empty state), et autres composants identifiés par un audit axe.
|
||||
|
||||
3. **Migration vers layout primitives**
|
||||
- Remplacer progressivement les valeurs arbitraires par les classes du design system (`max-w-layout-content`, `min-h-layout-main`, etc.) et l’échelle Tailwind.
|
||||
- Prioriser : layout (Sidebar, Navbar, content), puis composants partagés (modales, cartes).
|
||||
|
||||
4. **Optimistic UI et erreurs**
|
||||
- Vérifier que tous les usages d’optimistic updates ont un rollback et un feedback utilisateur en cas d’échec (toast, réversion visuelle).
|
||||
- Centraliser la logique d’erreur (ex. `apiToastHelper`, ErrorDisplay) pour éviter des traitements ad hoc.
|
||||
|
||||
**Risques**
|
||||
- Scope trop large si on veut tout migrer d’un coup ; prioriser par trafic ou par criticité.
|
||||
|
||||
**Indicateurs de réussite**
|
||||
- Chaque écran couvert par la checklist loading/empty/error.
|
||||
- Storybook a11y activé et 0 violation sur les stories des composants critiques.
|
||||
- Réduction mesurée du nombre de violations “arbitraires” (objectif chiffré par sprint).
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Polish et excellence perçue
|
||||
|
||||
**Objectifs**
|
||||
- Transitions et micro-interactions cohérentes.
|
||||
- Performance perçue (skeleton, priorisation) et mesurée (LCP, INP).
|
||||
- Réduction des “petits bugs” visuels et comportementaux.
|
||||
|
||||
**Actions concrètes**
|
||||
1. **Suspense et streaming**
|
||||
- Définir des Suspense boundaries au niveau des routes (ou des sous-arbres) avec fallback skeleton.
|
||||
- Vérifier que le premier paint utile (header + skeleton contenu) est rapide.
|
||||
|
||||
2. **Métriques et objectifs**
|
||||
- Suivre LCP, INP (ou FID), TTI.
|
||||
- Définir des objectifs (ex. LCP < 2.5 s sur 3G) et un process de revue si dégradation.
|
||||
|
||||
3. **Micro-interactions**
|
||||
- Utiliser les variables déjà définies (`--duration-fast`, `--ease-out`, etc.) partout où c’est pertinent (hover, focus, modales).
|
||||
- Passer en revue les CTA et les listes interactives pour un feedback immédiat (disabled + spinner ou état “loading” sur le bouton).
|
||||
|
||||
4. **Nettoyage produit**
|
||||
- Supprimer ou documenter tout code mort et composants non utilisés.
|
||||
- S’assurer qu’aucun no-op ou placeholder visible ne reste dans les parcours principaux.
|
||||
|
||||
**Risques**
|
||||
- Sur-optimisation sans mesure ; garder les métriques comme garde-fou.
|
||||
|
||||
**Indicateurs de réussite**
|
||||
- LCP/INP dans la cible sur les parcours critiques.
|
||||
- Pas de régression a11y ; design system respecté sur les nouvelles livraisons.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Scalabilité et long terme
|
||||
|
||||
**Objectifs**
|
||||
- Code prêt pour 10× fonctionnalités et 10× équipe.
|
||||
- Dette technique visible et pilotée.
|
||||
- Expérimentations (A/B, feature flags) sans tout casser.
|
||||
|
||||
**Actions concrètes**
|
||||
1. **Frontières de domaines**
|
||||
- Clarifier ce qui vit dans `features/` vs `components/` (ex. pas de duplication forms auth dans les deux).
|
||||
- Règle simple : composants réutilisables et design system dans `components/` ; logique métier et pages dans `features/`.
|
||||
|
||||
2. **State et données**
|
||||
- Documenter quand utiliser React Query vs Zustand (server state vs client state).
|
||||
- Éviter de dupliquer des données serveur dans des stores globaux (library, etc.) sans stratégie d’invalidation claire.
|
||||
|
||||
3. **Feature flags et expérimentations**
|
||||
- Introduire un mécanisme léger de feature flags (build-time ou runtime) pour découpler déploiement et activation.
|
||||
- Tester une feature derrière flag sans impacter les parcours principaux.
|
||||
|
||||
4. **Documentation et onboarding**
|
||||
- Un README frontend à jour (structure, auth unique, design system, comment lancer Storybook et tests).
|
||||
- Créer ou mettre à jour `docs/STORYBOOK_CONTRACT.md` (référencé dans le code) : états requis (Loading, Error, Empty), pas de providers dans les stories, MSW pour l’API.
|
||||
|
||||
**Risques**
|
||||
- Refactors trop larges ; privilégier des incréments et des ADR pour les décisions d’architecture.
|
||||
|
||||
**Indicateurs de réussite**
|
||||
- Nouveau dev opérationnel (première feature livrée) en un temps cible (ex. < 2 jours).
|
||||
- Dette listée et priorisée (ex. backlog “dette” avec critères de sortie).
|
||||
|
||||
---
|
||||
|
||||
## D. Recommandations non évidentes
|
||||
|
||||
- **Ne pas “nettoyer” tout le code arbitraire d’un coup.**
|
||||
Une équipe junior aurait tendance à tout remplacer en un big bang. Mieux vaut : lint qui bloque en *nouveau* code, et migration par zones (layout d’abord, puis composants partagés). Les 130 fichiers se traitent par lots avec revue visuelle et régression.
|
||||
|
||||
- **Traiter l’auth comme un projet de migration, pas comme une tâche.**
|
||||
Faire un inventaire exhaustif (grep `useAuth` / `useAuthStore`), un plan de migration (ordre des fichiers), des tests E2E et Storybook qui valident login/logout/refresh, puis migrer avec une PR par zone. Un “quick fix” (tout basculer d’un coup) sans tests est risqué.
|
||||
|
||||
- **Ne pas activer a11y dans Storybook sans budget de correction.**
|
||||
Activer axe avec des règles strictes du jour au lendemain va faire exploser les “failed”. Mieux : activer sur un sous-ensemble de stories (ex. composants ui/ + layout), corriger ces stories, puis élargir. a11y doit devenir un critère de merge sur les composants critiques.
|
||||
|
||||
- **Documenter pourquoi chaque fix-* existe.**
|
||||
Avant de les supprimer, ajouter un commentaire ou un petit doc : “fix-login-form existe parce que …”. Cela évite de recréer le même patch plus tard et guide la refonte (quel composant doit prendre en charge le focus, etc.).
|
||||
|
||||
- **Mettre des indicateurs de “maturité” par feature.**
|
||||
Une grille simple (loading / empty / error / a11y / tests / Storybook) par feature ou page. Permet de prioriser les remédiations et de ne pas prétendre que “tout est à niveau” alors que certaines zones sont encore fragiles.
|
||||
|
||||
- **Réserver du temps “dette” dans chaque sprint.**
|
||||
Un projet “correct” reporte la dette ; un projet qui vise l’excellence en consacre une part fixe (ex. 20 %). Chaque sprint : X violations arbitraires en moins, Y écrans avec checklist complète, Z stories avec a11y passante.
|
||||
|
||||
- **Éviter d’ajouter de nouveaux stores globaux “au fil de l’eau”.**
|
||||
Vous avez déjà auth, ui, library, cart, chat. Avant d’en ajouter un nouveau : vérifier si React Query + état local (ou store de feature) ne suffit pas. Les stores globaux multiplient les sources de vérité et compliquent le debugging et la cohérence.
|
||||
|
||||
---
|
||||
|
||||
## Synthèse
|
||||
|
||||
- **Niveau actuel** : Intermédiaire à senior (6/10), avec de vraies bases (design system, patterns, MSW, tests, Storybook) et des faiblesses structurelles (double auth, violations de layout, correctifs, a11y en option).
|
||||
- **Écart Discord/Spotify** : Moins une question de stack que de **rigueur d’exécution** (une auth, respect des règles, a11y et UX non négociables) et de **finition** (moins de patches, plus de polish).
|
||||
- **Ordre recommandé** : Fondations (auth, route config, fix-*, lint) → Stabilisation (checklist loading/empty/error, a11y, primitives) → Polish (Suspense, métriques, micro-interactions) → Scalabilité (frontières, state, flags, doc).
|
||||
- **Ton** : Exigeant mais réaliste : le projet peut viser un niveau “world-class” à condition de traiter la dette identifiée comme un plan structuré et de ne pas ajouter de nouvelle dette sur l’auth et le layout.
|
||||
|
|
@ -96,6 +96,8 @@ npm run validate:all
|
|||
### Code Quality
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run lint:fix` - Fix ESLint issues
|
||||
- `npm run lint:ui` - Run ESLint on `src/components` and `src/features` only
|
||||
- `npm run report:arbitrary` - Report Tailwind arbitrary values (w-[...], gap-[...], etc.) for migration
|
||||
- `npm run typecheck` - Type check without emitting files
|
||||
- `npm run fmt` - Format code with Prettier
|
||||
|
||||
|
|
@ -127,14 +129,14 @@ apps/web/
|
|||
|
||||
## Design System
|
||||
|
||||
The application uses the Kodo design system:
|
||||
The application uses the Kodo design system. **Single source of truth** for layout, spacing, shadows, and transitions: `docs/DESIGN_TOKENS.md`. Shell layout: `docs/APP_SHELL.md`.
|
||||
|
||||
- **Colors**: Kodo color palette (see `src/styles/COLOR_USAGE.md`)
|
||||
- **Components**: Design system components in `src/components/ui/`
|
||||
- **Typography**: Type scale system (see `src/styles/TYPOGRAPHY_GUIDE.md`)
|
||||
- **Spacing**: Spacing scale system (see `src/styles/SPACING_GUIDE.md`)
|
||||
- **Typography**: Type scale and hierarchy (see `docs/DESIGN_TOKENS.md`, `src/styles/TYPOGRAPHY_GUIDE.md`)
|
||||
- **Spacing**: Spacing scale (see `docs/SPACING_GUIDE.md`) — no arbitrary values (e.g. `w-[300px]`, `gap-[7px]`) without justification.
|
||||
|
||||
See `src/components/COMPONENT_USAGE.md` for component usage guidelines.
|
||||
**Visual regression**: `npm run visual:capture`, `npm run visual:compare`, `npm run visual:update` (see `visual-tests/README.md`). **Arbitrary values report**: `npm run report:arbitrary` to list Tailwind arbitrary patterns for migration. **New full-layout page**: see `docs/FULL_LAYOUT_PAGE.md`.
|
||||
|
||||
## ESLint Rules
|
||||
|
||||
|
|
|
|||
68
apps/web/docs/APP_SHELL.md
Normal file
68
apps/web/docs/APP_SHELL.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# App Shell — Référence
|
||||
|
||||
Vue d’ensemble du shell applicatif (layout principal) et des tokens CSS associés. Toute évolution du shell (sidebar, header, main, player) doit s’appuyer sur ces variables et classes pour rester cohérente.
|
||||
|
||||
## Rôle du shell
|
||||
|
||||
- **Sidebar** : navigation fixe à gauche (largeur pilotée par `--sidebar-width-expanded` / `--sidebar-width-collapsed`). Voir [index.css](../src/index.css) section "Sidebar layout".
|
||||
- **Header** : barre fixe en haut (hauteur `--header-height`), dont le bord gauche suit la sidebar (expanded/collapsed).
|
||||
- **Main** : zone scrollable (contenu des pages). Marges gauche pilotées par l’état de la sidebar ; padding top/bottom pour ne pas passer sous le header ni le player.
|
||||
- **Player** : barre de lecture fixe en bas (rendue par `GlobalPlayer` dans `DashboardLayout`).
|
||||
|
||||
Fichiers principaux :
|
||||
|
||||
- [DashboardLayout.tsx](../src/components/layout/DashboardLayout.tsx) — assemblage Sidebar, zone main, Header, GlobalPlayer.
|
||||
- [Header.tsx](../src/components/layout/Header.tsx) — barre supérieure (recherche, actions, user menu).
|
||||
|
||||
## Variables CSS (shell)
|
||||
|
||||
Définies dans `:root` dans [index.css](../src/index.css), section "App shell" :
|
||||
|
||||
| Variable | Valeur | Rôle |
|
||||
|----------|--------|------|
|
||||
| `--header-height` | 4rem | Hauteur de la barre header fixe |
|
||||
| `--main-offset-top` | 5rem | Padding-top du `<main>` (dégager le header) |
|
||||
| `--main-offset-bottom` | 8rem | Padding-bottom du `<main>` (réserve pour le player) |
|
||||
| `--main-margin-left-expanded` | 18rem | Marge gauche du conteneur main quand sidebar ouverte (15rem sidebar + 3rem gap) |
|
||||
| `--main-margin-left-collapsed` | 7rem | Marge gauche du main quand sidebar fermée (5rem + 2rem gap) |
|
||||
| `--header-left-expanded` | 18rem | Position `left` de la barre header quand sidebar ouverte |
|
||||
| `--header-left-collapsed` | 5rem | Position `left` de la barre header quand sidebar fermée |
|
||||
|
||||
Les largeurs sidebar sont définies à part : `--sidebar-width-expanded` (15rem), `--sidebar-width-collapsed` (5rem). Pour garder la cohérence, ne pas modifier les marges main/header sans ajuster ces tokens de façon cohérente.
|
||||
|
||||
## Classes utilitaires (shell)
|
||||
|
||||
Définies dans [index.css](../src/index.css) après les classes sidebar (`.left-sidebar`, `.z-sidebar`, etc.) :
|
||||
|
||||
| Classe | Propriété | Usage |
|
||||
|--------|-----------|--------|
|
||||
| `.h-header` | height | Barre header et div interne du header |
|
||||
| `.pt-main` | padding-top | Élément `<main>` |
|
||||
| `.pb-main` | padding-bottom | Élément `<main>` |
|
||||
| `.ml-main-expanded` | margin-left | Conteneur principal (avec préfixe `lg:`) quand sidebar ouverte |
|
||||
| `.ml-main-collapsed` | margin-left | Conteneur principal (avec `lg:`) quand sidebar fermée |
|
||||
| `.left-header-expanded` | left | Barre header quand sidebar ouverte |
|
||||
| `.left-header-collapsed` | left | Barre header quand sidebar fermée |
|
||||
|
||||
À utiliser avec le préfixe responsive `lg:` pour les marges/positions desktop (ex. `lg:ml-main-expanded`). En dessous de `lg`, la sidebar est en overlay et le main utilise `ml-0`, le header `left-0` (`max-lg:left-0`).
|
||||
|
||||
## Comportement responsive
|
||||
|
||||
- **lg (1024px et plus)** : sidebar fixe à gauche, main et header utilisent les classes tokenisées (expanded/collapsed selon `sidebarOpen`).
|
||||
- **En dessous de lg** : sidebar en overlay (ouverte/fermée par toggle), conteneur main en pleine largeur (`ml-0`), header en pleine largeur (`max-lg:left-0`).
|
||||
|
||||
## Breakpoints et viewports de test
|
||||
|
||||
| Breakpoint | Largeur | Comportement attendu |
|
||||
|------------|---------|----------------------|
|
||||
| Mobile | 320px | Sidebar overlay, main pleine largeur, header pleine largeur. |
|
||||
| Tablet | 768px | Idem (sidebar overlay jusqu’à lg). |
|
||||
| Desktop | 1024px (lg) | Sidebar fixe, main et header avec marges/positions tokenisées. |
|
||||
| Large desktop | 1280px | Même comportement que lg, contenu limité par `max-w-layout-content` si applicable. |
|
||||
|
||||
Vérifier visuellement (ou via tests Playwright) que Sidebar, Header et Main se comportent correctement sur ces largeurs.
|
||||
|
||||
## Référence croisée
|
||||
|
||||
- Tokens sidebar (largeurs, offsets, z-index) : [index.css](../src/index.css) — "Sidebar layout" et classes `.w-sidebar-expanded`, `.left-sidebar`, `.top-sidebar`, `.bottom-sidebar`, `.z-sidebar`, `.z-sidebar-overlay`.
|
||||
- Layout primitives (max-width contenu, min-height pages) : mêmes variables `--layout-content-max-width`, `--layout-page-min-height`, etc. Le `<main>` contient un wrapper `max-w-layout-content` pour le contenu.
|
||||
109
apps/web/docs/DESIGN_TOKENS.md
Normal file
109
apps/web/docs/DESIGN_TOKENS.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Design Tokens — Référence
|
||||
|
||||
Source de vérité pour les tokens du design system Veza/Kodo. Toute valeur d’espacement, couleur, ombre, typographie ou transition doit s’appuyer sur ces tokens ou sur l’échelle Tailwind documentée ici.
|
||||
|
||||
## 1. Espacements (layout et sections)
|
||||
|
||||
Définis dans [index.css](../src/index.css) (`:root`) et [design-tokens.css](../src/styles/design-tokens.css) (scale Tailwind).
|
||||
|
||||
| Variable | Valeur | Rôle |
|
||||
|----------|--------|------|
|
||||
| `--layout-gap` | 1rem (16px) | Gap par défaut entre sections (align Tailwind gap-4) |
|
||||
| `--layout-gap-sm` | 0.75rem (12px) | Gap serré (gap-3) |
|
||||
| `--layout-gap-lg` | 1.5rem (24px) | Gap large (gap-6) |
|
||||
|
||||
Scale complète (4px base) : voir [SPACING_GUIDE.md](SPACING_GUIDE.md) et `design-tokens.css` (`--spacing-0` à `--spacing-96`). Utiliser les classes Tailwind `gap-*`, `p-*`, `m-*`, `space-*` de préférence aux valeurs arbitraires.
|
||||
|
||||
## 2. Couleurs
|
||||
|
||||
- **Kodo (primaire)** : [index.css](../src/index.css) — `--background`, `--foreground`, `--primary`, `--muted`, `--border`, etc.
|
||||
- **Sidebar** : `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, etc. (index.css).
|
||||
- **Mapping et usage** : [src/styles/COLOR_USAGE.md](../src/styles/COLOR_USAGE.md).
|
||||
|
||||
Ne pas utiliser les couleurs par défaut Tailwind (slate, gray, zinc, etc.) ; privilégier les tokens Kodo (kodo-void, kodo-cyan, etc.).
|
||||
|
||||
## 3. Typographie
|
||||
|
||||
Échelle Tailwind utilisée (tailles de texte) :
|
||||
|
||||
| Classe | Taille (rem) | Usage |
|
||||
|--------|--------------|--------|
|
||||
| `text-xs` | 0.75rem | Caption, labels secondaires |
|
||||
| `text-sm` | 0.875rem | Labels, métadonnées |
|
||||
| `text-base` | 1rem | Corps de texte |
|
||||
| `text-lg` | 1.125rem | Sous-titres |
|
||||
| `text-xl` | 1.25rem | Titres de section |
|
||||
| `text-2xl` | 1.5rem | Titres de page |
|
||||
| `text-3xl` | 1.875rem | Titres principaux |
|
||||
| `text-4xl` | 2.25rem | Hero / titres très visibles |
|
||||
|
||||
Line-height et font-weight : suivre les utilitaires Tailwind (`leading-*`, `font-normal`, `font-medium`, `font-semibold`, `font-bold`). Ne pas utiliser de tailles arbitraires (`text-[14px]`, etc.) sauf exception documentée (ex. SVG charts).
|
||||
|
||||
### Hiérarchie visuelle (usage recommandé)
|
||||
|
||||
| Rôle | Classe | Contexte |
|
||||
|------|--------|----------|
|
||||
| Titre de page | `text-2xl` ou `text-3xl` + `font-bold` | H1 de la page (Dashboard, Playlists, etc.) |
|
||||
| Sous-titre / section | `text-lg` ou `text-xl` | Titres de blocs |
|
||||
| Label / métadonnée | `text-sm` | Labels de champs, infos secondaires |
|
||||
| Corps | `text-base` | Texte courant |
|
||||
| Caption / timestamps | `text-xs` | Légendes, durées, timestamps |
|
||||
|
||||
Contraste : privilégier `text-foreground` pour l’info principale et `text-muted-foreground` pour l’info secondaire (style Spotify : titre + artiste, description en gris).
|
||||
|
||||
## 4. Ombres
|
||||
|
||||
Définies dans [design-system.css](../src/styles/design-system.css).
|
||||
|
||||
| Variable | Rôle |
|
||||
|----------|------|
|
||||
| `--shadow-sm` | Ombres légères |
|
||||
| `--shadow-md` | Ombres moyennes, tooltips |
|
||||
| `--shadow-lg` | Cartes surélevées |
|
||||
| `--shadow-xl` | Modales |
|
||||
| `--shadow-soft` | Ombres douces |
|
||||
| `--shadow-card` | Cartes (alias `--shadow-soft`) |
|
||||
| `--shadow-modal` | Modales (alias `--shadow-xl`) |
|
||||
| `--shadow-tooltip` | Tooltips (alias `--shadow-md`) |
|
||||
|
||||
Classes utilitaires dans index.css : `.shadow-card`, `.shadow-modal`, `.shadow-tooltip`, `.shadow-card-hover`, `.shadow-card-glow-cyan`, `.shadow-card-glow-magenta`. Tokens additionnels : `--sidebar-active-indicator`, `--button-primary-glow`, `--button-primary-glow-hover`, `--player-thumb-glow`, `--player-hover-glow`, `--queue-item-current-glow`, `--slider-thumb-glow`, `--status-dot-glow-cyan`, `--status-dot-glow-lime`, `--status-dot-glow-magenta` (design-system.css) avec classes `.sidebar-active-indicator`, `.shadow-button-primary-glow`, `.shadow-button-primary-glow-hover`, `.shadow-player-thumb`, `.shadow-player-hover`, `.shadow-queue-item-current`, `.shadow-slider-thumb`, `.shadow-status-dot-cyan`, `.shadow-status-dot-lime`, `.shadow-status-dot-magenta`. Couverture / hero : `--shadow-cover-depth` (`.shadow-cover-depth`). Gaming / XP : `--shadow-gold-glow` (`.shadow-gold-glow`). FAB : `--shadow-fab-glow`, `--shadow-fab-glow-hover` (`.shadow-fab-glow`, `.shadow-fab-glow-hover`).
|
||||
|
||||
## 5. Layout shell (sidebar, header, main)
|
||||
|
||||
Définis dans [index.css](../src/index.css). Référence complète : [APP_SHELL.md](APP_SHELL.md).
|
||||
|
||||
- **Sidebar** : `--sidebar-width-expanded`, `--sidebar-width-collapsed`, `--sidebar-offset-*`, `--sidebar-z-index`.
|
||||
- **Header** : `--header-height`, `--header-left-expanded`, `--header-left-collapsed`.
|
||||
- **Main** : `--main-offset-top`, `--main-offset-bottom`, `--main-margin-left-expanded`, `--main-margin-left-collapsed`.
|
||||
- **Max heights (drawers, panels, lists)** : `--layout-drawer-max-height` (60vh), `--layout-panel-max-height` (70vh), `--layout-list-max-height` (25rem) — classes `.max-h-layout-drawer`, `.max-h-layout-panel`, `.max-h-layout-list`.
|
||||
|
||||
Ne pas définir de variables concurrentes (ex. `--sidebar-width`, `--header-height`) ailleurs ; index.css est la source unique pour le shell.
|
||||
|
||||
## 6. Transitions et animations
|
||||
|
||||
Définies dans [design-system.css](../src/styles/design-system.css).
|
||||
|
||||
| Variable | Valeur | Usage |
|
||||
|----------|--------|--------|
|
||||
| `--duration-instant` | 100ms | Feedback immédiat |
|
||||
| `--duration-fast` | 150ms | Hover, focus |
|
||||
| `--duration-normal` | 250ms | Sidebar, modales |
|
||||
| `--duration-immersive` | 200ms | Micro-interactions (hover, focus) |
|
||||
| `--duration-slow` | 400ms | Animations visibles |
|
||||
| `--duration-slower` | 600ms | Transitions longues |
|
||||
| `--ease-out` | cubic-bezier(0.33, 1, 0.68, 1) | Sortie |
|
||||
| `--ease-in-out` | cubic-bezier(0.65, 0, 0.35, 1) | Entrée/sortie |
|
||||
| `--ease-bounce` | cubic-bezier(0.34, 1.56, 0.64, 1) | Rebond |
|
||||
| `--ease-spring` | cubic-bezier(0.175, 0.885, 0.32, 1.275) | Spring |
|
||||
|
||||
Utiliser ces variables dans les transitions (ex. `transition-all var(--duration-normal) var(--ease-out)`).
|
||||
|
||||
## 7. Exceptions (valeurs arbitraires)
|
||||
|
||||
Les règles ESLint interdisent les classes arbitraires pour `w-[...]`, `h-[...]`, `rounded-[...]`, `shadow-[...]`, etc. Exceptions autorisées avec commentaire `eslint-disable` :
|
||||
|
||||
- SVG ou canvas (ex. `text-[2px]` pour axes de graphiques).
|
||||
- Composants tiers dont on ne contrôle pas le markup.
|
||||
- Cas documentés dans ce fichier ou en PR avec justification.
|
||||
|
||||
Voir aussi : [.cursorrules](../.cursorrules) (layout primitives, pas de valeurs arbitraires pour espacements/tailles).
|
||||
61
apps/web/docs/FULL_LAYOUT_PAGE.md
Normal file
61
apps/web/docs/FULL_LAYOUT_PAGE.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Comment ajouter une nouvelle page full layout
|
||||
|
||||
Ce document décrit comment ajouter une nouvelle page principale dans le shell (DashboardLayout) et sa story full layout dans Storybook.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- La page doit être rendue dans le même shell que Dashboard, Playlists, Library, Settings, Profile : **DashboardLayout** (voir [APP_SHELL.md](APP_SHELL.md)).
|
||||
- Les routes protégées utilisent `wrapProtected` dans [routeConfig.tsx](../src/router/routeConfig.tsx), qui enveloppe le contenu avec `ProtectedLayoutRoute` → `DashboardLayout`.
|
||||
|
||||
## Étapes
|
||||
|
||||
### 1. Route
|
||||
|
||||
Dans [routeConfig.tsx](../src/router/routeConfig.tsx) :
|
||||
|
||||
- Importer (lazy) le composant de page depuis `LazyComponent` ou l’ajouter dans [lazy-component/lazyExports.ts](../src/components/ui/lazy-component/lazyExports.ts).
|
||||
- Ajouter une entrée dans `getProtectedRoutes()` :
|
||||
`{ path: '/votre-page', element: wrapProtected(<LazyVotrePage />) }`.
|
||||
|
||||
### 2. Story full layout
|
||||
|
||||
Dans [DashboardLayout.stories.tsx](../src/components/layout/DashboardLayout.stories.tsx) :
|
||||
|
||||
- Importer la page :
|
||||
`import { VotrePage } from '@/features/...';`
|
||||
- Ajouter une story sur le même modèle que `LibraryFullLayout` ou `SettingsFullLayout` :
|
||||
|
||||
```tsx
|
||||
export const VotrePageFullLayout: Story = {
|
||||
name: 'Votre page – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<VotrePage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/votre-page'] },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. MSW (handlers)
|
||||
|
||||
Si la page charge des données via l’API, ajouter ou réutiliser les handlers dans [handlers.ts](../src/mocks/handlers.ts) pour éviter un loading infini dans Storybook (ex. `http.get('*/api/v1/votre-resource', ...)`).
|
||||
|
||||
### 4. Viewport
|
||||
|
||||
Les stories full layout utilisent le viewport par défaut du meta (desktop). Les captures visuelles Playwright utilisent 1280×720 (voir [visual-tests/README.md](../visual-tests/README.md)). Aucune config supplémentaire n’est nécessaire pour une nouvelle story.
|
||||
|
||||
### 5. Validation visuelle (optionnel)
|
||||
|
||||
Pour inclure la page dans la régression visuelle :
|
||||
|
||||
- Ajouter une entrée dans `SCREENS` dans [e2e/visual-complete.spec.ts](../e2e/visual-complete.spec.ts) si vous voulez une capture full page (ex. `{ name: 'votre-page', url: '/votre-page', auth: true, full: true }`).
|
||||
- Lancer `npm run visual:capture` puis `npm run visual:compare` ; si les changements sont validés, `npm run visual:update` et committer les baselines.
|
||||
|
||||
## Règles UI
|
||||
|
||||
- Pas de valeurs arbitraires (w-[...], h-[...], gap-[...], etc.) : utiliser l’échelle Tailwind ou les tokens (voir [DESIGN_TOKENS.md](DESIGN_TOKENS.md)).
|
||||
- Espacements et typo : respecter [SPACING_GUIDE.md](SPACING_GUIDE.md) et la hiérarchie dans DESIGN_TOKENS.md.
|
||||
30
apps/web/e2e/tests/visual/sidebar.spec.ts
Normal file
30
apps/web/e2e/tests/visual/sidebar.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Visual Regression - Sidebar', () => {
|
||||
test('should match design tokens implementation', async ({ page }) => {
|
||||
// Visit the Storybook iframe directly for isolation
|
||||
// ID derived from title: 'App/Layouts/Sidebar' -> 'app-layouts-sidebar'
|
||||
await page.goto('http://localhost:6006/iframe.html?id=app-layouts-sidebar--default&viewMode=story');
|
||||
|
||||
// Wait for component to be stable
|
||||
await page.waitForSelector('aside');
|
||||
|
||||
// Take snapshot of the entire customized layout (Side bar is fixed position in the story)
|
||||
// We target the aside element directly for the component snapshot
|
||||
const sidebar = page.locator('aside');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Initial State Snapshot
|
||||
await expect(sidebar).toHaveScreenshot('sidebar-default.png');
|
||||
|
||||
// Interaction Test: Hover over an item
|
||||
const studioItem = page.getByText('Cloud Files');
|
||||
await studioItem.hover();
|
||||
|
||||
// Allow animation to complete (transition duration-200)
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Hover State Snapshot
|
||||
await expect(sidebar).toHaveScreenshot('sidebar-hover-item.png');
|
||||
});
|
||||
});
|
||||
171
apps/web/e2e/visual-complete.spec.ts
Normal file
171
apps/web/e2e/visual-complete.spec.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* Suite complète de capture visuelle pour régression pixel-perfect.
|
||||
*
|
||||
* - Boucle sur URLs critiques (Login, Dashboard, Playlists, etc.)
|
||||
* - Auth via storageState pour pages protégées ; pas d'auth pour login/register
|
||||
* - Full page + screenshots ciblés (Sidebar, Player, Header)
|
||||
* - waitForStableNetwork, masquage éléments dynamiques (dates, avatars)
|
||||
* - Nommage : {screen-name}-desktop-dark.png
|
||||
*
|
||||
* Sortie : visual-tests/current/ (visual:capture) ou visual-tests/baselines/ (visual:update)
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { visualOutputDir, screenshotName } from '../playwright.config.visual';
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173';
|
||||
const ANIMATION_SETTLE_MS = 800;
|
||||
const NETWORK_IDLE_MS = 500;
|
||||
|
||||
/** Désactive les animations/transitions CSS pour captures stables */
|
||||
async function disableAnimations(page: import('@playwright/test').Page) {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Force le thème sombre sur le document */
|
||||
async function ensureDarkTheme(page: import('@playwright/test').Page) {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
/** Attend réseau inactif puis un court délai pour éviter skeletons / images en cours de chargement */
|
||||
async function waitForStableNetwork(page: import('@playwright/test').Page) {
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||||
await page.waitForTimeout(NETWORK_IDLE_MS);
|
||||
}
|
||||
|
||||
/** Locators des éléments dynamiques à masquer (dates, avatars, temps) */
|
||||
async function getDynamicMasks(page: import('@playwright/test').Page): Promise<import('@playwright/test').Locator[]> {
|
||||
const candidates = [
|
||||
page.locator('img[alt="Avatar"], img[alt*="avatar"]').first(),
|
||||
page.locator('[role="timer"]').first(),
|
||||
page.locator('time').first(),
|
||||
];
|
||||
const out: import('@playwright/test').Locator[] = [];
|
||||
for (const loc of candidates) {
|
||||
if ((await loc.count()) > 0) out.push(loc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Écrit un screenshot dans visual-tests/current ou baselines */
|
||||
async function saveScreenshot(
|
||||
page: import('@playwright/test').Page,
|
||||
name: string,
|
||||
options: { fullPage?: boolean; locator?: import('@playwright/test').Locator } = {}
|
||||
) {
|
||||
fs.mkdirSync(visualOutputDir, { recursive: true });
|
||||
const filePath = path.join(visualOutputDir, screenshotName(name));
|
||||
const mask = await getDynamicMasks(page);
|
||||
const screenshotOpts = { path: filePath, mask: mask.length > 0 ? mask : undefined };
|
||||
|
||||
if (options.locator) {
|
||||
await options.locator.screenshot(screenshotOpts);
|
||||
} else {
|
||||
await page.screenshot({ fullPage: options.fullPage ?? true, ...screenshotOpts });
|
||||
}
|
||||
test.info().attach(name, { path: filePath, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
const SCREENS: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
auth: boolean;
|
||||
full: boolean;
|
||||
locator?: string;
|
||||
}> = [
|
||||
{ name: 'login', url: '/login', auth: false, full: true },
|
||||
{ name: 'register', url: '/register', auth: false, full: true },
|
||||
{ name: 'dashboard', url: '/dashboard', auth: true, full: true },
|
||||
{ name: 'playlists', url: '/playlists', auth: true, full: true },
|
||||
{ name: 'library', url: '/library', auth: true, full: true },
|
||||
{ name: '404', url: '/non-existent-route-404', auth: false, full: true },
|
||||
];
|
||||
|
||||
const COMPONENT_CAPTURES: Array<{ name: string; url: string; locator: string }> = [
|
||||
{ name: 'sidebar', url: '/dashboard', locator: '[data-testid="app-sidebar"]' },
|
||||
{ name: 'header', url: '/dashboard', locator: 'header' },
|
||||
{ name: 'player', url: '/dashboard', locator: '[data-testid="global-player"]' },
|
||||
];
|
||||
|
||||
test.describe('Visual capture (complete)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' });
|
||||
});
|
||||
|
||||
test.describe('Full-page screens (no auth)', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
for (const screen of SCREENS.filter((s) => !s.auth)) {
|
||||
test(`${screen.name}`, async ({ page }) => {
|
||||
const url = BASE_URL.replace(/\/$/, '') + screen.url;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await waitForStableNetwork(page);
|
||||
await page.waitForSelector('body', { timeout: 8000 }).catch(() => {});
|
||||
|
||||
await disableAnimations(page);
|
||||
await ensureDarkTheme(page);
|
||||
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
||||
|
||||
await saveScreenshot(page, screen.name, { fullPage: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Full-page screens (authenticated)', () => {
|
||||
for (const screen of SCREENS.filter((s) => s.auth)) {
|
||||
test(`${screen.name}`, async ({ page }) => {
|
||||
const url = BASE_URL.replace(/\/$/, '') + screen.url;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await waitForStableNetwork(page);
|
||||
await page.waitForSelector('body', { timeout: 8000 }).catch(() => {});
|
||||
|
||||
await disableAnimations(page);
|
||||
await ensureDarkTheme(page);
|
||||
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
||||
|
||||
await saveScreenshot(page, screen.name, { fullPage: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Component screenshots (authenticated)', () => {
|
||||
for (const comp of COMPONENT_CAPTURES) {
|
||||
test(comp.name, async ({ page }) => {
|
||||
const url = BASE_URL.replace(/\/$/, '') + comp.url;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await waitForStableNetwork(page);
|
||||
|
||||
const loc = page.locator(comp.locator).first();
|
||||
const visible = await loc.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
||||
if (!visible) {
|
||||
test.skip(true, `${comp.name} locator not visible`);
|
||||
return;
|
||||
}
|
||||
|
||||
await disableAnimations(page);
|
||||
await ensureDarkTheme(page);
|
||||
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
||||
|
||||
const mask = await getDynamicMasks(page);
|
||||
fs.mkdirSync(visualOutputDir, { recursive: true });
|
||||
const filePath = path.join(visualOutputDir, screenshotName(comp.name));
|
||||
await loc.screenshot({ path: filePath, mask: mask.length > 0 ? mask : undefined });
|
||||
test.info().attach(comp.name, { path: filePath, contentType: 'image/png' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -206,6 +206,34 @@ export default [js.configs.recommended, {
|
|||
message:
|
||||
'Use the Button component from @/components/ui/button instead of native <button> elements. This ensures consistent styling, accessibility, and design system compliance. For exceptions (e.g., test files, third-party components), add eslint-disable comment.',
|
||||
},
|
||||
// Width: Avoid arbitrary width classes — use layout tokens or scale (w-4, max-w-layout-content, etc.)
|
||||
{
|
||||
selector:
|
||||
"Literal[value=/.*(w-|min-w-|max-w-)\\[[^]]+\\].*/], TemplateElement[value.raw=/.*(w-|min-w-|max-w-)\\[[^]]+\\].*/]",
|
||||
message:
|
||||
'Avoid arbitrary width classes (w-[...], min-w-[...], max-w-[...]). Use scale (w-4, min-w-80) or layout tokens (max-w-layout-content). See docs/DESIGN_TOKENS.md. For exceptions (e.g. SVG), add eslint-disable.',
|
||||
},
|
||||
// Height: Avoid arbitrary height classes
|
||||
{
|
||||
selector:
|
||||
"Literal[value=/.*(h-|min-h-|max-h-)\\[[^]]+\\].*/], TemplateElement[value.raw=/.*(h-|min-h-|max-h-)\\[[^]]+\\].*/]",
|
||||
message:
|
||||
'Avoid arbitrary height classes (h-[...], min-h-[...], max-h-[...]). Use scale (h-4, min-h-8) or layout tokens. See docs/DESIGN_TOKENS.md. For exceptions, add eslint-disable.',
|
||||
},
|
||||
// Rounded: Avoid arbitrary rounded — use rounded, rounded-lg, rounded-xl, rounded-full or var(--radius-xl)
|
||||
{
|
||||
selector:
|
||||
"Literal[value=/.*rounded-\\[[^]]+\\].*/], TemplateElement[value.raw=/.*rounded-\\[[^]]+\\].*/]",
|
||||
message:
|
||||
'Avoid arbitrary rounded classes (rounded-[...]). Use rounded, rounded-lg, rounded-xl, rounded-full or rounded-[var(--radius-xl)]. See docs/DESIGN_TOKENS.md.',
|
||||
},
|
||||
// Shadow: Avoid arbitrary shadow — use tokens (shadow-card, shadow-modal, shadow-lg, etc.)
|
||||
{
|
||||
selector:
|
||||
"Literal[value=/.*shadow-\\[[^]]+\\].*/], TemplateElement[value.raw=/.*shadow-\\[[^]]+\\].*/]",
|
||||
message:
|
||||
'Avoid arbitrary shadow classes (shadow-[...]). Use design tokens (shadow-card, shadow-modal, shadow-button-primary-glow) or Tailwind shadow-lg, shadow-xl. See docs/DESIGN_TOKENS.md. For exceptions, add eslint-disable.',
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@
|
|||
"test:visual": "playwright test --config=playwright.config.visual.ts",
|
||||
"test:visual:update": "playwright test --config=playwright.config.visual.ts --update-snapshots",
|
||||
"test:visual:report": "playwright show-report e2e/playwright-report-visual",
|
||||
"visual:capture": "playwright test --config=playwright.config.visual.ts",
|
||||
"visual:update": "cross-env VISUAL_UPDATE_BASELINES=1 playwright test --config=playwright.config.visual.ts",
|
||||
"visual:baseline": "node scripts/capture-visual-baseline.mjs --baseline",
|
||||
"visual:compare": "node scripts/generate-visual-report.mjs",
|
||||
"visual:test": "playwright test --config=playwright.config.visual.ts",
|
||||
"lint": "eslint . --ext ts,tsx",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"lint:ui": "eslint src/components src/features --ext ts,tsx",
|
||||
"report:arbitrary": "node scripts/report-arbitrary-values.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate:types": "bash ./scripts/generate-types.sh",
|
||||
"validate:schemas": "vitest run src/schemas",
|
||||
|
|
|
|||
|
|
@ -1,29 +1,40 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Playwright config for pixel-perfect visual regression tests.
|
||||
* Playwright config for pixel-perfect visual regression (capture & compare).
|
||||
*
|
||||
* - Fixed viewport and single browser (Chromium) for reproducible screenshots.
|
||||
* - Snapshots stored in e2e/snapshots/ for easy review and CI artifact.
|
||||
* - Optional: run without global auth for login/register snapshots.
|
||||
* - Viewport fixe 1280×720, dark mode forcé, reduced motion.
|
||||
* - Désactivation des animations CSS via inject-style (fixture / beforeEach).
|
||||
* - Sortie : visual-tests/current/ (capture) ou visual-tests/baselines/ (update).
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test --config=playwright.config.visual.ts
|
||||
* Update baselines:
|
||||
* npx playwright test --config=playwright.config.visual.ts --update-snapshots
|
||||
* Usage:
|
||||
* npm run visual:capture → écrit dans visual-tests/current/
|
||||
* npm run visual:update → écrit dans visual-tests/baselines/
|
||||
* npm run visual:compare → script generate-visual-report.mjs (baselines vs current)
|
||||
*/
|
||||
const VIEWPORT = { width: 1280, height: 720 };
|
||||
const THEME = 'dark';
|
||||
const VIEWPORT_LABEL = 'desktop';
|
||||
|
||||
export const visualOutputDir = process.env.VISUAL_UPDATE_BASELINES
|
||||
? path.join(process.cwd(), 'visual-tests', 'baselines')
|
||||
: path.join(process.cwd(), 'visual-tests', 'current');
|
||||
|
||||
export function screenshotName(screenName: string): string {
|
||||
return `${screenName}-${VIEWPORT_LABEL}-${THEME}.png`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests/visual',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
testDir: './e2e',
|
||||
testMatch: /visual-complete\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
timeout: 30000,
|
||||
timeout: 45000,
|
||||
|
||||
outputDir: 'e2e/test-results-visual',
|
||||
snapshotPathTemplate: '{testDir}/__snapshots__/{arg}-{projectName}{ext}',
|
||||
|
||||
reporter: [
|
||||
['html', { outputFolder: 'e2e/playwright-report-visual', open: 'never' }],
|
||||
['list'],
|
||||
|
|
@ -34,10 +45,15 @@ export default defineConfig({
|
|||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
// Fixed viewport for pixel-perfect comparison (no cross-resolution variance)
|
||||
viewport: { width: 1280, height: 720 },
|
||||
// Storage state: set per-test for login (no auth) vs dashboard (auth)
|
||||
storageState: process.env.VISUAL_AUTH_STATE || 'e2e/.auth/user.json',
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
hasTouch: false,
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Europe/Paris',
|
||||
reducedMotion: 'reduce',
|
||||
colorScheme: 'dark',
|
||||
storageState: process.env.VISUAL_NO_AUTH ? undefined : 'e2e/.auth/user.json',
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
|
@ -45,12 +61,10 @@ export default defineConfig({
|
|||
name: 'chromium-desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
hasTouch: false,
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Europe/Paris',
|
||||
reducedMotion: 'reduce',
|
||||
colorScheme: 'dark',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
131
apps/web/scripts/capture-visual-baseline.mjs
Normal file
131
apps/web/scripts/capture-visual-baseline.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Capture visual baseline or current screenshots for pixel-accurate regression.
|
||||
* Usage:
|
||||
* node scripts/capture-visual-baseline.mjs [--baseline]
|
||||
* --baseline Write to visual-tests/baselines/ (default: visual-tests/current/)
|
||||
*
|
||||
* Requires: dev server running at baseURL (default http://localhost:5173).
|
||||
* Set PLAYWRIGHT_BASE_URL or VITE_FRONTEND_URL to override.
|
||||
*
|
||||
* Viewport: 1280×720, dark theme, reduced motion for stability.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173';
|
||||
const OUT_DIR = process.argv.includes('--baseline')
|
||||
? path.join(ROOT, 'visual-tests', 'baselines')
|
||||
: path.join(ROOT, 'visual-tests', 'current');
|
||||
|
||||
const VIEWPORT = { width: 1280, height: 720 };
|
||||
const ANIMATION_SETTLE_MS = 800;
|
||||
|
||||
function ensureDarkTheme(page) {
|
||||
return page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
});
|
||||
}
|
||||
|
||||
async function waitSettle(page, ms = ANIMATION_SETTLE_MS) {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function captureFull(page, name) {
|
||||
await ensureDarkTheme(page);
|
||||
await waitSettle(page);
|
||||
const file = path.join(OUT_DIR, `${name}-desktop.png`);
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
console.log(' captured:', file);
|
||||
}
|
||||
|
||||
async function captureLocator(page, locator, name) {
|
||||
await ensureDarkTheme(page);
|
||||
await waitSettle(page);
|
||||
const file = path.join(OUT_DIR, `${name}-desktop.png`);
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
await locator.screenshot({ path: file });
|
||||
console.log(' captured:', file);
|
||||
}
|
||||
|
||||
const CRITICAL_SCREENS = [
|
||||
{ name: 'login', url: '/login', auth: false, full: true },
|
||||
{ name: 'register', url: '/register', auth: false, full: true },
|
||||
{ name: 'dashboard', url: '/dashboard', auth: true, full: true },
|
||||
{ name: 'sidebar', url: '/dashboard', auth: true, locator: 'aside.fixed' },
|
||||
{ name: 'playlists', url: '/playlists', auth: true, full: true },
|
||||
{ name: 'library', url: '/library', auth: true, full: true },
|
||||
{ name: '404', url: '/non-existent-route-404', auth: false, full: true },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('Capture target:', OUT_DIR);
|
||||
console.log('Base URL:', BASE_URL);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
const storageStatePath = path.join(ROOT, 'e2e', '.auth', 'user.json');
|
||||
const hasAuth = fs.existsSync(storageStatePath);
|
||||
const contextOptions = {
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'en-US',
|
||||
reducedMotion: 'reduce',
|
||||
};
|
||||
if (hasAuth) {
|
||||
try {
|
||||
contextOptions.storageState = storageStatePath;
|
||||
} catch (e) {
|
||||
console.warn('Could not set auth state:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('No e2e/.auth/user.json — authenticated screens may redirect to login.');
|
||||
}
|
||||
|
||||
const context = await browser.newContext(contextOptions);
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
for (const screen of CRITICAL_SCREENS) {
|
||||
if (screen.auth && !hasAuth) {
|
||||
console.log('Skip (auth required):', screen.name);
|
||||
continue;
|
||||
}
|
||||
if (!screen.auth) {
|
||||
await context.clearCookies();
|
||||
}
|
||||
|
||||
const fullUrl = BASE_URL.replace(/\/$/, '') + screen.url;
|
||||
console.log('Open:', screen.name, fullUrl);
|
||||
|
||||
await page.goto(fullUrl, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => {});
|
||||
|
||||
if (screen.locator) {
|
||||
const el = page.locator(screen.locator).first();
|
||||
const visible = await el.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (visible) {
|
||||
await captureLocator(page, el, screen.name);
|
||||
} else {
|
||||
console.log(' skipped: locator not visible');
|
||||
}
|
||||
} else {
|
||||
await page.waitForSelector('body', { timeout: 5000 }).catch(() => {});
|
||||
await captureFull(page, screen.name);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
189
apps/web/scripts/compare-visual.mjs
Normal file
189
apps/web/scripts/compare-visual.mjs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compare visual-tests/baselines/ vs visual-tests/current/ using pixelmatch.
|
||||
* Writes diffs to visual-tests/diffs/ and report to visual-tests/reports/.
|
||||
*
|
||||
* Usage: node scripts/compare-visual.mjs [threshold]
|
||||
* threshold Pixelmatch threshold 0–1 (default: VISUAL_DIFF_THRESHOLD or 0.1)
|
||||
*
|
||||
* Exit code: 0 if no diffs exceed maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, default 0).
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const BASELINES = path.join(ROOT, 'visual-tests', 'baselines');
|
||||
const CURRENT = path.join(ROOT, 'visual-tests', 'current');
|
||||
const DIFFS = path.join(ROOT, 'visual-tests', 'diffs');
|
||||
const REPORTS = path.join(ROOT, 'visual-tests', 'reports');
|
||||
|
||||
const THRESHOLD = parseFloat(process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
|
||||
const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS
|
||||
? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10)
|
||||
: 0;
|
||||
|
||||
function loadPng(filePath) {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return PNG.sync.read(data);
|
||||
}
|
||||
|
||||
function compare(baselinePath, currentPath, diffPath, threshold) {
|
||||
const imgExpected = loadPng(baselinePath);
|
||||
const imgActual = loadPng(currentPath);
|
||||
|
||||
if (imgExpected.width !== imgActual.width || imgExpected.height !== imgActual.height) {
|
||||
return {
|
||||
error: `Dimensions differ: ${imgExpected.width}x${imgExpected.height} vs ${imgActual.width}x${imgActual.height}`,
|
||||
diffPixels: null,
|
||||
totalPixels: imgExpected.width * imgExpected.height,
|
||||
};
|
||||
}
|
||||
|
||||
const { width, height } = imgExpected;
|
||||
const totalPixels = width * height;
|
||||
const diff = new PNG({ width, height });
|
||||
const numDiffPixels = pixelmatch(
|
||||
imgExpected.data,
|
||||
imgActual.data,
|
||||
diff.data,
|
||||
width,
|
||||
height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
|
||||
fs.writeFileSync(diffPath, PNG.sync.write(diff));
|
||||
|
||||
return { diffPixels: numDiffPixels, totalPixels, error: null };
|
||||
}
|
||||
|
||||
function listPngs(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((f) => f.endsWith('.png'));
|
||||
}
|
||||
|
||||
function generateHtmlReport(results, threshold) {
|
||||
const rows = results
|
||||
.map(
|
||||
(r) => `
|
||||
<tr class="${r.pass ? 'pass' : 'fail'}">
|
||||
<td><code>${r.name}</code></td>
|
||||
<td>${r.diffPixels ?? '—'}</td>
|
||||
<td>${r.totalPixels ?? '—'}</td>
|
||||
<td>${r.pass ? 'Pass' : 'Fail'}</td>
|
||||
<td>${r.error ?? ''}</td>
|
||||
<td>
|
||||
${r.diffPath ? `<a href="../diffs/${path.basename(r.diffPath)}">diff</a>` : ''}
|
||||
${r.baselinePath ? `<a href="../baselines/${path.basename(r.baselinePath)}">baseline</a>` : ''}
|
||||
${r.currentPath ? `<a href="../current/${path.basename(r.currentPath)}">current</a>` : ''}
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Visual diff report</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { border: 1px solid #333; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #1a1a1a; color: #eee; }
|
||||
.pass { background: #0d2b0d; }
|
||||
.fail { background: #2b0d0d; }
|
||||
code { font-size: 0.9em; }
|
||||
.meta { color: #888; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Visual diff report</h1>
|
||||
<p class="meta">Threshold: ${threshold} | Max diff pixels (pass): ${MAX_DIFF_PIXELS} | Generated: ${new Date().toISOString()}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Screen</th>
|
||||
<th>Diff pixels</th>
|
||||
<th>Total pixels</th>
|
||||
<th>Result</th>
|
||||
<th>Error</th>
|
||||
<th>Links</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const threshold = parseFloat(process.argv[2] || process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
|
||||
|
||||
console.log('Comparing baselines vs current');
|
||||
console.log(' Threshold:', threshold);
|
||||
console.log(' Max diff pixels (pass):', MAX_DIFF_PIXELS);
|
||||
|
||||
const baselineFiles = listPngs(BASELINES);
|
||||
if (baselineFiles.length === 0) {
|
||||
console.log('No baseline PNGs in visual-tests/baselines/');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const name of baselineFiles) {
|
||||
const baselinePath = path.join(BASELINES, name);
|
||||
const currentPath = path.join(CURRENT, name);
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
results.push({
|
||||
name,
|
||||
baselinePath,
|
||||
currentPath: null,
|
||||
diffPath: null,
|
||||
diffPixels: null,
|
||||
totalPixels: null,
|
||||
error: 'No current file',
|
||||
pass: false,
|
||||
});
|
||||
console.log(name, '— no current file');
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffPath = path.join(DIFFS, name);
|
||||
const out = compare(baselinePath, currentPath, diffPath, threshold);
|
||||
const pass = out.error ? false : (out.diffPixels <= MAX_DIFF_PIXELS);
|
||||
results.push({
|
||||
name,
|
||||
baselinePath,
|
||||
currentPath,
|
||||
diffPath: out.error ? null : diffPath,
|
||||
diffPixels: out.diffPixels,
|
||||
totalPixels: out.totalPixels,
|
||||
error: out.error,
|
||||
pass,
|
||||
});
|
||||
console.log(name, '— diff pixels:', out.diffPixels ?? out.error, pass ? '✓' : '✗');
|
||||
}
|
||||
|
||||
fs.mkdirSync(REPORTS, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(REPORTS, 'results.json'),
|
||||
JSON.stringify({ threshold, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport(results, threshold));
|
||||
console.log('Report:', path.join(REPORTS, 'index.html'));
|
||||
|
||||
const failed = results.filter((r) => !r.pass);
|
||||
process.exit(failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
197
apps/web/scripts/generate-visual-report.mjs
Normal file
197
apps/web/scripts/generate-visual-report.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compare visual-tests/baselines/ vs visual-tests/current/ with pixelmatch.
|
||||
* Produit un rapport JSON et HTML dans visual-tests/reports/ et les diffs dans visual-tests/diffs/.
|
||||
*
|
||||
* Usage: node scripts/generate-visual-report.mjs [threshold]
|
||||
* threshold Sensibilité pixelmatch 0–1 (défaut: VISUAL_DIFF_THRESHOLD ou 0.1)
|
||||
*
|
||||
* Exit code: 0 si aucune diff ne dépasse maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, défaut 0).
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const BASELINES = path.join(ROOT, 'visual-tests', 'baselines');
|
||||
const CURRENT = path.join(ROOT, 'visual-tests', 'current');
|
||||
const DIFFS = path.join(ROOT, 'visual-tests', 'diffs');
|
||||
const REPORTS = path.join(ROOT, 'visual-tests', 'reports');
|
||||
|
||||
const THRESHOLD = parseFloat(process.argv[2] || process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
|
||||
const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS
|
||||
? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10)
|
||||
: 0;
|
||||
|
||||
function loadPng(filePath) {
|
||||
const data = fs.readFileSync(filePath);
|
||||
return PNG.sync.read(data);
|
||||
}
|
||||
|
||||
function compare(baselinePath, currentPath, diffPath, threshold) {
|
||||
const imgExpected = loadPng(baselinePath);
|
||||
const imgActual = loadPng(currentPath);
|
||||
|
||||
if (imgExpected.width !== imgActual.width || imgExpected.height !== imgActual.height) {
|
||||
return {
|
||||
error: `Dimensions differ: ${imgExpected.width}x${imgExpected.height} vs ${imgActual.width}x${imgActual.height}`,
|
||||
diffPixels: null,
|
||||
totalPixels: imgExpected.width * imgExpected.height,
|
||||
};
|
||||
}
|
||||
|
||||
const { width, height } = imgExpected;
|
||||
const totalPixels = width * height;
|
||||
const diff = new PNG({ width, height });
|
||||
const numDiffPixels = pixelmatch(
|
||||
imgExpected.data,
|
||||
imgActual.data,
|
||||
diff.data,
|
||||
width,
|
||||
height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
|
||||
fs.writeFileSync(diffPath, PNG.sync.write(diff));
|
||||
|
||||
return { diffPixels: numDiffPixels, totalPixels, error: null };
|
||||
}
|
||||
|
||||
function listPngs(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((f) => f.endsWith('.png'));
|
||||
}
|
||||
|
||||
function generateHtmlReport(results, threshold) {
|
||||
const rows = results
|
||||
.map(
|
||||
(r) => `
|
||||
<tr class="${r.pass ? 'pass' : 'fail'}">
|
||||
<td><code>${r.name}</code></td>
|
||||
<td>${r.diffPixels ?? '—'}</td>
|
||||
<td>${r.totalPixels ?? '—'}</td>
|
||||
<td>${r.pass ? 'Pass' : 'Fail'}</td>
|
||||
<td>${r.error ?? ''}</td>
|
||||
<td>
|
||||
${r.diffPath ? `<a href="../diffs/${path.basename(r.diffPath)}">diff</a>` : ''}
|
||||
${r.baselinePath ? ` <a href="../baselines/${path.basename(r.baselinePath)}">baseline</a>` : ''}
|
||||
${r.currentPath ? ` <a href="../current/${path.basename(r.currentPath)}">current</a>` : ''}
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Visual regression report</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { border: 1px solid #333; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #1a1a1a; color: #eee; }
|
||||
.pass { background: #0d2b0d; }
|
||||
.fail { background: #2b0d0d; }
|
||||
code { font-size: 0.9em; }
|
||||
.meta { color: #888; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Visual regression report</h1>
|
||||
<p class="meta">Threshold: ${threshold} | Max diff pixels (pass): ${MAX_DIFF_PIXELS} | Generated: ${new Date().toISOString()}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Screen</th>
|
||||
<th>Diff pixels</th>
|
||||
<th>Total pixels</th>
|
||||
<th>Result</th>
|
||||
<th>Error</th>
|
||||
<th>Links</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Comparing baselines vs current');
|
||||
console.log(' Threshold:', THRESHOLD);
|
||||
console.log(' Max diff pixels (pass):', MAX_DIFF_PIXELS);
|
||||
|
||||
const baselineFiles = listPngs(BASELINES);
|
||||
if (baselineFiles.length === 0) {
|
||||
console.log('No baseline PNGs in visual-tests/baselines/');
|
||||
fs.mkdirSync(REPORTS, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(REPORTS, 'results.json'),
|
||||
JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results: [] }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport([], THRESHOLD));
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const name of baselineFiles) {
|
||||
const baselinePath = path.join(BASELINES, name);
|
||||
const currentPath = path.join(CURRENT, name);
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
results.push({
|
||||
name,
|
||||
baselinePath,
|
||||
currentPath: null,
|
||||
diffPath: null,
|
||||
diffPixels: null,
|
||||
totalPixels: null,
|
||||
error: 'No current file',
|
||||
pass: false,
|
||||
});
|
||||
console.log(name, '— no current file');
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffPath = path.join(DIFFS, name);
|
||||
const out = compare(baselinePath, currentPath, diffPath, THRESHOLD);
|
||||
const pass = out.error ? false : out.diffPixels <= MAX_DIFF_PIXELS;
|
||||
results.push({
|
||||
name,
|
||||
baselinePath,
|
||||
currentPath,
|
||||
diffPath: out.error ? null : diffPath,
|
||||
diffPixels: out.diffPixels,
|
||||
totalPixels: out.totalPixels,
|
||||
error: out.error,
|
||||
pass,
|
||||
});
|
||||
console.log(name, '— diff pixels:', out.diffPixels ?? out.error, pass ? '✓' : '✗');
|
||||
}
|
||||
|
||||
fs.mkdirSync(REPORTS, { recursive: true });
|
||||
const jsonPath = path.join(REPORTS, 'results.json');
|
||||
const htmlPath = path.join(REPORTS, 'index.html');
|
||||
fs.writeFileSync(
|
||||
jsonPath,
|
||||
JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2)
|
||||
);
|
||||
fs.writeFileSync(htmlPath, generateHtmlReport(results, THRESHOLD));
|
||||
console.log('Report JSON:', jsonPath);
|
||||
console.log('Report HTML:', htmlPath);
|
||||
|
||||
const failed = results.filter((r) => !r.pass);
|
||||
process.exit(failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
107
apps/web/scripts/report-arbitrary-values.mjs
Normal file
107
apps/web/scripts/report-arbitrary-values.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rapport des valeurs arbitraires Tailwind (w-[...], h-[...], gap-[...], etc.)
|
||||
* pour prioriser les migrations vers le design system.
|
||||
* Usage: node scripts/report-arbitrary-values.mjs [--json] [--dir src/components]
|
||||
* Sortie: rapport (fichier, ligne, pattern) — pas de remplacement automatique.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ARBITRARY_PATTERNS = [
|
||||
{ name: 'width', regex: /(?:^|\s)(w-|min-w-|max-w-)\[[^\]]+\]/g },
|
||||
{ name: 'height', regex: /(?:^|\s)(h-|min-h-|max-h-)\[[^\]]+\]/g },
|
||||
{ name: 'gap', regex: /(?:^|\s)gap-\[[^\]]+\]/g },
|
||||
{ name: 'padding', regex: /(?:^|\s)(p-|px-|py-|pt-|pb-|pl-|pr-)\[[^\]]+\]/g },
|
||||
{ name: 'margin', regex: /(?:^|\s)(m-|mx-|my-|mt-|mb-|ml-|mr-)\[[^\]]+\]/g },
|
||||
{ name: 'space', regex: /(?:^|\s)space-[xy]-\[[^\]]+\]/g },
|
||||
{ name: 'rounded', regex: /(?:^|\s)rounded-\[[^\]]+\]/g },
|
||||
{ name: 'shadow', regex: /(?:^|\s)shadow-\[[^\]]+\]/g },
|
||||
];
|
||||
|
||||
const DEFAULT_DIRS = ['src/components', 'src/features'];
|
||||
const IGNORE_DIRS = ['node_modules', 'dist', '.storybook', 'dist_verification'];
|
||||
const EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js']);
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let dirs = DEFAULT_DIRS;
|
||||
let json = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--json') json = true;
|
||||
else if (args[i] === '--dir' && args[i + 1]) {
|
||||
dirs = [args[++i]];
|
||||
}
|
||||
}
|
||||
return { dirs, json };
|
||||
}
|
||||
|
||||
function* walkFiles(dir, base = dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
const rel = path.relative(base, full);
|
||||
if (e.isDirectory()) {
|
||||
if (!IGNORE_DIRS.includes(e.name)) yield* walkFiles(full, base);
|
||||
} else if (EXTENSIONS.has(path.extname(e.name))) {
|
||||
yield { full, rel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const hits = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
for (const { name, regex } of ARBITRARY_PATTERNS) {
|
||||
regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = regex.exec(line)) !== null) {
|
||||
hits.push({
|
||||
pattern: name,
|
||||
match: m[0].trim(),
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { dirs, json } = parseArgs();
|
||||
const cwd = process.cwd();
|
||||
const report = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const absDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
||||
for (const { full, rel } of walkFiles(absDir)) {
|
||||
const hits = scanFile(full);
|
||||
if (hits.length > 0) {
|
||||
report.push({ file: rel, hits });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('# Rapport des valeurs arbitraires Tailwind\n');
|
||||
console.log('Prioriser les migrations vers tokens / scale (voir docs/DESIGN_TOKENS.md).\n');
|
||||
for (const { file, hits } of report) {
|
||||
console.log(`## ${file}`);
|
||||
for (const { pattern, match, line } of hits) {
|
||||
console.log(` ${line}: [${pattern}] ${match}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
console.log(`Total: ${report.length} fichiers, ${report.reduce((s, r) => s + r.hits.length, 0)} occurrences.`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -72,7 +72,7 @@ export function BulkModeBanner({
|
|||
className={cn(
|
||||
'w-full bg-kodo-steel/10 border-b border-kodo-steel/30 text-kodo-steel',
|
||||
'px-4 py-4 flex items-center justify-between gap-4',
|
||||
'transition-all duration-300',
|
||||
'transition-all duration-[var(--duration-normal)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const CartItem: React.FC<CartItemProps> = ({ item, onRemove }) => {
|
|||
<div className="w-full md:w-24 h-24 rounded-lg overflow-hidden flex-shrink-0 bg-kodo-graphite">
|
||||
<img
|
||||
src={product.coverUrl}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-[var(--duration-slow)]"
|
||||
alt={product.title}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export const WishlistView: React.FC = () => {
|
|||
<div className="relative w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={product.coverUrl}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-[var(--duration-slow)]"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function ActivityGraph() {
|
|||
{/* Barre */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-t-sm transition-all duration-300 relative z-10",
|
||||
"w-full rounded-t-sm transition-all duration-[var(--duration-normal)] relative z-10",
|
||||
isHovered ? "bg-kodo-cyan opacity-100" : "bg-kodo-cyan/30 opacity-70",
|
||||
// Animation d'entrée
|
||||
"animate-slideUp"
|
||||
|
|
@ -116,13 +116,13 @@ export function ActivityGraph() {
|
|||
{/* Tooltip */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-[110%] left-1/2 -translate-x-1/2 z-20 transition-all duration-200 pointer-events-none",
|
||||
"absolute bottom-[110%] left-1/2 -translate-x-1/2 z-20 transition-all duration-[var(--duration-fast)] pointer-events-none",
|
||||
isHovered ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="bg-kodo-ink border border-kodo-steel/50 px-3 py-2 rounded-lg shadow-xl whitespace-nowrap">
|
||||
<div className="text-xs font-bold text-white mb-0.5">{point.value} écoutes</div>
|
||||
<div className="text-[10px] text-kodo-text-dim font-mono">{point.label}</div>
|
||||
<div className="text-xs text-kodo-text-dim font-mono">{point.label}</div>
|
||||
</div>
|
||||
{/* Triangle du tooltip */}
|
||||
<div className="w-2 h-2 bg-kodo-ink border-b border-r border-kodo-steel/50 rotate-45 absolute -bottom-1 left-1/2 -translate-x-1/2" />
|
||||
|
|
@ -130,7 +130,7 @@ export function ActivityGraph() {
|
|||
|
||||
{/* Label Axe X (tous les 3 ou 5 points selon densité) */}
|
||||
{(period === 7 || i % 5 === 0) && (
|
||||
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 text-[10px] text-kodo-text-dim font-mono whitespace-nowrap">
|
||||
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 text-xs text-kodo-text-dim font-mono whitespace-nowrap">
|
||||
{point.label.split(' ')[0]}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="drop-shadow-[0_0_8px_currentColor]"
|
||||
className="drop-shadow-stat-sparkline"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export const WebhooksView: React.FC = () => {
|
|||
webhooks.map((hook) => (
|
||||
<Card key={hook.id} variant="glass" className="group overflow-hidden relative border-white/5 hover:border-white/10 transition-all bg-black/40">
|
||||
{/* Status Indicator Bar */}
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1", hook.status === 'active' ? 'bg-lime-500 shadow-[0_0_10px_rgba(var(--lime-500),0.5)]' : 'bg-red-500 shadow-[0_0_10px_rgba(var(--red-500),0.5)]')} />
|
||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1", hook.status === 'active' ? 'bg-lime-500 shadow-status-dot-lime' : 'bg-red-500')} />
|
||||
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between p-6 pl-8 gap-4">
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -137,9 +137,9 @@ export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
|
|||
<label
|
||||
key={scope.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-200 group relative overflow-hidden",
|
||||
"flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-[var(--duration-fast)] group relative overflow-hidden",
|
||||
selectedScopes.includes(scope.id)
|
||||
? "bg-kodo-cyan/10 border-kodo-cyan/50 shadow-[0_0_15px_rgba(102,252,241,0.1)]"
|
||||
? "bg-kodo-cyan/10 border-kodo-cyan/50 shadow-card-glow-cyan"
|
||||
: "bg-kodo-void/30 border-kodo-steel/50 hover:border-kodo-steel hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
|
|
@ -179,8 +179,8 @@ export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
|
|||
<div className="text-center space-y-8 py-4">
|
||||
<div className="relative w-20 h-20 mx-auto">
|
||||
<div className="absolute inset-0 bg-kodo-lime/20 rounded-full animate-ping opacity-50"></div>
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-kodo-lime/20 to-kodo-cyan/20 rounded-full flex items-center justify-center border border-kodo-lime/30 shadow-[0_0_30px_rgba(54,229,209,0.2)]">
|
||||
<Check className="w-10 h-10 text-kodo-lime drop-shadow-[0_0_10px_rgba(54,229,209,0.5)]" />
|
||||
<div className="relative w-full h-full bg-gradient-to-br from-kodo-lime/20 to-kodo-cyan/20 rounded-full flex items-center justify-center border border-kodo-lime/30 shadow-card-glow-cyan">
|
||||
<Check className="w-10 h-10 text-kodo-lime drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
|
|||
}}
|
||||
disabled={isGenerating || !name.trim() || selectedScopes.length === 0}
|
||||
type="button"
|
||||
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold shadow-[0_0_20px_rgba(102,252,241,0.3)] hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-300"
|
||||
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold shadow-button-primary-glow hover:shadow-button-primary-glow-hover transition-all duration-[var(--duration-normal)]"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
|
|
@ -248,7 +248,7 @@ export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({
|
|||
) : (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold min-w-[100px] shadow-[0_0_20px_rgba(102,252,241,0.3)] hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-300"
|
||||
className="bg-kodo-cyan hover:bg-kodo-cyan/80 text-black font-semibold min-w-[100px] shadow-button-primary-glow hover:shadow-button-primary-glow-hover transition-all duration-[var(--duration-normal)]"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
|||
<div className="relative aspect-video bg-kodo-ink overflow-hidden">
|
||||
<img
|
||||
src={course.thumbnailUrl}
|
||||
className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity duration-200"
|
||||
className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity duration-[var(--duration-fast)]"
|
||||
alt={course.title}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<PlayCircle className="w-12 h-12 text-white fill-current opacity-80" />
|
||||
</div>
|
||||
{course.certificateAvailable && (
|
||||
<div className="absolute top-2 right-2 bg-kodo-gold/90 text-black text-[10px] font-bold px-2 py-0.5 rounded shadow-lg flex items-center gap-1">
|
||||
<div className="absolute top-2 right-2 bg-kodo-gold/90 text-black text-xs font-bold px-2 py-0.5 rounded shadow-lg flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-current" /> CERTIFIED
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -45,7 +45,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
|||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded uppercase font-bold ${
|
||||
className={`text-xs px-2 py-0.5 rounded uppercase font-bold ${
|
||||
course.level === 'Advanced'
|
||||
? 'bg-kodo-red/20 text-kodo-red'
|
||||
: course.level === 'Intermediate'
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export const QuizModal: React.FC<QuizModalProps> = ({
|
|||
{/* Progress */}
|
||||
<div className="h-1 bg-kodo-steel w-full">
|
||||
<div
|
||||
className="h-full bg-kodo-cyan transition-all duration-300"
|
||||
className="h-full bg-kodo-cyan transition-all duration-[var(--duration-normal)]"
|
||||
style={{
|
||||
width: `${((currentQuestionIndex + 1) / quiz.questions.length) * 100}%`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function Progress({
|
|||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className={cn(
|
||||
'transition-all duration-300 ease-in-out',
|
||||
'transition-all duration-[var(--duration-normal)] ease-in-out',
|
||||
!color && 'text-primary',
|
||||
)}
|
||||
style={color ? { stroke: color } : undefined}
|
||||
|
|
@ -109,7 +109,7 @@ export function Progress({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300 ease-in-out',
|
||||
'h-full rounded-full transition-all duration-[var(--duration-normal)] ease-in-out',
|
||||
!color && 'bg-primary',
|
||||
)}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function ToastComponent({ toast, onDismiss }: ToastProps) {
|
|||
<Card
|
||||
variant="glass"
|
||||
className={cn(
|
||||
'relative flex min-w-[320px] max-w-sm items-start gap-4 p-4 shadow-[0_4px_20px_rgba(0,0,0,0.2)] transition-all duration-300 transform',
|
||||
'relative flex min-w-80 max-w-sm items-start gap-4 p-4 shadow-modal transition-all duration-[var(--duration-normal)] transform',
|
||||
variantClass,
|
||||
isVisible && !isLeaving
|
||||
? 'opacity-100 translate-x-0 translate-y-0 scale-100'
|
||||
|
|
@ -84,7 +84,7 @@ export function ToastComponent({ toast, onDismiss }: ToastProps) {
|
|||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 rounded-md p-1 opacity-60 transition-opacity hover:opacity-100 hover:bg-black/10 focus:outline-none focus:ring-1 focus:ring-current"
|
||||
className="flex-shrink-0 rounded-md p-1 opacity-60 transition-opacity duration-[var(--duration-fast)] hover:opacity-100 hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function PasswordStrengthIndicator({
|
|||
<div className="flex-1 bg-kodo-steel/30 dark:bg-kodo-steel rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all duration-300',
|
||||
'h-2 rounded-full transition-all duration-[var(--duration-normal)]',
|
||||
currentStrengthColor,
|
||||
)}
|
||||
style={{ width: `${(strength.score / 5) * 100}%` }}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const AchievementCard: React.FC<AchievementCardProps> = ({
|
|||
className={`flex ${compact ? 'flex-row items-center gap-4' : 'flex-col items-center text-center gap-4'}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-full bg-gradient-to-br from-kodo-graphite to-black flex items-center justify-center border-2 ${isUnlocked ? 'border-kodo-gold w-16 h-16 text-3xl shadow-[0_0_15px_rgba(234,179,8,0.3)]' : 'border-kodo-steel w-12 h-12 text-xl text-kodo-content-dim'}`}
|
||||
className={`rounded-full bg-gradient-to-br from-kodo-graphite to-black flex items-center justify-center border-2 ${isUnlocked ? 'border-kodo-gold w-16 h-16 text-3xl shadow-gold-glow' : 'border-kodo-steel w-12 h-12 text-xl text-kodo-content-dim'}`}
|
||||
>
|
||||
{achievement.icon}
|
||||
</div>
|
||||
|
|
@ -56,11 +56,11 @@ export const AchievementCard: React.FC<AchievementCardProps> = ({
|
|||
{/* Progress */}
|
||||
<div className="w-full bg-kodo-graphite h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${isUnlocked ? 'bg-kodo-gold' : 'bg-kodo-steel'}`}
|
||||
className={`h-full transition-all duration-[var(--duration-slow)] ${isUnlocked ? 'bg-kodo-gold' : 'bg-kodo-steel'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] mt-1 font-mono">
|
||||
<div className="flex justify-between text-xs mt-1 font-mono">
|
||||
<span className={isUnlocked ? 'text-kodo-gold' : 'text-kodo-content-dim'}>
|
||||
{achievement.progress} / {achievement.maxProgress}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export const ProfileXPView: React.FC<ProfileXPViewProps> = ({ username }) => {
|
|||
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
||||
{/* Level Badge */}
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-24 h-24 bg-gradient-to-b from-kodo-gold to-orange-600 rounded-full flex items-center justify-center shadow-[0_0_30px_rgba(234,179,8,0.4)] border-4 border-black">
|
||||
<div className="w-24 h-24 bg-gradient-to-b from-kodo-gold to-orange-600 rounded-full flex items-center justify-center shadow-gold-glow border-4 border-black">
|
||||
<div className="text-4xl font-black text-black">
|
||||
{xpData.level}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const XPBar: React.FC<XPBarProps> = ({
|
|||
};
|
||||
|
||||
const textClasses = {
|
||||
sm: 'text-[10px]',
|
||||
sm: 'text-xs',
|
||||
md: 'text-xs',
|
||||
lg: 'text-sm',
|
||||
};
|
||||
|
|
@ -55,7 +55,7 @@ export const XPBar: React.FC<XPBarProps> = ({
|
|||
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-kodo-gold/80 to-kodo-gold transition-all duration-500 shadow-[0_0_15px_rgba(234,179,8,0.4)] relative"
|
||||
className="h-full bg-gradient-to-r from-kodo-gold/80 to-kodo-gold transition-all duration-[var(--duration-slow)] shadow-gold-glow relative"
|
||||
style={{ width: `${percentage}%` }}
|
||||
>
|
||||
{/* Shimmer Effect */}
|
||||
|
|
@ -64,7 +64,7 @@ export const XPBar: React.FC<XPBarProps> = ({
|
|||
</div>
|
||||
|
||||
{showLabels && size === 'lg' && (
|
||||
<div className="text-right text-[10px] text-kodo-content-dim mt-1 font-mono">
|
||||
<div className="text-right text-xs text-kodo-content-dim mt-1 font-mono">
|
||||
{Math.round(nextLevelXP - currentXP)} XP to next level
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,17 @@ export const EquipmentCard: React.FC<EquipmentCardProps> = ({
|
|||
return (
|
||||
<Card
|
||||
variant="default"
|
||||
className="group p-0 overflow-hidden cursor-pointer hover:bg-white/5 transition-colors duration-200 flex flex-col h-full"
|
||||
className="group p-0 overflow-hidden cursor-pointer hover:bg-white/5 transition-colors duration-[var(--duration-fast)] flex flex-col h-full"
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<div className="relative aspect-square bg-kodo-ink overflow-hidden">
|
||||
<img
|
||||
src={item.image || 'https://via.placeholder.com/400'}
|
||||
className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity duration-200"
|
||||
className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity duration-[var(--duration-fast)]"
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-[10px] font-bold uppercase backdrop-blur-md ${statusColor[item.status]}`}
|
||||
className={`px-2 py-1 rounded text-xs font-bold uppercase backdrop-blur-md ${statusColor[item.status]}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export const AudioPlayer: React.FC = () => {
|
|||
|
||||
{/* QUEUE DRAWER */}
|
||||
{showQueue && !isImmersive && (
|
||||
<div className="fixed bottom-24 right-4 w-full md:w-96 bg-card/95 backdrop-blur-xl border border-border/50 rounded-xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-[70vh] flex flex-col ring-1 ring-white/10">
|
||||
<div className="fixed bottom-24 right-4 w-full md:w-96 bg-card/95 backdrop-blur-xl border border-border/50 rounded-xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-layout-panel flex flex-col ring-1 ring-white/10">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/80">
|
||||
<h3 className="font-bold text-foreground text-sm tracking-wide flex items-center gap-2">
|
||||
<ListMusic className="w-4 h-4 text-kodo-steel" /> PLAY QUEUE
|
||||
|
|
@ -94,7 +94,7 @@ export const AudioPlayer: React.FC = () => {
|
|||
{queueTab === 'up-next' && (
|
||||
<>
|
||||
<div className="p-4 bg-primary/5 border-b border-border/30">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Now Playing
|
||||
</div>
|
||||
<div className="flex items-center gap-4 group relative">
|
||||
|
|
|
|||
|
|
@ -1,21 +1,12 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DashboardLayout } from './DashboardLayout';
|
||||
import DashboardPage from '@/features/dashboard/pages/DashboardPage';
|
||||
import { PlaylistListPage } from '@/features/playlists/pages/PlaylistListPage';
|
||||
import { LibraryPage } from '@/features/library/pages/LibraryPage';
|
||||
import { SettingsPage } from '@/features/settings/pages/SettingsPage';
|
||||
import { UserProfilePage } from '@/features/profile/pages/UserProfilePage';
|
||||
|
||||
const meta = {
|
||||
title: 'App/Layouts/DashboardLayout',
|
||||
component: DashboardLayout,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
} satisfies Meta<typeof DashboardLayout>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
const placeholderContent = (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold text-white">Dashboard Content</h1>
|
||||
<p className="text-gray-400">This is the main content area rendered within the layout.</p>
|
||||
|
|
@ -24,13 +15,101 @@ export const Default: Story = {
|
|||
<div className="h-32 bg-white/5 rounded-xl border border-white/10 p-4">Card 2</div>
|
||||
<div className="h-32 bg-white/5 rounded-xl border border-white/10 p-4">Card 3</div>
|
||||
</div>
|
||||
{/* Long content to test scrolling */}
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-white/5 rounded-lg border border-white/5 flex items-center px-4">
|
||||
Item {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: 'App/Layouts/DashboardLayout',
|
||||
component: DashboardLayout,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
viewport: { defaultViewport: 'desktop' },
|
||||
},
|
||||
} satisfies Meta<typeof DashboardLayout>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Shell with placeholder content. Use to validate scroll and proportions. */
|
||||
export const Default: Story = {
|
||||
name: 'App shell (placeholder)',
|
||||
args: {
|
||||
children: placeholderContent,
|
||||
},
|
||||
};
|
||||
|
||||
/** Full app view: shell + real Dashboard page (MSW mocks user + library). */
|
||||
export const DashboardFullLayout: Story = {
|
||||
name: 'Dashboard – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<DashboardPage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/dashboard'] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Full app view: shell + Playlist list page (MSW mocks playlists). */
|
||||
export const PlaylistsFullLayout: Story = {
|
||||
name: 'Playlists – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<PlaylistListPage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/playlists'] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Full app view: shell + Library page (MSW mocks tracks). */
|
||||
export const LibraryFullLayout: Story = {
|
||||
name: 'Library – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<LibraryPage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/library'] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Full app view: shell + Settings page (MSW mocks user + users/settings). */
|
||||
export const SettingsFullLayout: Story = {
|
||||
name: 'Settings – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<SettingsPage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/settings'] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Full app view: shell + Profile page (MSW mocks user profile). */
|
||||
export const ProfileFullLayout: Story = {
|
||||
name: 'Profile – full layout',
|
||||
render: () => (
|
||||
<DashboardLayout>
|
||||
<UserProfilePage />
|
||||
</DashboardLayout>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
router: { initialEntries: ['/profile'] },
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from '@/components/feedback/ToastProvider';
|
||||
import { DashboardLayout } from './DashboardLayout';
|
||||
|
||||
// Mock useTranslation
|
||||
|
|
@ -46,7 +47,9 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||
const queryClient = createTestQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
|
@ -127,7 +130,7 @@ describe('DashboardLayout Component', () => {
|
|||
const mainContent = container.querySelector('.flex-1.flex.flex-col');
|
||||
expect(mainContent).toBeInTheDocument();
|
||||
|
||||
const main = container.querySelector('main.flex-1.overflow-auto');
|
||||
const main = container.querySelector('main.flex-1');
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
{/* 3. Main Content Area (The only thing that scrolls) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col h-full relative z-10 transition-all duration-500 ease-in-out',
|
||||
// Desktop margins: calculated precisely based on sidebar width + gap
|
||||
sidebarOpen ? 'lg:ml-72' : 'lg:ml-28',
|
||||
// Mobile: no margin (sidebar is overlay)
|
||||
'flex-1 flex flex-col h-full relative z-10 transition-all duration-[var(--duration-slow)] ease-[var(--ease-in-out)]',
|
||||
sidebarOpen ? 'lg:ml-main-expanded' : 'lg:ml-main-collapsed',
|
||||
'ml-0'
|
||||
)}
|
||||
>
|
||||
|
|
@ -44,10 +42,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
<Header />
|
||||
|
||||
{/* Scrollable Content Container */}
|
||||
{/* pt-20: content below fixed header (h-16) */}
|
||||
{/* pb-32 ensures content isn't hidden by the floating player */}
|
||||
<main
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-20 pb-32 px-4 md:px-8 custom-scrollbar"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-main pb-main px-4 md:px-8 custom-scrollbar"
|
||||
id="main-scroll-container"
|
||||
>
|
||||
<div className="max-w-layout-content mx-auto w-full">
|
||||
|
|
|
|||
|
|
@ -53,11 +53,11 @@ export function Header(_props: HeaderProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 h-16 z-50 pointer-events-none">
|
||||
<header className="fixed top-0 left-0 right-0 h-header z-50 pointer-events-none">
|
||||
<div className={cn(
|
||||
"absolute top-0 right-0 h-16 bg-background/80 backdrop-blur-md flex items-center justify-between px-4 md:px-6 pointer-events-auto transition-all duration-200",
|
||||
sidebarOpen ? "left-72" : "left-20",
|
||||
"max-lg:left-0"
|
||||
'absolute top-0 right-0 h-header bg-background/80 backdrop-blur-md flex items-center justify-between px-4 md:px-6 pointer-events-auto transition-all duration-[var(--duration-fast)]',
|
||||
sidebarOpen ? 'left-header-expanded' : 'left-header-collapsed',
|
||||
'max-lg:left-0'
|
||||
)}>
|
||||
|
||||
{/* Mobile Sidebar Toggle */}
|
||||
|
|
@ -77,7 +77,7 @@ export function Header(_props: HeaderProps) {
|
|||
placeholder="What do you want to play?"
|
||||
className="w-full h-10 pl-10 pr-4 bg-white/5 border-0 rounded-full text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-white/20 transition-all"
|
||||
/>
|
||||
<kbd className="absolute right-3 hidden sm:inline-flex items-center gap-0.5 px-2 py-0.5 rounded bg-white/5 text-[10px] font-medium text-muted-foreground">
|
||||
<kbd className="absolute right-3 hidden sm:inline-flex items-center gap-0.5 px-2 py-0.5 rounded bg-white/5 text-xs font-medium text-muted-foreground">
|
||||
<Command className="w-3 h-3" />K
|
||||
</kbd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ export function Layout({ children }: LayoutProps) {
|
|||
|
||||
<Header />
|
||||
|
||||
<div className="flex relative z-10 transition-all duration-500">
|
||||
<div className="flex relative z-10 transition-all duration-[var(--duration-slow)]">
|
||||
<Sidebar />
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 min-h-layout-main transition-all duration-300 ease-in-out',
|
||||
'flex-1 min-h-layout-main transition-all duration-[var(--duration-normal)] ease-[var(--ease-in-out)]',
|
||||
sidebarOpen ? 'lg:ml-64' : 'ml-0',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, onLogout }) => {
|
|||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-white group-hover:text-primary transition-transform duration-200 ${showUserMenu ? 'rotate-180' : ''}`}
|
||||
className={`w-4 h-4 text-white group-hover:text-primary transition-transform duration-[var(--duration-fast)] ${showUserMenu ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -93,30 +93,31 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
|
|||
<>
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm lg:hidden z-[90]"
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm lg:hidden z-sidebar-overlay"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
data-testid="app-sidebar"
|
||||
className={cn(
|
||||
'fixed left-6 bottom-6 top-20 rounded-xl flex flex-col transition-all duration-300 ease-out z-[95] overflow-hidden bg-[var(--sidebar)] backdrop-blur-md',
|
||||
sidebarOpen ? 'w-64 translate-x-0 opacity-100' : '-translate-x-full lg:translate-x-0 lg:opacity-100 lg:w-20'
|
||||
'fixed left-sidebar bottom-sidebar top-sidebar rounded-xl flex flex-col transition-shell z-sidebar overflow-hidden bg-[var(--sidebar)] backdrop-blur-md',
|
||||
sidebarOpen ? 'w-sidebar-expanded translate-x-0 opacity-100' : '-translate-x-full lg:translate-x-0 lg:opacity-100 lg:w-sidebar-collapsed'
|
||||
)}
|
||||
>
|
||||
{/* Header — minimal Spotify-style */}
|
||||
<div className="px-4 py-4 flex items-center gap-3 relative">
|
||||
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-sidebar-accent flex items-center justify-center flex-shrink-0">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className={cn("transition-all duration-300 overflow-hidden min-w-0", sidebarOpen ? "opacity-100" : "w-0 opacity-0")}>
|
||||
<div className={cn("transition-shell overflow-hidden min-w-0", sidebarOpen ? "opacity-100" : "w-0 opacity-0")}>
|
||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
||||
System Hub
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0 animate-pulse" />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Online
|
||||
</span>
|
||||
|
|
@ -128,7 +129,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
|
|||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className={cn(
|
||||
"ml-auto text-muted-foreground hover:text-white hidden lg:flex hover:bg-white/5",
|
||||
"ml-auto text-muted-foreground hover:text-white hidden lg:flex hover:bg-sidebar-accent",
|
||||
!sidebarOpen && "absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2"
|
||||
)}
|
||||
>
|
||||
|
|
@ -141,7 +142,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
|
|||
{navItems.map((group, idx) => (
|
||||
<div key={idx}>
|
||||
<h3 className={cn(
|
||||
"text-xs font-medium text-muted-foreground mb-2 px-3 transition-all duration-300",
|
||||
"text-xs font-medium text-muted-foreground mb-2 px-3 transition-all duration-[var(--duration-normal)] uppercase tracking-wider",
|
||||
!sidebarOpen && "opacity-0 h-0 overflow-hidden mb-0 px-0"
|
||||
)}>
|
||||
{group.section}
|
||||
|
|
@ -161,24 +162,25 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
|
|||
onNavigate?.(item.id);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-200 group relative',
|
||||
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-[var(--duration-fast)] group relative',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
isActive
|
||||
? 'bg-white/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/5',
|
||||
? 'bg-primary/10 text-primary sidebar-active-indicator'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
|
||||
!sidebarOpen && "justify-center px-0"
|
||||
)}
|
||||
title={!sidebarOpen ? item.label : undefined}
|
||||
>
|
||||
<div className={cn("flex items-center gap-3 relative z-10 min-w-0", !sidebarOpen && "justify-center")}>
|
||||
<span className={cn(
|
||||
'shrink-0 transition-colors duration-200',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground'
|
||||
'shrink-0 transition-colors duration-[var(--duration-fast)]',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
|
||||
)}>
|
||||
{item.icon}
|
||||
</span>
|
||||
|
||||
<span className={cn(
|
||||
"transition-all duration-300 whitespace-nowrap truncate",
|
||||
"transition-all duration-[var(--duration-normal)] whitespace-nowrap truncate",
|
||||
sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden"
|
||||
)}>
|
||||
{item.label}
|
||||
|
|
@ -203,13 +205,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
|
|||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t border-white/5">
|
||||
<div className="p-2 border-t border-sidebar-border">
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => { handleMobileNav(); onNavigate?.('settings'); }}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-200',
|
||||
activeView === 'settings' ? 'bg-white/10 text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-white/5',
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-[var(--duration-fast)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
activeView === 'settings' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
|
||||
!sidebarOpen && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|||
>
|
||||
<Card
|
||||
variant="glass"
|
||||
className="group p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-300 cursor-pointer"
|
||||
className="group p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-[var(--duration-normal)] cursor-pointer"
|
||||
onClick={() => onClick(product)}
|
||||
>
|
||||
{/* Image & Overlay */}
|
||||
|
|
@ -51,7 +51,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
|||
)}
|
||||
|
||||
{/* Play Button Overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--duration-normal)]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const FullPlayer: React.FC<FullPlayerProps> = ({ onClose }) => {
|
|||
<div className="relative z-20 flex-1 flex flex-col md:flex-row items-center justify-center gap-12 px-8 pb-8 max-w-7xl mx-auto w-full">
|
||||
{/* Left: Artwork & Metadata */}
|
||||
<div
|
||||
className={`flex flex-col items-center md:items-start text-center md:text-left transition-all duration-500 ${showLyrics ? 'md:w-1/3' : 'md:w-1/2'}`}
|
||||
className={`flex flex-col items-center md:items-start text-center md:text-left transition-all duration-[var(--duration-slow)] ${showLyrics ? 'md:w-1/3' : 'md:w-1/2'}`}
|
||||
>
|
||||
<div
|
||||
className="aspect-square w-full max-w-[400px] rounded-2xl overflow-hidden shadow-2xl mb-8 border border-white/10 relative group cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const LyricsPanel: React.FC = () => {
|
|||
return (
|
||||
<p
|
||||
key={i}
|
||||
className={`text-2xl md:text-3xl font-bold transition-all duration-500 cursor-pointer hover:text-white ${isActive ? 'text-white scale-105 origin-center' : 'text-white/20 blur-[1px]'}`}
|
||||
className={`text-2xl md:text-3xl font-bold transition-all duration-[var(--duration-slow)] cursor-pointer hover:text-white ${isActive ? 'text-white scale-105 origin-center' : 'text-white/20 blur-[1px]'}`}
|
||||
onClick={() => seek((line.time / duration) * 100)}
|
||||
>
|
||||
{line.text}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const MiniPlayer: React.FC<MiniPlayerProps> = ({
|
|||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center transition-opacity"
|
||||
className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center transition-opacity duration-[var(--duration-fast)]"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-white" />
|
||||
|
|
@ -53,17 +53,17 @@ export const MiniPlayer: React.FC<MiniPlayerProps> = ({
|
|||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<h4
|
||||
className="text-foreground font-semibold truncate text-sm hover:text-primary transition-colors cursor-pointer"
|
||||
className="text-foreground font-semibold truncate text-sm hover:text-primary transition-colors duration-[var(--duration-fast)] cursor-pointer"
|
||||
onClick={onExpand}
|
||||
>
|
||||
{currentTrack.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-xs truncate cursor-pointer hover:text-foreground transition-colors" onClick={onExpand}>
|
||||
<p className="text-muted-foreground text-xs truncate cursor-pointer hover:text-foreground transition-colors duration-[var(--duration-fast)]" onClick={onExpand}>
|
||||
{currentTrack.artist}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary transition-colors flex-shrink-0 p-1 rounded-full hover:bg-white/5"
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-[var(--duration-fast)] flex-shrink-0 p-1 rounded-full hover:bg-white/5"
|
||||
onClick={() => addToast('Added to Liked Songs')}
|
||||
aria-label="Save to library"
|
||||
>
|
||||
|
|
@ -75,7 +75,7 @@ export const MiniPlayer: React.FC<MiniPlayerProps> = ({
|
|||
<div className="flex flex-col items-center justify-center flex-1 min-w-0 max-w-xl px-4">
|
||||
<PlayerControls layout="compact" />
|
||||
<div className="w-full flex items-center gap-3 mt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-7 text-right shrink-0">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
|
|
@ -91,7 +91,7 @@ export const MiniPlayer: React.FC<MiniPlayerProps> = ({
|
|||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-7 shrink-0">
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-7 shrink-0">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={`${layout === 'full' ? 'w-14 h-14' : 'w-9 h-9'} rounded-full bg-primary text-primary-foreground flex items-center justify-center transition-all duration-[var(--duration-immersive)] ease-in-out hover:opacity-90 shadow-[0_0_24px_var(--color-primary)/0.4]`}
|
||||
className={`${layout === 'full' ? 'w-14 h-14' : 'w-9 h-9'} rounded-full bg-primary text-primary-foreground flex items-center justify-center transition-all duration-[var(--duration-immersive)] ease-in-out hover:opacity-90 shadow-button-primary-glow`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export const ExploreView: React.FC = () => {
|
|||
>
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
className="w-full h-full object-cover transition-opacity duration-200 opacity-80 group-hover:opacity-100"
|
||||
className="w-full h-full object-cover transition-opacity duration-[var(--duration-fast)] opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ export const GroupCard: React.FC<GroupCardProps> = ({
|
|||
<div className="h-32 relative overflow-hidden bg-kodo-ink">
|
||||
<img
|
||||
src={group.coverUrl}
|
||||
className="w-full h-full object-cover transition-opacity duration-200 opacity-80 group-hover:opacity-100"
|
||||
className="w-full h-full object-cover transition-opacity duration-[var(--duration-fast)] opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-[10px] font-bold uppercase backdrop-blur-md flex items-center gap-1 ${group.isPrivate ? 'bg-black/60 text-kodo-gold' : 'bg-black/60 text-kodo-cyan'}`}
|
||||
className={`px-2 py-1 rounded text-xs font-bold uppercase backdrop-blur-md flex items-center gap-1 ${group.isPrivate ? 'bg-black/60 text-kodo-gold' : 'bg-black/60 text-kodo-cyan'}`}
|
||||
>
|
||||
{group.isPrivate ? (
|
||||
<Lock className="w-3 h-3" />
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function ThemeSwitcher({
|
|||
<button
|
||||
key={theme.id}
|
||||
onClick={() => onThemeChange(theme.id)}
|
||||
className={`group relative p-8 rounded-2xl border-2 transition-all duration-300 text-left overflow-hidden ${
|
||||
className={`group relative p-8 rounded-2xl border-2 transition-all duration-[var(--duration-normal)] text-left overflow-hidden ${
|
||||
currentTheme === theme.id
|
||||
? 'border-kodo-cyan bg-kodo-cyan/10 shadow-lg shadow-kodo-cyan/20'
|
||||
: 'border-white/10 bg-kodo-graphite/50 hover:border-white/30 hover:bg-kodo-graphite'
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export const ErrorDisplay = React.forwardRef<HTMLDivElement, ErrorDisplayProps>(
|
|||
};
|
||||
|
||||
const errorContent = (
|
||||
<div ref={ref} role="alert" aria-live="polite" className={cn('rounded-lg border flex shadow-[0_0_15px_rgba(0,0,0,0.1)]', colorStyles.bg, colorStyles.border, colorStyles.text, sizeStyles.padding, sizeStyles.gap, className)} {...props}>
|
||||
<div ref={ref} role="alert" aria-live="polite" className={cn('rounded-lg border flex shadow-card', colorStyles.bg, colorStyles.border, colorStyles.text, sizeStyles.padding, sizeStyles.gap, className)} {...props}>
|
||||
<div className="flex-shrink-0 pt-0.5">{iconConfig}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('font-semibold mb-1', sizeStyles.title)}>{title}</div>
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ export function FAB({
|
|||
variant="default"
|
||||
className={cn(
|
||||
'rounded-full aspect-square p-0',
|
||||
'shadow-[0_0_30px_rgba(102,252,241,0.5)] hover:shadow-[0_0_40px_rgba(102,252,241,0.7)]',
|
||||
'transition-all duration-300',
|
||||
'shadow-fab-glow shadow-fab-glow-hover',
|
||||
'transition-all duration-[var(--duration-normal)]',
|
||||
'active:opacity-80',
|
||||
sizeClasses[size],
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function KodoEmptyState({
|
|||
)}
|
||||
>
|
||||
{/* Subtle ambient orbs */}
|
||||
<div className="absolute inset-0 opacity-[0.12] group-hover:opacity-20 transition-opacity duration-500 pointer-events-none">
|
||||
<div className="absolute inset-0 opacity-[0.12] group-hover:opacity-20 transition-opacity duration-[var(--duration-slow)] pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary/40 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/30 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export function Sidebar({
|
|||
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col transition-all duration-300 ease-in-out',
|
||||
'flex flex-col transition-all duration-[var(--duration-normal)] ease-in-out',
|
||||
position === 'left' ? 'border-r' : 'border-l',
|
||||
'border-white/10 bg-kodo-ink/40 backdrop-blur-md rounded-xl',
|
||||
isCollapsed ? 'w-0 overflow-hidden' : width,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const AccordionContent = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden text-sm transition-all duration-300 ease-in-out',
|
||||
'overflow-hidden text-sm transition-all duration-[var(--duration-normal)] ease-[var(--ease-in-out)]',
|
||||
open ? 'max-h-none opacity-100' : 'max-h-0 opacity-0',
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -139,8 +139,8 @@ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
|||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
md: 'px-2.5 py-0.5 text-[10px]',
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-0.5 text-xs',
|
||||
lg: 'px-4 py-1 text-xs',
|
||||
};
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
|||
{icon && <span className="w-3 h-3">{icon}</span>}
|
||||
{displayText}
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-current/20 text-[10px]">
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-current/20 text-xs">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
/** Primary action button - main CTAs, submit */
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-[0_0_20px_var(--primary)/0.25] border border-transparent font-semibold',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-button-primary-glow border border-transparent font-semibold',
|
||||
/** Primary alias for Design System compatibility */
|
||||
primary:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-[0_0_20px_var(--primary)/0.25] border border-transparent font-semibold',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-button-primary-glow border border-transparent font-semibold',
|
||||
/** Destructive actions - delete, remove, clear */
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 border border-destructive/30 hover:border-destructive/50',
|
||||
|
|
|
|||
|
|
@ -30,17 +30,17 @@ const cardVariants = cva(
|
|||
'bg-card border-0 shadow-lg shadow-black/5 cursor-pointer hover:shadow-xl hover:-translate-y-0.5',
|
||||
|
||||
glow:
|
||||
'bg-card border-0 shadow-lg hover:shadow-[0_0_30px_oklch(0.75_0.18_195_/_0.15)]',
|
||||
'bg-card border-0 shadow-lg hover:shadow-card-glow-cyan',
|
||||
|
||||
glowMagenta:
|
||||
'bg-card border-0 shadow-lg hover:shadow-[0_0_30px_oklch(0.65_0.25_330_/_0.15)]',
|
||||
'bg-card border-0 shadow-lg hover:shadow-card-glow-magenta',
|
||||
|
||||
spotlight:
|
||||
'bg-black/40 border border-white/10 hover:border-white/20',
|
||||
|
||||
/* Immersive surface: subtle border, lighter + diffuse shadow on hover */
|
||||
surface:
|
||||
'bg-card border border-white/5 shadow-none hover:bg-card/90 hover:border-white/10 hover:shadow-[0_8px_30px_rgba(0,0,0,0.25)] transition-all duration-[var(--duration-immersive)] ease-in-out',
|
||||
'bg-card border border-white/5 shadow-none hover:bg-card/90 hover:border-white/10 hover:shadow-card-hover transition-all duration-[var(--duration-immersive)] ease-in-out',
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
|
|
@ -99,7 +99,7 @@ function Card({ className, variant, padding, spotlight, spotlightColor = 'rgba(2
|
|||
>
|
||||
{isSpotlight && (
|
||||
<div
|
||||
className="pointer-events-none absolute -inset-px opacity-0 transition duration-300"
|
||||
className="pointer-events-none absolute -inset-px opacity-0 transition duration-[var(--duration-normal)]"
|
||||
style={{
|
||||
opacity,
|
||||
background: `radial-gradient(600px circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 40%)`,
|
||||
|
|
|
|||
|
|
@ -109,16 +109,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|||
w-5 h-5 rounded border border-kodo-steel bg-kodo-graphite
|
||||
peer-checked:bg-kodo-cyan peer-checked:border-kodo-steel
|
||||
peer-focus:ring-2 peer-focus:ring-kodo-steel/30
|
||||
transition-all duration-200
|
||||
transition-all duration-[var(--duration-fast)]
|
||||
"
|
||||
></div>
|
||||
<Check
|
||||
className="w-3.5 h-3.5 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 peer-checked:opacity-100 transition-opacity pointer-events-none"
|
||||
className="w-3.5 h-3.5 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 peer-checked:opacity-100 transition-opacity duration-[var(--duration-fast)] pointer-events-none"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="text-sm text-kodo-text-main group-hover:text-white transition-colors select-none">
|
||||
<span className="text-sm text-kodo-text-main group-hover:text-white transition-colors duration-[var(--duration-fast)] select-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -118,14 +118,14 @@ export function Collapsible({
|
|||
>
|
||||
<div className="flex-1 text-left">{trigger}</div>
|
||||
{showChevron && (
|
||||
<ChevronIcon className="w-4 h-4 text-kodo-secondary transition-transform duration-200" />
|
||||
<ChevronIcon className="w-4 h-4 text-kodo-secondary transition-transform duration-[var(--duration-fast)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="collapsible-content"
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||
'overflow-hidden transition-all duration-[var(--duration-normal)] ease-in-out',
|
||||
isOpen ? 'max-h-[5000px] opacity-100' : 'max-h-0 opacity-0',
|
||||
contentClassName,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const DropdownMenuItem = React.forwardRef<
|
|||
role="menuitem"
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'transition-colors focus:bg-white/5 focus:text-white disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors duration-[var(--duration-fast)] focus-visible:bg-white/5 focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50',
|
||||
'text-foreground hover:bg-muted/50 w-full text-left',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ export const DropdownMenuTrigger = React.forwardRef<
|
|||
} as React.HTMLAttributes<HTMLElement>);
|
||||
}
|
||||
return (
|
||||
<button ref={ref} type="button" className={cn('outline-none', className)} {...props}>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export function Dropdown({
|
|||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 min-w-[8rem] bg-card border border-border rounded-xl shadow-lg',
|
||||
'absolute z-50 mt-2 min-w-32 bg-card border border-border rounded-xl shadow-lg',
|
||||
'overflow-hidden',
|
||||
alignClasses[align],
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const FloatingInput = React.forwardRef<HTMLInputElement, FloatingInputProps>(
|
|||
type={type}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
"block px-4 pb-2.5 pt-5 w-full text-sm text-white bg-kodo-graphite/40 rounded-xl border appearance-none focus:outline-none focus:ring-0 peer transition-all duration-200",
|
||||
"block px-4 pb-2.5 pt-5 w-full text-sm text-white bg-kodo-graphite/40 rounded-xl border appearance-none focus:outline-none focus:ring-0 peer transition-all duration-[var(--duration-fast)]",
|
||||
// Borders & Colors
|
||||
error
|
||||
? "border-kodo-red focus:border-kodo-red"
|
||||
|
|
@ -38,7 +38,7 @@ const FloatingInput = React.forwardRef<HTMLInputElement, FloatingInputProps>(
|
|||
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-kodo-text-dim peer-focus:text-kodo-cyan transition-colors pointer-events-none">
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-kodo-text-dim peer-focus:text-kodo-cyan transition-colors duration-[var(--duration-fast)] pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -47,7 +47,7 @@ const FloatingInput = React.forwardRef<HTMLInputElement, FloatingInputProps>(
|
|||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
"absolute text-sm duration-200 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 pointer-events-none",
|
||||
"absolute text-sm duration-[var(--duration-fast)] transform -translate-y-3 scale-75 top-4 z-10 origin-[0] peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3 pointer-events-none transition-transform",
|
||||
icon ? "left-11 peer-focus:left-11 peer-placeholder-shown:left-11" : "left-4 peer-focus:left-4 peer-placeholder-shown:left-4",
|
||||
error
|
||||
? "text-kodo-red"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
{label && <Label htmlFor={id} className="text-xs font-mono text-muted-foreground uppercase tracking-widest">{label}</Label>}
|
||||
<div className="relative group">
|
||||
{icon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors duration-[var(--duration-fast)] pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -27,7 +27,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white placeholder:text-muted-foreground/50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"backdrop-blur-sm transition-all duration-200",
|
||||
"backdrop-blur-sm transition-all duration-[var(--duration-fast)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary/50",
|
||||
"hover:bg-white/5 hover:border-white/20",
|
||||
icon && "pl-10",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export function LazyErrorFallback({
|
|||
onRetry,
|
||||
}: LazyErrorFallbackProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page-sm p-8 text-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page-sm p-8 text-center animate-in fade-in zoom-in duration-[var(--duration-normal)]">
|
||||
<div className="bg-kodo-ink/50 border border-kodo-steel/30 rounded-xl p-8 max-w-md w-full shadow-lg backdrop-blur-sm">
|
||||
<div className="w-16 h-16 bg-kodo-red/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="h-8 w-8 text-kodo-red" />
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function OptimizedImage({
|
|||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`transition-opacity duration-300 ${
|
||||
className={`transition-opacity duration-[var(--duration-normal)] ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
} ${className}`}
|
||||
onLoad={handleImageLoad}
|
||||
|
|
|
|||
|
|
@ -139,14 +139,14 @@ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|||
<div className="h-4 bg-kodo-void rounded-full overflow-hidden border border-kodo-gold/30">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full bg-gradient-to-r shadow-[0_0_10px_rgba(255,215,0,0.5)] transition-all duration-500',
|
||||
'h-full bg-gradient-to-r shadow-gold-glow transition-all duration-[var(--duration-slow)]',
|
||||
gradientStyles.gold,
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{(labelLeft || labelRight) && (
|
||||
<div className="flex justify-between text-[10px] font-mono font-bold text-kodo-gold mt-1 uppercase tracking-wider">
|
||||
<div className="flex justify-between text-xs font-mono font-bold text-kodo-gold mt-1 uppercase tracking-wider">
|
||||
<span>{labelLeft}</span>
|
||||
<span>{labelRight}</span>
|
||||
</div>
|
||||
|
|
@ -160,7 +160,7 @@ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
|||
<div className="h-2 bg-kodo-steel rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300 shadow-[0_0_10px_currentColor]',
|
||||
'h-full transition-all duration-[var(--duration-normal)] shadow-slider-thumb',
|
||||
colorStyles[color],
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
|||
>
|
||||
<div className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="absolute h-full bg-primary transition-all shadow-[0_0_10px_oklch(0.75_0.18_195_/_0.5)]"
|
||||
className="absolute h-full bg-primary transition-all duration-[var(--duration-fast)] shadow-slider-thumb"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -155,7 +155,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
|||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-transform pointer-events-none shadow-[0_0_10px_oklch(0.75_0.18_195_/_0.5)]',
|
||||
'absolute h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-transform duration-[var(--duration-fast)] pointer-events-none shadow-slider-thumb',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
style={{ left: `calc(${percentage}% - 10px)` }}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
'font-body text-base',
|
||||
'rounded-lg',
|
||||
'focus:outline-none focus:border-kodo-steel focus:ring-1 focus:ring-kodo-steel',
|
||||
'transition-all duration-200',
|
||||
'transition-all duration-[var(--duration-fast)]',
|
||||
'min-h-[100px] resize-y',
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ export const BulkUploadModal: React.FC<BulkUploadModalProps> = ({
|
|||
|
||||
{/* Global Progress */}
|
||||
<div className="px-6 py-2 bg-kodo-slate/30 border-b border-kodo-steel">
|
||||
<div className="flex justify-between text-[10px] font-bold text-muted-foreground uppercase mb-1">
|
||||
<div className="flex justify-between text-xs font-bold text-muted-foreground uppercase mb-1">
|
||||
<span>Overall Progress</span>
|
||||
<span>{Math.round(totalProgress)}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-kodo-cyan transition-all duration-300"
|
||||
className="h-full bg-kodo-cyan transition-all duration-[var(--duration-normal)]"
|
||||
style={{ width: `${totalProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
|||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-12 flex flex-col items-center justify-center text-center transition-all duration-300 group cursor-pointer
|
||||
border-2 border-dashed rounded-xl p-12 flex flex-col items-center justify-center text-center transition-all duration-[var(--duration-normal)] group cursor-pointer
|
||||
${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10 scale-[1.02]'
|
||||
|
|
@ -87,7 +87,7 @@ export const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
|||
>
|
||||
<div
|
||||
className={`
|
||||
w-20 h-20 rounded-full flex items-center justify-center mb-6 transition-all duration-300 shadow-lg
|
||||
w-20 h-20 rounded-full flex items-center justify-center mb-6 transition-all duration-[var(--duration-normal)] shadow-lg
|
||||
${isDragging ? 'bg-kodo-cyan text-black' : 'bg-muted text-primary group-hover:bg-border'}
|
||||
`}
|
||||
>
|
||||
|
|
@ -105,13 +105,13 @@ export const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
|||
{acceptedFormats.map((fmt) => (
|
||||
<span
|
||||
key={fmt}
|
||||
className="px-2 py-1 bg-black/30 rounded text-[10px] font-mono text-muted-foreground border border-white/5 uppercase"
|
||||
className="px-2 py-1 bg-black/30 rounded text-xs font-mono text-muted-foreground border border-white/5 uppercase"
|
||||
>
|
||||
{fmt.replace('.', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-[10px] text-muted-foreground">
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Max file size: {maxSizeInMB}MB
|
||||
</p>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export const UploadProgressBar: React.FC<UploadProgressBarProps> = ({
|
|||
return (
|
||||
<div className="w-full flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-[10px] uppercase font-bold text-muted-foreground mb-1">
|
||||
<div className="flex justify-between text-xs uppercase font-bold text-muted-foreground mb-1">
|
||||
<span>{status}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${getColor()} ${status === 'uploading' ? 'animate-pulse' : ''}`}
|
||||
className={`h-full transition-all duration-[var(--duration-normal)] ${getColor()} ${status === 'uploading' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function AdminViewSidebar({
|
|||
}: AdminViewSidebarProps) {
|
||||
return (
|
||||
<div className="w-full lg:w-64 flex-shrink-0">
|
||||
<Card variant="glass" className="mb-6 p-4 border-destructive/30 bg-destructive/5 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="mb-6 p-4 border-destructive/30 bg-destructive/5 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<ShieldAlert className="w-6 h-6 text-destructive" />
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function CheckoutViewOrderSummary({
|
|||
onPurchase,
|
||||
}: CheckoutViewOrderSummaryProps) {
|
||||
return (
|
||||
<Card variant="glass" className="rounded-[var(--radius-xl)] p-8 sticky top-24 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="rounded-[var(--radius-xl)] p-8 sticky top-24 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<h3 className="font-bold text-foreground mb-4 uppercase tracking-wider text-sm tracking-tight">
|
||||
Order Summary
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export function DiscoverViewHero({ onPlayWeekly }: DiscoverViewHeroProps) {
|
|||
<img
|
||||
src="https://picsum.photos/id/88/800/400"
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-40 transition-opacity duration-200"
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-40 transition-opacity duration-[var(--duration-fast)]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" aria-hidden />
|
||||
<div className="relative z-10 w-full">
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function FileManagerViewGrid({
|
|||
<Square className="w-4 h-4 text-muted-foreground hover:text-white" />
|
||||
)}
|
||||
</button>
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center shadow-lg transition-colors duration-200">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center shadow-lg transition-colors duration-[var(--duration-fast)]">
|
||||
<FileTypeIcon type={file.type} size="lg" />
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function LiveViewChat({
|
|||
return (
|
||||
<Card
|
||||
variant="glass"
|
||||
className="lg:col-span-3 flex flex-col p-0 overflow-hidden h-full min-h-0 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300"
|
||||
className="lg:col-span-3 flex flex-col p-0 overflow-hidden h-full min-h-0 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]"
|
||||
>
|
||||
<div className="p-4 border-b border-border flex justify-between items-center bg-card">
|
||||
<span className="font-mono text-sm font-bold text-foreground tracking-tight">
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function LiveViewPlayer({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--duration-normal)]">
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function LiveViewRecommended({ onChannelClick }: LiveViewRecommendedProps
|
|||
<motion.div key={i} variants={{ hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0 } }}>
|
||||
<Card
|
||||
variant="glass"
|
||||
className="p-0 overflow-hidden group cursor-pointer border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-300"
|
||||
className="p-0 overflow-hidden group cursor-pointer border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-[var(--duration-normal)]"
|
||||
onClick={() => onChannelClick?.(i)}
|
||||
>
|
||||
<div className="aspect-video relative">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function LiveViewStreamInfo({
|
|||
onShare,
|
||||
}: LiveViewStreamInfoProps) {
|
||||
return (
|
||||
<Card variant="glass" className="p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-neon p-0.5">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface MarketplaceViewHeaderProps {
|
|||
|
||||
export function MarketplaceViewHeader({ searchQuery, onSearchChange }: MarketplaceViewHeaderProps) {
|
||||
return (
|
||||
<Card variant="glass" className="mb-8 p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="mb-8 p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-display font-bold text-foreground mb-2 tracking-tight">MARKETPLACE</h2>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Card } from '@/components/ui/card';
|
|||
export function MarketplaceViewSidebar() {
|
||||
return (
|
||||
<div className="w-64 flex-shrink-0 space-y-8 hidden lg:block animate-slideInLeft">
|
||||
<Card variant="glass" className="border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-4">
|
||||
Price
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Card } from '@/components/ui/card';
|
|||
|
||||
export function SettingsViewHeader() {
|
||||
return (
|
||||
<Card variant="glass" className="p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="p-6 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<h2 className="text-3xl font-display font-bold text-foreground mb-2">SETTINGS</h2>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
Configure your studio, account, and preferences.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function SocialViewFeedItem({ track, onPlay }: SocialViewFeedItemProps) {
|
|||
>
|
||||
<Card
|
||||
variant="glass"
|
||||
className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-300"
|
||||
className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-[var(--duration-normal)]"
|
||||
>
|
||||
<div className="p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function SocialViewSidebar({
|
|||
}: SocialViewSidebarProps) {
|
||||
return (
|
||||
<div className="hidden lg:block lg:col-span-3 space-y-8">
|
||||
<Card variant="glass" className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<div className="h-20 bg-gradient-gaming" />
|
||||
<div className="px-4 pb-4">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const TRENDING_TAGS = ['#Techno', '#Synthwave', '#NewGear', '#Tutorial'];
|
|||
export function SocialViewTrending() {
|
||||
return (
|
||||
<div className="hidden lg:block lg:col-span-3 space-y-8">
|
||||
<Card variant="glass" className="border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<Card variant="glass" className="border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-[var(--duration-normal)]">
|
||||
<h3 className="font-bold text-sm text-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-primary" /> Trending Tags
|
||||
</h3>
|
||||
|
|
@ -15,7 +15,7 @@ export function SocialViewTrending() {
|
|||
{TRENDING_TAGS.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-xs bg-muted px-2 py-1 rounded text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/80 transition-all duration-200"
|
||||
className="text-xs bg-muted px-2 py-1 rounded text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/80 transition-all duration-[var(--duration-fast)]"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function AuthButton({
|
|||
className={cn(
|
||||
'w-full px-4 py-2.5 rounded-xl font-medium transition-all duration-[var(--duration-immersive)] ease-in-out focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 focus:ring-offset-background',
|
||||
variant === 'primary'
|
||||
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-[0_0_20px_var(--color-primary)/0.25]'
|
||||
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-button-primary-glow'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80 border border-border',
|
||||
(disabled || loading) && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function AuthLayout({
|
|||
<header className="text-center">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div
|
||||
className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center shadow-[0_0_20px_var(--color-primary)/0.4]"
|
||||
className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center shadow-button-primary-glow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-background font-bold text-2xl">V</span>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const LoginForm = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<FloatingInput
|
||||
label="Email"
|
||||
type="email"
|
||||
|
|
@ -147,7 +147,7 @@ export const LoginForm = () => {
|
|||
disabled={isLoading}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground font-bold tracking-wide shadow-lg shadow-primary/20 rounded-xl"
|
||||
className="mt-5 w-full bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground font-bold tracking-wide shadow-lg shadow-primary/20 rounded-xl transition-all duration-[var(--duration-normal)]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const RegisterForm = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<FloatingInput
|
||||
label="Email"
|
||||
type="email"
|
||||
|
|
@ -182,7 +182,7 @@ export const RegisterForm = () => {
|
|||
disabled={isLoading}
|
||||
variant="default"
|
||||
size="lg"
|
||||
className="w-full bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground font-bold tracking-wide shadow-lg shadow-primary/20 rounded-xl"
|
||||
className="mt-5 w-full bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground font-bold tracking-wide shadow-lg shadow-primary/20 rounded-xl transition-all duration-[var(--duration-normal)]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ export const ChatInput: React.FC = () => {
|
|||
variant="primary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'rounded-xl transition-all duration-300',
|
||||
'rounded-xl transition-all duration-[var(--duration-normal)]',
|
||||
message.trim() || attachments.length > 0
|
||||
? 'bg-kodo-cyan text-kodo-void hover:bg-kodo-cyan-dim shadow-neon-cyan'
|
||||
: 'bg-white/5 text-kodo-secondary hover:bg-white/10',
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
|||
<div className="flex items-center gap-2 px-1 mb-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10px] uppercase tracking-wider',
|
||||
'font-mono text-xs uppercase tracking-wider',
|
||||
isMe ? 'text-kodo-steel' : 'text-kodo-magenta',
|
||||
)}
|
||||
>
|
||||
|
|
@ -56,7 +56,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
|||
// ...
|
||||
{isMe ? 'You' : safeString(message.sender_username || 'Unknown_Signal')}
|
||||
</span>
|
||||
<span className="text-[9px] text-kodo-secondary/60">
|
||||
<span className="text-xs text-kodo-secondary/60">
|
||||
{new Date(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
|
|
@ -80,7 +80,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
|||
className={cn(
|
||||
'px-4 py-2.5 rounded-2xl text-sm backdrop-blur-md shadow-lg transition-all',
|
||||
isMe
|
||||
? 'bg-kodo-cyan/10 border border-kodo-cyan/20 text-white rounded-tr-sm shadow-[0_0_15px_rgba(102,252,241,0.05)]'
|
||||
? 'bg-kodo-cyan/10 border border-kodo-cyan/20 text-white rounded-tr-sm shadow-card-glow-cyan'
|
||||
: 'bg-white/5 border border-white/10 text-kodo-text-main rounded-tl-sm hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
|
|
@ -176,9 +176,9 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
|||
key={emoji}
|
||||
onClick={() => addReaction(message.id, emoji)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] border transition-all animate-scaleIn',
|
||||
'flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs border transition-all animate-scaleIn',
|
||||
users.includes(user?.id || '')
|
||||
? 'bg-kodo-cyan/20 border-kodo-cyan/40 text-kodo-cyan shadow-[0_0_10px_rgba(102,252,241,0.2)]'
|
||||
? 'bg-kodo-cyan/20 border-kodo-cyan/40 text-kodo-cyan shadow-queue-item-current'
|
||||
: 'bg-white/5 border-white/10 text-kodo-secondary hover:bg-white/10 hover:border-white/20',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|||
{/* Search Header Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0 right-0 z-20 px-4 py-2 transition-all duration-300',
|
||||
'absolute top-0 left-0 right-0 z-20 px-4 py-2 transition-all duration-[var(--duration-normal)]',
|
||||
showSearch
|
||||
? 'bg-kodo-void/90 backdrop-blur-md border-b border-white/10'
|
||||
: 'bg-transparent pointer-events-none',
|
||||
|
|
@ -142,7 +142,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
|
|||
key={msg.id}
|
||||
id={`message-${msg.id}`}
|
||||
className={cn(
|
||||
'transition-all duration-500 animate-slideUp',
|
||||
'transition-all duration-[var(--duration-slow)] animate-slideUp',
|
||||
highlightedMessageId === msg.id &&
|
||||
'bg-kodo-steel/10 rounded-xl -mx-4 px-4 py-2 ring-1 ring-kodo-steel/30',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ export function ConversationItem({
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-between p-4 rounded-xl cursor-pointer transition-all duration-300 border border-transparent',
|
||||
'group relative flex items-center justify-between p-4 rounded-xl cursor-pointer transition-all duration-[var(--duration-normal)] border border-transparent',
|
||||
isSelected
|
||||
? 'bg-primary/10 border-primary/30 shadow-[0_0_15px_hsl(var(--primary)/0.1)]'
|
||||
? 'bg-primary/10 border-primary/30 shadow-queue-item-current'
|
||||
: 'hover:bg-muted/50 hover:border-muted',
|
||||
)}
|
||||
>
|
||||
|
|
@ -141,7 +141,7 @@ export function ConversationItem({
|
|||
{safeString(conversation.name || `Channel ${conversation.id.substring(0, 4)}`)}
|
||||
</span>
|
||||
{conversation.type !== 'direct' && (
|
||||
<span className="text-[10px] text-kodo-secondary/50 uppercase tracking-wider">
|
||||
<span className="text-xs text-kodo-secondary/50 uppercase tracking-wider">
|
||||
{safeString(conversation.type)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -149,7 +149,7 @@ export function ConversationItem({
|
|||
</div>
|
||||
|
||||
{conversation.unread_count != null && Number(conversation.unread_count) > 0 ? (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] px-1.5 py-0.5 rounded-full font-bold shadow-lg shrink-0">
|
||||
<span className="bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full font-bold shadow-lg shrink-0">
|
||||
{conversation.unread_count}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -188,7 +188,7 @@ export function ConversationItem({
|
|||
</DropdownMenu>
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-3 bottom-3 w-0.5 bg-primary rounded-r-full shadow-[0_0_8px_hsl(var(--primary)/0.5)]" />
|
||||
<div className="absolute left-0 top-3 bottom-3 w-0.5 bg-primary rounded-r-full shadow-status-dot-cyan" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const ChatPage: React.FC = () => {
|
|||
<div className="relative mb-6">
|
||||
<div className="w-16 h-16 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse shadow-[0_0_10px_var(--color-primary)]" />
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse shadow-status-dot-cyan" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-mono text-sm text-primary animate-pulse tracking-widest">ESTABLISHING UPLINK...</p>
|
||||
|
|
@ -89,7 +89,7 @@ export const ChatPage: React.FC = () => {
|
|||
<Card variant="glass" className="w-80 shrink-0 flex flex-col overflow-hidden p-0 border-white/5 bg-black/40 backdrop-blur-2xl">
|
||||
<div className="p-4 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm tracking-widest text-muted-foreground uppercase">Channels</h3>
|
||||
<div className={cn("w-2 h-2 rounded-full shadow-[0_0_5px_currentColor]", wsStatus === 'connected' ? 'bg-lime-500 text-lime-500' : 'bg-red-500 text-red-500')} />
|
||||
<div className={cn("w-2 h-2 rounded-full", wsStatus === 'connected' ? 'bg-lime-500 shadow-status-dot-lime' : 'bg-red-500')} />
|
||||
</div>
|
||||
<ChatSidebar />
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ function DashboardPage() {
|
|||
change: '+12%',
|
||||
icon: Music,
|
||||
color: 'text-cyan-500',
|
||||
shadow: 'drop-shadow-[0_0_8px_rgba(var(--cyan-500),0.5)]',
|
||||
shadow: 'drop-shadow-stat-icon',
|
||||
},
|
||||
{
|
||||
title: 'Messages Sent',
|
||||
|
|
@ -43,7 +43,7 @@ function DashboardPage() {
|
|||
change: '+8%',
|
||||
icon: MessageSquare,
|
||||
color: 'text-lime-500',
|
||||
shadow: 'drop-shadow-[0_0_8px_rgba(var(--lime-500),0.5)]',
|
||||
shadow: 'drop-shadow-stat-icon',
|
||||
},
|
||||
{
|
||||
title: 'Favorites',
|
||||
|
|
@ -51,7 +51,7 @@ function DashboardPage() {
|
|||
change: '+23%',
|
||||
icon: Heart,
|
||||
color: 'text-destructive',
|
||||
shadow: 'drop-shadow-[0_0_8px_rgba(var(--destructive),0.5)]',
|
||||
shadow: 'drop-shadow-stat-icon',
|
||||
},
|
||||
{
|
||||
title: 'Active Friends',
|
||||
|
|
@ -59,7 +59,7 @@ function DashboardPage() {
|
|||
change: '+5%',
|
||||
icon: Users,
|
||||
color: 'text-magenta-500',
|
||||
shadow: 'drop-shadow-[0_0_8px_rgba(var(--magenta-500),0.5)]',
|
||||
shadow: 'drop-shadow-stat-icon',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -78,12 +78,12 @@ function DashboardPage() {
|
|||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} variant="glass" className="group hover:border-primary/50 transition-all duration-300">
|
||||
<Card key={stat.title} variant="glass" className="group hover:border-primary/50 transition-all duration-[var(--duration-normal)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-white transition-colors">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-white transition-colors duration-[var(--duration-fast)]">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className={cn("h-4 w-4 transition-all duration-300", stat.color, stat.shadow, "group-hover:scale-110")} />
|
||||
<stat.icon className={cn("h-4 w-4 transition-all duration-[var(--duration-normal)]", stat.color, stat.shadow, "group-hover:scale-110")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white tracking-tight">{stat.value}</div>
|
||||
|
|
@ -106,22 +106,22 @@ function DashboardPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full shadow-[0_0_8px_rgba(var(--cyan-500),0.8)] animate-pulse" />
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-cyan-500 rounded-full shadow-status-dot-cyan animate-pulse" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-white">New track added</p>
|
||||
<p className="text-xs text-muted-foreground">2 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-lime-500 rounded-full shadow-[0_0_8px_rgba(var(--lime-500),0.8)]" />
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-lime-500 rounded-full shadow-status-dot-lime" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-white">Message from @alice</p>
|
||||
<p className="text-xs text-muted-foreground">4 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-magenta-500 rounded-full shadow-[0_0_8px_rgba(var(--magenta-500),0.8)]" />
|
||||
<div className="flex items-center space-x-4 p-3 rounded-lg hover:bg-white/5 transition-colors duration-[var(--duration-fast)] border border-transparent hover:border-white/5">
|
||||
<div className="w-2 h-2 bg-magenta-500 rounded-full shadow-status-dot-magenta" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-white">New favorite added</p>
|
||||
<p className="text-xs text-muted-foreground">6 hours ago</p>
|
||||
|
|
@ -155,7 +155,7 @@ function DashboardPage() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.slice(0, 3).map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-4 p-2 rounded-lg hover:bg-white/5 transition-colors cursor-pointer group border border-transparent hover:border-white/5">
|
||||
<div key={item.id} className="flex items-center space-x-4 p-2 rounded-lg hover:bg-white/5 transition-colors duration-[var(--duration-fast)] cursor-pointer group border border-transparent hover:border-white/5">
|
||||
<div className="w-10 h-10 bg-black/40 rounded flex items-center justify-center border border-white/10 group-hover:border-cyan-500/50 transition-colors shadow-lg">
|
||||
<Music className="h-4 w-4 text-muted-foreground group-hover:text-cyan-500 transition-colors" />
|
||||
</div>
|
||||
|
|
@ -201,17 +201,17 @@ function DashboardPage() {
|
|||
variant="outline"
|
||||
onClick={action.action}
|
||||
className={cn(
|
||||
"h-24 flex-col gap-3 bg-black/20 border-white/10 hover:bg-white/5 transition-all duration-300 group",
|
||||
"h-24 flex-col gap-3 bg-black/20 border-white/10 hover:bg-white/5 transition-all duration-[var(--duration-normal)] group",
|
||||
action.border
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full bg-white/5 flex items-center justify-center transition-all duration-300 group-hover:scale-110",
|
||||
"w-10 h-10 rounded-full bg-white/5 flex items-center justify-center transition-all duration-[var(--duration-normal)] group-hover:scale-110",
|
||||
action.color
|
||||
)}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground group-hover:text-white transition-colors">{action.label}</span>
|
||||
<span className="text-muted-foreground group-hover:text-white transition-colors duration-[var(--duration-fast)]">{action.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,19 +22,19 @@ function NotFoundPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Card className="text-center">
|
||||
<div className="w-full max-w-2xl animate-fadeIn">
|
||||
<Card className="text-center transition-shadow duration-[var(--duration-normal)]">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="h-8 w-8 text-kodo-steel dark:text-kodo-steel" />
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted transition-colors duration-[var(--duration-fast)]">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Page non trouvée</CardTitle>
|
||||
<CardTitle className="text-2xl font-display font-bold tracking-tight">Page non trouvée</CardTitle>
|
||||
<CardDescription>
|
||||
La page que vous recherchez n'existe pas ou a été déplacée.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-6xl font-bold text-foreground">
|
||||
<div className="text-6xl font-display font-bold text-foreground tracking-tight">
|
||||
404
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ interface LibraryPageEmptyProps {
|
|||
|
||||
export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60 min-h-layout-page-sm">
|
||||
<div className="w-24 h-24 bg-muted/10 rounded-full flex items-center justify-center mb-6 border border-border">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center min-h-layout-page-sm animate-fadeIn">
|
||||
<div className="w-24 h-24 bg-muted/10 rounded-full flex items-center justify-center mb-6 border border-border transition-colors duration-[var(--duration-normal)] hover:border-primary/20 hover:bg-muted/20">
|
||||
<FileAudio className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display font-bold text-foreground mb-2">It's empty here</h3>
|
||||
|
|
@ -18,7 +18,7 @@ export function LibraryPageEmpty({ onUploadClick }: LibraryPageEmptyProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={onUploadClick}
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10 transition-colors duration-[var(--duration-fast)]"
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -34,19 +34,22 @@ export function LibraryPageGrid({
|
|||
<motion.div key={track.id} variants={itemVariants}>
|
||||
<Card
|
||||
variant="glass"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className={cn(
|
||||
'group relative aspect-[4/5] overflow-hidden cursor-pointer hover:-translate-y-2 transition-all duration-300 border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan',
|
||||
'group relative aspect-[4/5] overflow-hidden cursor-pointer hover:-translate-y-2 transition-all duration-[var(--duration-normal)] border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
isSelected
|
||||
? 'border-primary ring-1 ring-primary shadow-[0_0_20px_rgba(var(--cyan-500),0.2)]'
|
||||
? 'border-primary ring-1 ring-primary shadow-card-glow-cyan'
|
||||
: 'hover:border-primary/30'
|
||||
)}
|
||||
onClick={() => onToggleSelection(track.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleSelection(track.id); } }}
|
||||
>
|
||||
<div className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--duration-fast)]">
|
||||
{isSelected ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-primary fill-background drop-shadow-[0_0_5px_rgba(0,0,0,0.5)]" />
|
||||
<CheckCircle2 className="w-6 h-6 text-primary fill-background drop-shadow-md" />
|
||||
) : (
|
||||
<Circle className="w-6 h-6 text-white/70 hover:text-white drop-shadow-[0_0_5px_rgba(0,0,0,0.5)]" />
|
||||
<Circle className="w-6 h-6 text-white/70 hover:text-white drop-shadow-md" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-3/5 w-full bg-gradient-to-br from-background to-black/40 flex items-center justify-center relative group-hover:from-background/80 group-hover:to-black/60 transition-all">
|
||||
|
|
@ -54,10 +57,10 @@ export function LibraryPageGrid({
|
|||
<img
|
||||
src={track.coverUrl}
|
||||
alt={track.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
className="w-full h-full object-cover transition-transform duration-[var(--duration-slower)] group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<FileAudio className="w-12 h-12 text-muted-foreground/30 group-hover:text-primary/50 transition-colors duration-300" />
|
||||
<FileAudio className="w-12 h-12 text-muted-foreground/30 group-hover:text-primary/50 transition-colors duration-[var(--duration-normal)]" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -65,9 +68,9 @@ export function LibraryPageGrid({
|
|||
e.stopPropagation();
|
||||
onPlayTrack(track);
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all scale-90 group-hover:scale-100"
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-[var(--duration-normal)] scale-90 group-hover:scale-100 focus:opacity-100 focus:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-inset"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-[0_0_30px_rgba(var(--cyan-500),0.6)] hover:scale-110 transition-transform">
|
||||
<div className="w-14 h-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-button-primary-glow hover:scale-110 transition-transform duration-[var(--duration-fast)]">
|
||||
<Play className="w-6 h-6 ml-1 fill-current" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -75,7 +78,7 @@ export function LibraryPageGrid({
|
|||
<div className="p-4 flex flex-col justify-between h-2/5 bg-black/20 backdrop-blur-sm">
|
||||
<div>
|
||||
<h3
|
||||
className="font-bold text-sm text-foreground truncate mb-1 group-hover:text-primary transition-colors"
|
||||
className="font-bold text-sm text-foreground truncate mb-1 group-hover:text-primary transition-colors duration-[var(--duration-fast)]"
|
||||
title={track.title}
|
||||
>
|
||||
{track.title}
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
|||
{tracks.map((track, i) => (
|
||||
<tr
|
||||
key={track.id}
|
||||
className="group hover:bg-white/5 transition-colors cursor-pointer"
|
||||
className="group hover:bg-white/5 transition-colors duration-[var(--duration-fast)] cursor-pointer"
|
||||
onClick={() => onPlayTrack(track)}
|
||||
>
|
||||
<td className="px-6 py-4 text-center text-muted-foreground group-hover:text-primary">
|
||||
<span className="group-hover:hidden">{i + 1}</span>
|
||||
<Play className="w-4 h-4 hidden group-hover:inline-block fill-current" />
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-foreground group-hover:text-primary transition-colors duration-[var(--duration-fast)]">
|
||||
{track.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground hidden md:table-cell">
|
||||
|
|
@ -55,7 +55,7 @@ export function LibraryPageList({ tracks, onPlayTrack }: LibraryPageListProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors opacity-0 group-hover:opacity-100 text-muted-foreground"
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors duration-[var(--duration-fast)] opacity-0 group-hover:opacity-100 text-muted-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function LibraryPageToolbar({
|
|||
</div>
|
||||
<Button
|
||||
onClick={onNewClick}
|
||||
className="shadow-[0_0_20px_rgba(var(--cyan-500),0.3)] hover:shadow-[0_0_30px_rgba(var(--cyan-500),0.5)] transition-all bg-primary text-primary-foreground"
|
||||
className="shadow-button-primary-glow hover:shadow-button-primary-glow-hover transition-all bg-primary text-primary-foreground"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> New
|
||||
</Button>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue