Silhouette animée de joueur de basket derrière les stats du hero

Ajoute une illustration originale (SVG, pas une photo) d'un joueur en
plein dribble, en aplat noir/blanc avec liseré rouge de la marque, animée
derrière la ligne de statistiques du hero (rebond du ballon, léger
balancement du corps, traits de vitesse). Silhouette générique — aucune
personne réelle identifiable, donc aucune question de droit à l'image.

- Nouveau composant HeroPlayerSilhouette.jsx : figure construite à partir
  de segments à traits épais et jointures rondes (même logique que les
  pictogrammes sportifs plats type NBA/Jordan), plus fiable à main levée
  qu'un tracé bezier continu.
- Positionnement en arrière-plan avec un masque en dégradé qui estompe la
  silhouette exactement derrière le texte des statistiques (déjà proche du
  seuil de contraste WCAG) et ne la laisse apparaître que dans l'espace
  vide à droite — le contraste du texte reste inchangé.
- prefers-reduced-motion : figure et ballon figés, traits de vitesse
  masqués (première utilisation de cette media query dans le projet).
  aria-hidden + pointer-events:none (purement décoratif).
- Réduite à 960px, masquée sous 640px (pas de place pour coexister avec
  la ligne de stats sur petit écran).
- Aucun changement serveur, CSP, Docker ni nouvelle dépendance — CSS/SVG
  pur, dans l'esprit de l'animation marquee déjà existante.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-25 22:43:58 +00:00
1 parent 65213e812a
commit f5b2e26ee4
3 files changed
+127 -7

No files matched your search

@@ -0,0 +1,60 @@
// Purely decorative basketball-player silhouette animated behind the hero
// stats row. An original flat-illustration silhouette (not a photo/video),
// built from rounded-capsule limb segments — the same construction as
// classic flat sports pictograms (NBA/Jordan-style marks) — in a duotone
// matching the site theme. Avoids any droit-à-l'image concern since it
// does not depict a real identifiable person.
export default function HeroPlayerSilhouette({ className = '' }) {
return (
<svg
className={`hero-stats-silhouette ${className}`.trim()}
viewBox="0 0 360 300"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="presentation"
focusable="false"
>
<g className="hspl-figure" fill="var(--ink)">
<g fill="none" stroke="var(--ink)" strokeLinecap="round">
{/* trailing arm, swung back for balance */}
<line x1="222" y1="88" x2="192" y2="64" strokeWidth="15" />
<line x1="192" y1="64" x2="164" y2="50" strokeWidth="10" />
{/* dribbling arm, reaching down to the ball */}
<line x1="230" y1="96" x2="262" y2="136" strokeWidth="16" />
<line x1="262" y1="136" x2="290" y2="186" strokeWidth="10" />
{/* torso */}
<line x1="240" y1="80" x2="215" y2="130" strokeWidth="34" />
<line x1="215" y1="130" x2="198" y2="168" strokeWidth="24" />
{/* planted / front leg */}
<line x1="205" y1="168" x2="224" y2="220" strokeWidth="22" />
<line x1="224" y1="220" x2="218" y2="272" strokeWidth="14" />
<line x1="218" y1="272" x2="240" y2="280" strokeWidth="12" />
{/* trailing leg, extended back */}
<line x1="196" y1="170" x2="150" y2="196" strokeWidth="22" />
<line x1="150" y1="196" x2="108" y2="204" strokeWidth="13" />
<line x1="108" y1="204" x2="84" y2="212" strokeWidth="11" />
</g>
{/* joint fillers smoothing the width change between segments */}
<circle cx="192" cy="64" r="7.5" />
<circle cx="262" cy="136" r="8" />
<circle cx="215" cy="130" r="17" />
<circle cx="201" cy="169" r="13" />
<circle cx="224" cy="220" r="11" />
<circle cx="150" cy="196" r="11" />
<circle cx="248" cy="54" r="23" />
</g>
<g className="hspl-ball" fill="var(--ink)">
<circle cx="300" cy="208" r="22" />
<path d="M280,196 C293,203 307,203 320,196" fill="none" stroke="var(--bg)" strokeWidth="2" />
<path d="M300,187 C293,197 293,220 300,231" fill="none" stroke="var(--bg)" strokeWidth="2" />
</g>
<g className="hspl-speedlines" stroke="var(--ink)" strokeWidth="3" strokeLinecap="round" fill="none">
<path className="hspl-speedline" d="M42,196 L78,201" />
<path className="hspl-speedline" d="M34,214 L72,214" />
<path className="hspl-speedline" d="M40,232 L76,226" />
</g>
</svg>
);
}
+11 -7
View File
@@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx';
import PastEventCard from '../components/PastEventCard.jsx';
import { AdhesionForm, AdhesionModal } from '../components/AdhesionForm.jsx';
import { associationSettings } from '../lib/siteSettings.js';
import HeroPlayerSilhouette from '../components/HeroPlayerSilhouette.jsx';
export function InscriptionModal({ rencontre, onClose, onDone }) {
const { user, login } = useAuth();
@@ -265,13 +266,16 @@ export default function Home() {
<button className="btn btn-outline">Découvrir l'association</button>
</Link>
</div>
<div style={{ display: 'flex', gap: 42, marginTop: 54, paddingTop: 34, borderTop: '1px solid var(--border)' }}>
{[[String(members.length), 'entreprises membres'], [String(passees.length), 'rencontres organisées'], ['30 ans', 'de partenariat']].map(([n, l]) => (
<div key={l}>
<div className="serif" style={{ fontSize: 34, fontWeight: 600 }}>{n}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>{l}</div>
</div>
))}
<div className="hero-stats-wrap" style={{ position: 'relative', marginTop: 54, paddingTop: 34, borderTop: '1px solid var(--border)' }}>
<HeroPlayerSilhouette />
<div style={{ display: 'flex', gap: 42, position: 'relative', zIndex: 1 }}>
{[[String(members.length), 'entreprises membres'], [String(passees.length), 'rencontres organisées'], ['30 ans', 'de partenariat']].map(([n, l]) => (
<div key={l}>
<div className="serif" style={{ fontSize: 34, fontWeight: 600 }}>{n}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 2 }}>{l}</div>
</div>
))}
</div>
</div>
</div>
<div style={{ position: 'relative' }}>
+56
View File
@@ -43,6 +43,60 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
.marquee-fade { width: 40px; }
}
/* Hero stats background: an animated basketball-player silhouette behind
the "entreprises membres / rencontres organisées / X ans" row. Original
illustration (not a photo), decorative only — see .hspl-* below. */
.hero-stats-wrap { overflow: visible; }
.hero-stats-silhouette {
position: absolute; right: -20px; bottom: -34px; width: 300px; height: auto;
pointer-events: none;
/* Fades the silhouette out over the stat text (left) and lets it show at
full (still very low) opacity only over the clear whitespace (right),
so it never degrades the existing text contrast. */
mask-image: linear-gradient(100deg, transparent 0%, transparent 24%, black 48%, black 100%);
-webkit-mask-image: linear-gradient(100deg, transparent 0%, transparent 24%, black 48%, black 100%);
}
.hspl-figure {
opacity: .08; transform-origin: 50% 100%;
animation: hspl-drift 7s ease-in-out infinite alternate, hspl-glow 5s ease-in-out infinite;
}
.hspl-ball {
opacity: .14; transform-box: fill-box; transform-origin: 50% 100%;
animation: hspl-ball-bounce 1.4s infinite;
}
.hspl-speedline {
opacity: .3;
animation: hspl-speedline 2.4s ease-out infinite;
}
.hspl-speedline:nth-child(2) { animation-delay: .3s; }
.hspl-speedline:nth-child(3) { animation-delay: .6s; }
@keyframes hspl-drift {
from { transform: translateY(0) rotate(0deg); }
to { transform: translateY(-10px) rotate(-1.4deg); }
}
@keyframes hspl-glow {
0%, 100% { filter: drop-shadow(2px 0 0 rgba(193, 39, 45, 0)); }
50% { filter: drop-shadow(2px 0 0 rgba(193, 39, 45, .18)); }
}
@keyframes hspl-ball-bounce {
0% { transform: translateY(0); animation-timing-function: cubic-bezier(.55, 0, 1, .45); }
18% { transform: translateY(38px) scaleY(.82) scaleX(1.1); animation-timing-function: cubic-bezier(0, .55, .45, 1); }
50% { transform: translateY(0); animation-timing-function: cubic-bezier(.55, 0, 1, .45); }
68% { transform: translateY(38px) scaleY(.82) scaleX(1.1); animation-timing-function: cubic-bezier(0, .55, .45, 1); }
100% { transform: translateY(0); }
}
@keyframes hspl-speedline {
0% { opacity: 0; transform: translateX(6px); }
30% { opacity: 1; transform: translateX(0); }
70% { opacity: 1; transform: translateX(-4px); }
100% { opacity: 0; transform: translateX(-10px); }
}
@media (prefers-reduced-motion: reduce) {
.hspl-figure, .hspl-ball { animation: none !important; }
.hspl-speedline { display: none !important; }
}
.container { max-width: 1180px; margin: 0 auto; padding-left: 32px; padding-right: 32px; }
.serif { font-family: var(--serif); }
@@ -248,6 +302,7 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
@media (max-width: 960px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr !important; }
.hero-grid { grid-template-columns: 1fr !important; }
.hero-stats-silhouette { width: 190px; right: -10px; bottom: -10px; }
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
@@ -274,6 +329,7 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r
@media (max-width: 640px) {
.container { padding-left: 20px; padding-right: 20px; }
.hero-stats-silhouette { display: none; }
.admin-nav { grid-template-columns: 1fr; gap: 20px; }
.admin-sidebar-footer { align-items: stretch; flex-direction: column; }
.admin-sidebar-action { width: 100%; }