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:
senke 2026-02-08 17:15:58 +01:00
parent b1ed46b142
commit 39b2b642d2
176 changed files with 3525 additions and 362 deletions

View file

@ -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
View 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/

View 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 lapplication
**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 lexécution est inégale, les règles du projet sont régulièrement violées (valeurs arbitraires, double source dauth), 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 dauth, 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 daccéder à lauth, 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 dimport de providers dans les stories. Handlers MSW très complets (~1600 lignes). Environ 324 stories : bonne couverture de surface.
3. **Patterns de chargement et derreur 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 dinté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 lauth**
`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 à lAPI, et confusion pour tout nouveau dev. Dette darchitecture 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 ellemême utilise `w-64`, `z-[90]`, `z-[95]`, `left-6`, `top-20`. Le design system est sousexploité 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 dun layout ou dun contexte. Signe que la frontière entre routing et feature nest 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. Laccessibilité reste un “plus” et non un pilier, ce qui limite la maturité produit et linclusivité.
---
## B. Écart avec Discord & Spotify
Tableau comparatif **technique et concret** (pas didé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 dauth ; 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 dexé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 limpact ressenti sans tout refactorer :
1. **First meaningful paint**
- Éviter tout JS bloquant avant le premier rendu (splitting par route déjà en place).
- Sassurer que le shell (header + sidebar ou placeholder) + un skeleton de contenu saffichent 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 nont 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 &lt; 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 lauth.
- Arrêt des correctifs “magiques” et renforcement du respect du design system.
**Actions concrètes**
1. **Unifier lauth**
- 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 linverse si vous choisissez Context).
- Faire du AuthProvider un simple consommateur du store (sil 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 dexceptions 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 nest 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 dauth utilisé partout ; plus dimport 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 lappliquer 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 doptimistic updates ont un rollback et un feedback utilisateur en cas déchec (toast, réversion visuelle).
- Centraliser la logique derreur (ex. `apiToastHelper`, ErrorDisplay) pour éviter des traitements ad hoc.
**Risques**
- Scope trop large si on veut tout migrer dun 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ù cest 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.
- Sassurer quaucun 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 dinvalidation 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 lAPI.
**Risques**
- Refactors trop larges ; privilégier des incréments et des ADR pour les décisions darchitecture.
**Indicateurs de réussite**
- Nouveau dev opérationnel (première feature livrée) en un temps cible (ex. &lt; 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 dun 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 dabord, puis composants partagés). Les 130 fichiers se traitent par lots avec revue visuelle et régression.
- **Traiter lauth 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 dun 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 lexcellence 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 dajouter de nouveaux stores globaux “au fil de leau”.**
Vous avez déjà auth, ui, library, cart, chat. Avant den 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 dexé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 lauth et le layout.

View file

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

View file

@ -0,0 +1,68 @@
# App Shell — Référence
Vue densemble du shell applicatif (layout principal) et des tokens CSS associés. Toute évolution du shell (sidebar, header, main, player) doit sappuyer 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.

View file

@ -0,0 +1,109 @@
# Design Tokens — Référence
Source de vérité pour les tokens du design system Veza/Kodo. Toute valeur despacement, couleur, ombre, typographie ou transition doit sappuyer 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 linfo principale et `text-muted-foreground` pour linfo 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).

View 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 lajouter 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 lAPI, 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 nest 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.

View 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');
});
});

View 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' });
});
}
});
});

View file

@ -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: {

View file

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

View file

@ -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',
},
},
],

View 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);
});

View 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 01 (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);
});

View 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 01 (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);
});

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

View file

@ -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,
)}
>

View file

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

View file

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

View file

@ -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>
)}

View file

@ -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>
);

View file

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

View file

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

View file

@ -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'

View file

@ -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}%`,
}}

View file

@ -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={{

View file

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

View file

@ -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}%` }}

View file

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

View file

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

View file

@ -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>
)}

View file

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

View file

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

View file

@ -1,36 +1,115 @@
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 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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-32 bg-white/5 rounded-xl border border-white/10 p-4">Card 1</div>
<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>
{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',
},
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 = {
args: {
children: (
<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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-32 bg-white/5 rounded-xl border border-white/10 p-4">Card 1</div>
<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>
),
},
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'] },
},
};

View file

@ -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();
});

View file

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

View file

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

View file

@ -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',
)}
>

View file

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

View file

@ -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"
)}
>

View file

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

View file

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

View file

@ -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}

View file

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

View file

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

View file

@ -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 */}

View file

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

View file

@ -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'

View file

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

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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,
)}

View file

@ -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>
)}

View file

@ -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',

View file

@ -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%)`,

View file

@ -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>
)}

View file

@ -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,
)}

View file

@ -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,

View file

@ -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>
);

View file

@ -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],
)}

View file

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

View file

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

View file

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

View file

@ -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}

View file

@ -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}%` }}

View file

@ -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)` }}

View file

@ -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,
)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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',
)}
>

View file

@ -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',
)}

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;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>

View file

@ -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}

View file

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

View file

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