184 lines
6.7 KiB
Markdown
184 lines
6.7 KiB
Markdown
---
|
|
name: component-skeleton-contract
|
|
description: >-
|
|
Enforce a strict synchronization contract between React components and their skeleton loading states.
|
|
Requires co-located compound <Component.Skeleton /> exports, shared structural layouts,
|
|
zero Cumulative Layout Shift (CLS = 0), OKLCH directional shimmer, and React 19 Suspense / loading.tsx composition.
|
|
---
|
|
|
|
# Component Skeleton Synchronization Contract (Zero-CLS Architecture)
|
|
|
|
Ce skill impose un contrat architectural strict : **chaque composant asynchrone ou dynamique doit posséder et exporter son propre Skeleton co-localisé (`<Component.Skeleton />`)**.
|
|
|
|
Il garantit que le squelette de chargement partage la même structure dimensionnelle que le composant réel, éliminant à 100% le décalage de mise en page (*Cumulative Layout Shift - CLS*), même lors des futures mises à jour du composant.
|
|
|
|
---
|
|
|
|
## 🎯 Déclencheurs d'Activation
|
|
|
|
Activez ce skill dès que :
|
|
1. Vous concevez un composant qui charge des données distantes (cartes de profil, widgets de métriques, listes d'articles, tableaux).
|
|
2. Vous créez un fichier de route de chargement (`loading.tsx` dans Next.js / Remix).
|
|
3. Vous définissez une frontière asynchrone avec `<Suspense fallback={...}>`.
|
|
4. Vous mettez à jour la géométrie ou les espacements d'un composant existant (le Skeleton doit être synchronisé immédiatement).
|
|
5. Vous auditez le critère Core Web Vitals **CLS ≤ 0.1**.
|
|
|
|
---
|
|
|
|
## 📐 1. Le Contrat Architectural Co-localisé (Compound Pattern)
|
|
|
|
### Les 4 Règles d'Or du Contrat :
|
|
1. **Même fichier source** : Le Skeleton vit obligatoirement dans le même fichier que le composant réel.
|
|
2. **Export Compound attaché** : Le composant principal expose son skeleton via la propriété statique `<Component.Skeleton />` ET un export nommé.
|
|
3. **Conteneur Structurel Identique** : Le conteneur racine du Skeleton doit avoir exactement les **mêmes classes CSS de disposition** (`display`, `padding`, `gap`, `grid-template`, `min-height`, `border-radius`) que le composant réel.
|
|
4. **Composition dans `loading.tsx` & `<Suspense>`** : Il est formellement interdit de réinventer un faux layout dans un `loading.tsx`. Une page en chargement doit être l'assemblage exact des `.Skeleton` de ses composants enfants.
|
|
|
|
---
|
|
|
|
## 💫 2. Texture Visuelle : Shimmer Directionnel en OKLCH
|
|
|
|
Les skeletons ne doivent pas utiliser une simple alternance d'opacité grossière (`animate-pulse`). Ils adoptent un **balayage de lumière feutré en OKLCH** qui respecte l'accessibilité cognitive (`prefers-reduced-motion`).
|
|
|
|
### Définition CSS du Shimmer (Tailwind v4 / CSS standard) :
|
|
```css
|
|
@keyframes shimmer {
|
|
0% { transform: translateX(-100%); }
|
|
100% { transform: translateX(100%); }
|
|
}
|
|
|
|
.skeleton-shimmer {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background-color: oklch(0.2 0.01 260); /* Fond neutre sombre teinté */
|
|
}
|
|
|
|
.skeleton-shimmer::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
transform: translateX(-100%);
|
|
background-image: linear-gradient(
|
|
90deg,
|
|
transparent 0%,
|
|
oklch(1 0 0 / 0.07) 50%,
|
|
transparent 100%
|
|
);
|
|
animation: shimmer 1.8s infinite ease-in-out;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.skeleton-shimmer::after {
|
|
animation: none;
|
|
background-image: none;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 💻 3. Exemple d'Implémentation de Référence
|
|
|
|
Voici un composant métier parfait respectant le contrat d'invariance dimensionnelle :
|
|
|
|
```tsx
|
|
// components/user-profile-card.tsx
|
|
import React from 'react';
|
|
|
|
// Classes de disposition partagées pour garantir CLS = 0
|
|
const CONTAINER_LAYOUT = `
|
|
relative overflow-hidden rounded-2xl p-6
|
|
bg-[oklch(0.16_0.008_255)] border border-[oklch(1_0_0/0.08)]
|
|
flex flex-col gap-4 min-h-[200px]
|
|
`;
|
|
|
|
export interface UserProfileCardProps {
|
|
name: string;
|
|
role: string;
|
|
avatarUrl: string;
|
|
stats: { followers: number; projects: number };
|
|
}
|
|
|
|
// 1. Le Composant Réel
|
|
export const UserProfileCard: React.FC<UserProfileCardProps> & {
|
|
Skeleton: React.FC;
|
|
} = ({ name, role, avatarUrl, stats }) => {
|
|
return (
|
|
<article className={CONTAINER_LAYOUT}>
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src={avatarUrl}
|
|
alt={name}
|
|
className="size-14 rounded-full object-cover border border-white/10"
|
|
/>
|
|
<div className="flex flex-col gap-1">
|
|
<h3 className="font-semibold text-lg text-white leading-none">{name}</h3>
|
|
<p className="text-sm text-white/60">{role}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-auto pt-4 border-t border-white/5 flex justify-between text-xs text-white/70">
|
|
<span>{stats.projects} projets</span>
|
|
<span>{stats.followers} abonnés</span>
|
|
</div>
|
|
</article>
|
|
);
|
|
};
|
|
|
|
// 2. Le Skeleton Co-localisé (Géométrie strictly identique)
|
|
const UserProfileCardSkeleton: React.FC = () => {
|
|
return (
|
|
<div aria-hidden="true" className={`${CONTAINER_LAYOUT} animate-pulse`}>
|
|
<div className="flex items-center gap-4">
|
|
{/* Avatar Placeholder */}
|
|
<div className="size-14 rounded-full bg-[oklch(0.24_0.01_260)] shrink-0" />
|
|
|
|
{/* Textes Placeholders */}
|
|
<div className="flex flex-col gap-2 flex-1">
|
|
<div className="h-5 w-32 rounded-md bg-[oklch(0.24_0.01_260)]" />
|
|
<div className="h-4 w-20 rounded-md bg-[oklch(0.20_0.01_260)]" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Placeholder */}
|
|
<div className="mt-auto pt-4 border-t border-white/5 flex justify-between">
|
|
<div className="h-3 w-16 rounded bg-[oklch(0.20_0.01_260)]" />
|
|
<div className="h-3 w-16 rounded bg-[oklch(0.20_0.01_260)]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Liaison compound
|
|
UserProfileCard.Skeleton = UserProfileCardSkeleton;
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 4. Composition dans `loading.tsx` & React 19 Suspense
|
|
|
|
Lors de la création d'une page avec streaming, assemblez les squelettes directement :
|
|
|
|
```tsx
|
|
// app/dashboard/loading.tsx
|
|
import { UserProfileCard } from '@/components/user-profile-card';
|
|
|
|
export default function DashboardLoading() {
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
|
|
<UserProfileCard.Skeleton />
|
|
<UserProfileCard.Skeleton />
|
|
<UserProfileCard.Skeleton />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Checklist de Conformité Skeleton (Zero-CLS Gate)
|
|
|
|
- [ ] Tout composant asynchrone exporte son sous-composant `.Skeleton`.
|
|
- [ ] Le conteneur du Skeleton et celui du composant réel partagent les mêmes classes de grille, flex, marges intérieures (`padding`) et hauteur minimale.
|
|
- [ ] `aria-hidden="true"` est apposé sur le conteneur du squelette pour ne pas perturber les lecteurs d'écran.
|
|
- [ ] Le shimmer visuel se désactive automatiquement sous `prefers-reduced-motion`.
|
|
- [ ] Le fichier `loading.tsx` de la route réutilise exclusivement les composants `.Skeleton` des briques de la page.
|