chore(design): ajout du package design system smartphone

Contient les tokens, composants et exemples adaptés au mobile,
à utiliser comme référence lors du développement des vues smartphone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 08:53:36 +02:00
co-authored by Claude Sonnet 4.6
parent 4c616fa5d3
commit 4518ed8311
25 changed files with 10222 additions and 0 deletions
@@ -0,0 +1,659 @@
/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});
@@ -0,0 +1,385 @@
/* ============================================================
mobile-forms.jsx
Composants de saisie mobile avec contrôle du clavier virtuel.
Tous nommés et exposés sur window.
============================================================ */
const { useState: uMF, useRef: rMF } = React;
/* ============================================================
FormField — wrapper standard pour un champ
Nom système : FormField
Affiche : label · description · le champ · message d'erreur/hint
============================================================ */
function FormField({ label, hint, error, required, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{label && (
<label style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
</label>
)}
{children}
{(error || hint) && (
<div style={{
fontSize: 12,
color: error ? 'var(--err)' : 'var(--ink-4)',
lineHeight: 1.4,
}}>{error || hint}</div>
)}
</div>
);
}
/* ============================================================
TextInput — champ texte avec contrôle complet du clavier virtuel
Nom système : TextInput
Props clavier virtuel (mobile uniquement) :
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
'given-name'|'family-name'|'current-password'|'new-password'|
'one-time-code'|'off'… (Web Authentication API)
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
spellCheck: bool
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
pattern: regex de validation
============================================================ */
function TextInput({
value, onChange, placeholder, type = 'text', icon, trailing,
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
error,
}) {
const C = multiline ? 'textarea' : 'input';
const inputProps = {
value, onChange: (e) => onChange(e.target.value),
placeholder,
inputMode: keyboard,
autoComplete: autocomplete,
autoCapitalize: autocapitalize,
spellCheck,
enterKeyHint: enterHint,
pattern, maxLength,
rows: multiline ? rows : undefined,
type: !multiline ? type : undefined,
style: {
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
fontSize: 15,
padding: multiline ? '4px 0' : 0,
resize: multiline ? 'vertical' : undefined,
minHeight: multiline ? rows * 22 : undefined,
},
};
return (
<div style={{
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
<C {...inputProps} />
{trailing}
</div>
);
}
/* ============================================================
DateInput — date picker natif mobile
Nom système : DateInput
============================================================ */
function DateInput({ value, onChange, mode = 'date' }) {
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
<input
type={mode}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: 'var(--font-mono)', fontSize: 15,
colorScheme: 'dark',
}}
/>
</div>
);
}
/* ============================================================
Dropdown — select natif stylisé
Nom système : Dropdown
============================================================ */
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
position: 'relative',
}}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
fontFamily: 'var(--font-ui)', fontSize: 15,
appearance: 'none', WebkitAppearance: 'none',
paddingRight: 24,
}}>
<option value="">{placeholder}</option>
{options.map((o) => (
typeof o === 'string'
? <option key={o} value={o}>{o}</option>
: <option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
</div>
);
}
/* ============================================================
CheckboxItem — case à cocher (style iOS)
Nom système : CheckboxItem
Cas : oui/non sur une option, sélection multiple dans une liste
============================================================ */
function CheckboxItem({ checked, onChange, label, description }) {
return (
<label className="touch-press" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '12px 14px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: checked ? 'var(--accent)' : 'var(--bg-1)',
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--bg-1)',
flex: '0 0 auto', marginTop: 1,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
transition: 'all .12s',
}}>
{checked && <Icon name="play" size={11} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
</label>
);
}
/* ============================================================
RadioGroup — groupe d'options exclusives
Nom système : RadioGroup
============================================================ */
function RadioGroup({ value, onChange, options }) {
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
overflow: 'hidden',
}}>
{options.map((o, i) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const d = typeof o === 'object' ? o.description : null;
const active = value === v;
return (
<label key={v} className="touch-press" style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
background: 'var(--bg-1)',
}}>
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
</div>
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
</label>
);
})}
</div>
);
}
/* ============================================================
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
Nom système : MediaInsert
Cas : ajouter une pièce jointe dans un formulaire mobile.
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
et navigator.geolocation pour le GPS.
============================================================ */
function MediaInsert({ onPick }) {
const items = [
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
];
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
}}>
{items.map((it) => (
<label key={it.id} className="touch-press" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, padding: '14px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
color: 'var(--ink-1)',
cursor: 'pointer',
textAlign: 'center',
WebkitTapHighlightColor: 'transparent',
minHeight: 72,
}}>
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
{!it.special && (
<input type="file" accept={it.accept} capture={it.capture}
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
style={{ display: 'none' }} />
)}
{it.special && (
<input type="button" onClick={() => {
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
navigator.geolocation.getCurrentPosition(
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
(err) => onPick && onPick('gps', { error: err.message }),
);
}} style={{ display: 'none' }} />
)}
</label>
))}
</div>
);
}
/* ============================================================
AvatarLogo — gros logo rond pour écran de connexion
Nom système : AvatarLogo
============================================================ */
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
return (
<div style={{
width: size, height: size, borderRadius: size * 0.28,
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: glow
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
margin: '0 auto',
}}>
<Icon name={icon} size={size * 0.45} />
</div>
);
}
/* ============================================================
BiometricButton — bouton biométrie (Face ID / Touch ID)
Nom système : BiometricButton
============================================================ */
function BiometricButton({ kind = 'face', label, onClick }) {
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
return (
<button onClick={onClick} className="touch-press" style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '8px 14px',
background: 'transparent', border: 'none',
color: 'var(--accent)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
{lbl}
</button>
);
}
Object.assign(window, {
FormField, TextInput, DateInput, Dropdown,
CheckboxItem, RadioGroup, MediaInsert,
AvatarLogo, BiometricButton,
});
/* ============================================================
CATALOGUE KEYBOARD — pour la doc
============================================================ */
const KEYBOARD_CATALOG = [
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
];
const AUTOCOMPLETE_CATALOG = [
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
{ name: 'tel', usage: 'Numéro de téléphone' },
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
{ name: 'off', usage: 'Désactive complètement les suggestions' },
];
const ENTER_HINT_CATALOG = [
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
{ name: 'previous', usage: 'Revenir au champ précédent' },
];
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
@@ -0,0 +1,286 @@
/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });
@@ -0,0 +1,407 @@
/* ============================================================
mobile-kit.jsx
Composants mobile-first du design system.
Tous nommés explicitement et exposés sur window.
Tactile-ready : hit targets ≥ 44px, animations fluides,
pas de hover, feedback au touch.
============================================================ */
const { useState: uM, useRef: rM, useEffect: eM } = React;
/* ============================================================
StatusBar — barre de statut iOS-like (en haut de l'écran)
Nom système : StatusBar
Usage : décor en haut de toute page mobile.
============================================================ */
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
return (
<div style={{
height: 44, flex: '0 0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 22px',
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color: 'var(--ink-1)',
}}>
<span>{time}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* signal bars */}
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
{[1, 2, 3, 4].map((b) => (
<span key={b} style={{
width: 3, height: 3 + b * 2, borderRadius: 1,
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
}} />
))}
</span>
<Icon name="network" size={13} />
{/* battery */}
<span style={{
width: 24, height: 11, borderRadius: 3,
border: '1px solid var(--ink-1)',
position: 'relative', marginLeft: 2,
}}>
<span style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${battery / 100})`,
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
borderRadius: 1,
}} />
<span style={{
position: 'absolute', right: -3, top: 3, bottom: 3,
width: 2, background: 'var(--ink-1)',
borderRadius: '0 1px 1px 0',
}} />
</span>
</span>
</div>
);
}
/* ============================================================
NavBar — barre de navigation en haut (titre + actions)
Nom système : NavBar
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
============================================================ */
function NavBar({ title, subtitle, onBack, right, large }) {
return (
<div style={{
flex: '0 0 auto',
padding: large ? '8px 16px 16px' : '8px 12px',
display: 'flex', flexDirection: 'column', gap: 4,
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderBottom: '1px solid var(--border-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
{onBack && (
<button onClick={onBack} style={{
width: 36, height: 36, borderRadius: 8,
background: 'transparent', border: 'none',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>
<Icon name="chevL" size={20} />
</button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!large && (
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
{title}
</div>
)}
</div>
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
</div>
{large && (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
</div>
)}
</div>
);
}
/* ============================================================
TabBar — barre d'onglets en bas (iOS/Android)
Nom système : TabBar
Usage : navigation principale entre 3-5 sections de l'app.
============================================================ */
function TabBar({ items, active, onSelect }) {
return (
<div style={{
flex: '0 0 auto',
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
padding: '6px 8px 18px',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderTop: '1px solid var(--border-2)',
}}>
{items.map((it) => {
const isActive = active === it.id;
return (
<button key={it.id} onClick={() => onSelect(it.id)} style={{
flex: 1, minHeight: 50,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 3, padding: 0,
background: 'transparent', border: 'none',
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
cursor: 'pointer',
transition: 'color .2s, transform .12s',
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
}}>
<Icon name={it.icon} size={22} />
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em', textTransform: 'uppercase',
fontWeight: isActive ? 700 : 500,
}}>{it.label}</span>
</button>
);
})}
</div>
);
}
/* ============================================================
ListRow — ligne d'une liste réglages (style iOS)
Nom système : ListRow
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
============================================================ */
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
const isInteractive = !!onClick;
const Tag = isInteractive ? 'button' : 'div';
return (
<Tag onClick={onClick} style={{
width: '100%',
minHeight: 52,
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: 'transparent',
border: 'none', borderBottom: '1px solid var(--border-1)',
color: danger ? 'var(--err)' : 'var(--ink-1)',
cursor: isInteractive ? 'pointer' : 'default',
textAlign: 'left',
transition: 'background .12s',
WebkitTapHighlightColor: 'transparent',
}}
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
{icon && (
<span style={{
width: 30, height: 30, borderRadius: 7,
background: iconColor || 'var(--bg-4)',
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
}}>
<Icon name={icon} size={15} />
</span>
)}
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
{right}
</Tag>
);
}
/* ============================================================
ListSection — groupe de ListRow avec titre
Nom système : ListSection
============================================================ */
function ListSection({ title, hint, children }) {
return (
<div style={{ marginBottom: 18 }}>
{title && (
<div style={{
padding: '0 16px 6px',
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>{title}</div>
)}
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
margin: '0 12px',
overflow: 'hidden',
boxShadow: 'var(--shadow-1)',
}}>{children}</div>
{hint && (
<div style={{
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
lineHeight: 1.4,
}}>{hint}</div>
)}
</div>
);
}
/* ============================================================
ActionCard — grosse carte d'action tactile
Nom système : ActionCard
Usage : actions principales sur écran d'accueil.
============================================================ */
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
return (
<button onClick={onClick} className="touch-press" style={{
flex: 1, minWidth: 0, minHeight: 110,
padding: 14,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--ink-1)',
textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 6,
cursor: 'pointer',
boxShadow: 'var(--tile-3d)',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 38, height: 38, borderRadius: 9,
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
}}>
<Icon name={icon} size={18} />
</span>
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
{value && (
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
)}
{badge && (
<span style={{
position: 'absolute', top: 10, right: 10,
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px',
background: 'var(--err)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge}</span>
)}
</button>
);
}
/* ============================================================
PrimaryButton — gros bouton plein largeur tactile
Nom système : PrimaryButton
Usage : action principale d'un écran (sauvegarder, valider).
============================================================ */
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
const sizes = {
md: { h: 44, fontSize: 14 },
lg: { h: 52, fontSize: 16 },
}[size];
const styles = {
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="touch-press" style={{
width: '100%',
height: sizes.h,
background: styles.bg,
color: styles.fg,
border: `1px solid ${styles.bd}`,
borderRadius: 12,
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
WebkitTapHighlightColor: 'transparent',
}}>
{icon && <Icon name={icon} size={18} />}
{children}
</button>
);
}
/* ============================================================
SegmentedControl — sélecteur segmenté iOS-style
Nom système : SegmentedControl
Usage : 2-4 options exclusives, jamais plus.
============================================================ */
function SegmentedControl({ value, onChange, options }) {
return (
<div style={{
display: 'flex',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 9,
padding: 3,
gap: 2,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'string' ? null : o.icon;
const active = value === v;
return (
<button key={v} onClick={() => onChange(v)} style={{
flex: 1, minHeight: 36,
padding: '6px 10px',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: 'none', borderRadius: 6,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
transition: 'background .18s, color .18s, transform .12s',
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={13} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
SearchBar — champ de recherche mobile
Nom système : SearchBar
============================================================ */
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
}} />
{value && (
<button onClick={() => onChange('')} style={{
width: 22, height: 22, borderRadius: '50%',
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}><Icon name="close" size={10} /></button>
)}
</div>
);
}
Object.assign(window, {
StatusBar, NavBar, TabBar, ListRow, ListSection,
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
});
/* Effets tactiles : pression au touch (pas de hover) */
(function injectMobileFX() {
if (document.getElementById('mobile-fx')) return;
const s = document.createElement('style');
s.id = 'mobile-fx';
s.textContent = `
.touch-press {
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
}
.touch-press:active {
transform: scale(0.97);
filter: brightness(0.92);
}
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,390 @@
/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});
@@ -0,0 +1,137 @@
/* ============================================================
mobile-swipeable.jsx
SwipeableRow ligne qui révèle des actions au swipe.
============================================================ */
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
/* ============================================================
SwipeableRow
Nom système : SwipeableRow
Cas d'usage : ligne d'une liste avec actions cachées
(archive, suppression, marquer comme lu).
Style iOS Mail / Things / Apple Reminders.
Gestes : SwipeLeft (révèle leftActions à droite),
SwipeRight (révèle rightActions à gauche),
Tap sur la ligne (action principale),
Tap sur une action (déclenche l'action puis ferme).
============================================================ */
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
// leftActions s'affichent quand on swipe vers la GAUCHE
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
const [tx, setTx] = uSw(0);
const [dragging, setDragging] = uSw(false);
const startX = rSw(0);
const initialTx = rSw(0);
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
const snap = (x) => {
if (x < -leftW * 0.5) setTx(-leftW);
else if (x > rightW * 0.5) setTx(rightW);
else setTx(0);
};
const onStart = (e) => {
setDragging(true);
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
initialTx.current = tx;
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
let d = initialTx.current + (x - startX.current);
// limite + élasticité hors zone
if (d > rightW) d = rightW + (d - rightW) * 0.3;
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
setTx(d);
};
const onEnd = () => {
setDragging(false);
snap(tx);
};
const fire = (action) => {
setTx(0);
setTimeout(() => action.onClick && action.onClick(), 200);
};
const handleTap = (e) => {
if (tx !== 0) { setTx(0); return; }
if (Math.abs(tx) < 4 && onTap) onTap(e);
};
return (
<div style={{
position: 'relative',
overflow: 'hidden',
background: 'var(--bg-3)',
WebkitUserSelect: 'none', userSelect: 'none',
}}>
{/* Actions à GAUCHE (révélées par swipe droit) */}
{rightActions.length > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: rightW,
}}>
{rightActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--info)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Actions à DROITE (révélées par swipe gauche) */}
{leftActions.length > 0 && (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: leftW,
}}>
{leftActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--err)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Ligne déplaçable */}
<div
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
onClick={handleTap}
style={{
position: 'relative',
background: 'var(--bg-3)',
transform: `translateX(${tx}px)`,
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
touchAction: 'pan-y',
}}>
{children}
</div>
</div>
);
}
Object.assign(window, { SwipeableRow });
@@ -0,0 +1,656 @@
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes Font Awesome 6 Free.
Mapping nom logique classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();