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
parent 4c616fa5d3
commit 4518ed8311
25 changed files with 10222 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
# mon design system — Package Smartphone (iOS / Android)
> Adaptation mobile complète de mon design system Gruvbox seventies.
> **45+ composants nommés**, optimisés tactile (hit targets ≥ 44px), animations fluides,
> dark + light, gestes nommés et testables, contrôle complet du clavier virtuel.
>
> Version 1.0 · iOS & Android
---
## 🚀 Démarrage rapide
```html
<!DOCTYPE html>
<html data-theme="dark">
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="tokens/tokens.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<!-- Base UI -->
<script type="text/babel" src="components/ui-kit.jsx"></script>
<!-- Mobile -->
<script type="text/babel" src="components/mobile-kit.jsx"></script>
<script type="text/babel" src="components/mobile-sheets.jsx"></script>
<script type="text/babel" src="components/mobile-gestures.jsx"></script>
<script type="text/babel" src="components/mobile-swipeable.jsx"></script>
<script type="text/babel" src="components/mobile-forms.jsx"></script>
<script type="text/babel" src="components/mobile-apps.jsx"></script>
<script type="text/babel">
// Ton app ici
</script>
</body>
</html>
```
---
## 📂 Contenu du package
```
package-smartphone/
├── README.md ← Ce fichier (guide humain)
├── consigne_mobile.md ← Brief pour agents IA
├── tokens/
│ ├── tokens.css ← Variables CSS web (dark + light)
│ ├── tokens.gnome.css ← Pour apps GTK
│ └── tokens.json ← Format générique
├── components/
│ ├── ui-kit.jsx ← Base (Button, Icon, Popup, gauges…)
│ ├── mobile-kit.jsx ← StatusBar, NavBar, TabBar, ActionCard…
│ ├── mobile-sheets.jsx ← BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh
│ ├── mobile-gestures.jsx ← useGesture, GestureZone (9 gestes nommés)
│ ├── mobile-swipeable.jsx ← SwipeableRow (swipe-to-reveal actions)
│ ├── mobile-forms.jsx ← TextInput, DateInput, Dropdown, Checkbox, Radio, MediaInsert…
│ └── mobile-apps.jsx ← Avatar+menu, Onboarding, Chat, Calendar, Maps, Scanner, Caméra, Files
└── examples/
├── exemple-mobile.html ← Vue d'ensemble + gestes interactifs
├── exemple-mobile-saisie.html ← Login, profil, formulaire complet, clavier virtuel
└── exemple-mobile-apps.html ← 10 écrans : onboarding, chat, calendrier, maps, scanner, caméra, fichiers, settings
```
Tous les exemples ont leurs composants copiés à côté pour fonctionner sans navigation `../`. Ouvre n'importe lequel directement dans le navigateur.
---
## 🧩 Tous les composants nommés
### Structure d'écran
- **`StatusBar`** — barre de statut iOS (heure, signal, batterie)
- **`NavBar`** — barre titre, mode compact ou large, bouton retour, actions à droite
- **`TabBar`** — barre d'onglets en bas (3-5 sections)
### Boutons & contrôles
- **`PrimaryButton`** — gros bouton plein largeur (primary, ghost, danger)
- **`IconButton`** — bouton icône seul avec tooltip
- **`Toggle`** — switch on/off
- **`SegmentedControl`** — sélecteur 2-4 options exclusives
- **`Button`** — bouton générique (depuis ui-kit)
### Listes
- **`ListSection / ListRow`** — liste de réglages iOS-style
- **`ActionCard`** — grosse tuile tactile pour grille d'apps
- **`SwipeableRow`** — ligne révélant des actions au swipe (Mail/Things)
- **`FileExplorer`** — liste de fichiers avec icônes par type + actions swipe
### Saisie
- **`FormField`** — wrapper avec label + hint + erreur
- **`TextInput`** — champ texte avec contrôle complet du clavier virtuel
- **`DateInput`** — picker date/heure/datetime natif mobile
- **`Dropdown`** — select avec sélecteur natif
- **`CheckboxItem`** — case à cocher avec label + description
- **`RadioGroup`** — options exclusives empilées
- **`MediaInsert`** — grille pour ajouter photo/image/vidéo/audio/fichier/GPS
- **`SearchBar`** — recherche tactile avec icône loupe
- **`FilterChips`** — chips horizontaux multi-sélection
### Fenêtres / Dialogues
- **`BottomSheet`** — feuille modale qui monte du bas (swipe-down pour fermer)
- **`ActionSheet`** — menu d'actions iOS (2-6 choix)
- **`AlertDialog`** — alerte centrée bloquante (confirmation destructive)
- **`Toast`** — notification éphémère 2.5s
- **`Popup`** — modale glassmorphism (depuis ui-kit)
### Identité utilisateur
- **`Avatar`** — bouton rond avec initiales (haut-droite de chaque écran)
- **`AvatarMenu`** — popup descendant : Profil / Paramètres / Aide / Déconnexion
- **`AvatarLogo`** — gros logo carré arrondi (login, profil)
- **`BiometricButton`** — Face ID / Touch ID
### Patterns d'app
- **`OnboardingSlider`** — slides + dots + suivant/passer
- **`ChatBubble`** — bulle message (envoyé/reçu, statut ✓ ✓✓)
- **`ChatComposer`** — barre d'envoi avec joindre + audio + send
- **`CalendarMonth`** — vue mois avec évènements
- **`MapView`** — carte avec pins colorés
- **`QrScannerView`** — viseur scanner avec ligne animée
- **`CameraView`** — viseur photo avec règle des tiers
### Gestes
- **`useGesture`** — hook qui détecte 9 gestes nommés
- **`GestureZone`** — zone interactive qui affiche le geste détecté
### Datavis (mobile-friendly)
- **`RadialGauge`**, **`BatteryGauge`** (compact ou standard), **`BigRadialGauge`**, **`Sparkline`**, **`LineChart`**, **`StatusLed`**
---
## ✋ Gestes nommés
9 gestes détectables par `useGesture()` :
| Nom | Geste | Usage typique |
|-------------|---------------------------------------|---------------------------------------|
| `Tap` | Pression rapide | Action principale (équiv. clic) |
| `DoubleTap` | Deux Tap rapprochés (< 300 ms) | Zoomer, liker |
| `LongPress` | Pression maintenue ≥ 500 ms | Menu contextuel, sélection |
| `SwipeLeft` | Glisser vers la gauche | Écran suivant, supprimer une ligne |
| `SwipeRight`| Glisser vers la droite | Écran précédent, archiver |
| `SwipeUp` | Glisser vers le haut | Plus de détails, fermer popup |
| `SwipeDown` | Glisser vers le bas | Rafraîchir, fermer BottomSheet |
| `Pan` | Glisser en continu | Déplacer un élément, scroll horizontal|
| `Pinch` | Écarter / rapprocher 2 doigts | Zoomer carte ou image |
---
## ⌨️ Clavier virtuel — paramètres
`TextInput` accepte 4 props clés pour contrôler le clavier mobile :
### `keyboard` (inputmode) — quel clavier afficher
- `text` — clavier standard
- `numeric` — pavé 0-9 (codes, OTP)
- `decimal` — pavé + virgule (prix, mesures)
- `tel` — pavé téléphone
- `email` — clavier texte + @ et . directs
- `url` — clavier texte + / et .com
- `search` — clavier standard, touche Entrée = "Rechercher"
- `none` — aucun clavier (picker custom)
### `autocomplete` — aide à la saisie système
- `email` · `tel` · `name` · `given-name` · `family-name`
- `address-line1` · `postal-code` · `country`
- `current-password` · `new-password`
- `one-time-code` (auto-lecture SMS sur iOS/Android !)
- `off`
### `enterHint` — texte de la touche Entrée
- `send` · `search` · `go` · `done` · `next` · `previous`
### `autocapitalize` — majuscules auto
- `sentences` · `words` · `characters` · `off`
### Combinaisons usuelles
| Cas | Réglages |
|--------------------|-------------------------------------------------------------------|
| Email | `keyboard="email" autocomplete="email" autocapitalize="off"` |
| Mot de passe | `type="password" autocomplete="current-password"` (ou `new-password`) |
| Code OTP SMS | `keyboard="numeric" autocomplete="one-time-code" maxLength={6}` |
| Téléphone | `keyboard="tel" autocomplete="tel"` |
| Recherche | `keyboard="search" enterHint="search"` |
| Prix / mesure | `keyboard="decimal"` |
| Adresse postale | `autocomplete="address-line1"`, puis `postal-code`, `country` |
| Texte libre | `autocapitalize="sentences" spellCheck={true}` |
---
## 🪟 Types de fenêtres mobile
| Type | Quand l'utiliser | Geste pour fermer |
|----------------|------------------------------------------------------------------|----------------------|
| `BottomSheet` | Action contextuelle, formulaire court, choix dans une liste | SwipeDown ↓ |
| `ActionSheet` | Menu d'actions (2-6 choix) sur un élément | Tap "Annuler" ou hors|
| `AlertDialog` | Confirmation destructive (suppression, déconnexion) | Volontairement bloquant|
| `Toast` | Feedback succès/erreur après une action | Auto 2.5s |
| `Popup` | Modale centrée plus large (form de config avancé) | ✕ ou clic extérieur |
---
## 📱 3 pages d'exemples (à ouvrir)
1. **`examples/exemple-mobile.html`** — vue d'ensemble, gestes interactifs, premières patterns
2. **`examples/exemple-mobile-saisie.html`** — login, profil, formulaire complet, clavier virtuel
3. **`examples/exemple-mobile-apps.html`** — 10 patterns d'app : onboarding, chat, calendrier, maps, scanner QR, caméra, fichiers, settings, avatar menu
Chaque page affiche un smartphone à gauche (basculable iOS/Android, sombre/clair) et la doc commentée à droite avec mini-visuels SVG sous chaque composant et écran.
---
## ⚙️ Paramétrage
Tout fonctionne avec les **mêmes tokens CSS** que le package desktop. Pour changer une couleur, édite `tokens/tokens.css` :
```css
:root[data-theme="dark"] {
--accent: #fe8019; /* Orange Gruvbox */
--bg-1: #2a231d; /* Fond app */
--ok: #4dbb26;
--warn: #fabd2f;
--err: #fb4934;
--blue: #3db0d1;
--purple: #c882c8;
}
```
Bascule de thème :
```js
document.documentElement.dataset.theme = 'light'; // ou 'dark'
```
---
## ✅ Checklist d'intégration
- [ ] Polices chargées (Inter, JetBrains Mono, Share Tech Mono)
- [ ] Font Awesome 6 chargé
- [ ] `tokens/tokens.css` chargé
- [ ] `data-theme="dark"` (ou "light") sur `<html>`
- [ ] React 18 + Babel chargés
- [ ] `ui-kit.jsx` chargé en premier
- [ ] Modules mobile chargés dans l'ordre : kit → sheets → gestures → swipeable → forms → apps
---
## 🤖 Pour les agents IA
Lire `consigne_mobile.md` pour les règles d'utilisation et conventions.
@@ -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);
})();
@@ -0,0 +1,192 @@
# Consignes mobile — mon design system
> **Tu es un agent IA qui produit du code mobile avec ce design system.**
> Lis ce fichier ENTIER avant d'écrire la moindre ligne. Suis les règles à la lettre.
---
## 🎯 Identité
- **Cibles** : iOS et Android via HTML/JS (Cordova, Capacitor, ou PWA)
- **Largeur ref** : 390px (iPhone 14 / Galaxy S22)
- **Hit target min** : 44 × 44px (Apple HIG / Material)
- **Style** : Gruvbox seventies — orange brûlé, fond brun délavé en sombre / gris clair usé en clair
---
## 📁 Fichiers
| Fichier | Composants exposés sur window |
|-------------------------------|----------------------------------------------------------------------------|
| `components/ui-kit.jsx` | Icon, Tooltip, Button, IconButton, Toggle, StatusLed, Popup, BatteryGauge, RadialGauge, BigRadialGauge, TreeNav, Sparkline, LineChart |
| `components/mobile-kit.jsx` | StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar |
| `components/mobile-sheets.jsx`| BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh |
| `components/mobile-gestures.jsx`| useGesture, GestureZone, GESTURE_CATALOG |
| `components/mobile-swipeable.jsx`| SwipeableRow |
| `components/mobile-forms.jsx` | FormField, TextInput, DateInput, Dropdown, CheckboxItem, RadioGroup, MediaInsert, AvatarLogo, BiometricButton |
| `components/mobile-apps.jsx` | Avatar, AvatarMenu, OnboardingSlider, ChatBubble, ChatComposer, CalendarMonth, MapView, FilterChips, QrScannerView, CameraView, FileExplorer |
---
## ⚠️ Règles absolues
1. **Hit targets ≥ 44 × 44 px** sur TOUT élément tactile. Pas de petits boutons.
2. **Pas de hover** — c'est du tactile. Utilise la pression au touch via `.touch-press`.
3. **Tooltips** sur tous les `<IconButton>` isolés (la prop `label` les active automatiquement).
4. **Toujours des polices natives mobile** : Inter / JetBrains Mono / Share Tech Mono via tokens.
5. **Animations fluides** : 180-300ms, easing `cubic-bezier(.3,.7,.3,1.2)` pour entrée, `cubic-bezier(.3,.6,.3,1)` pour mouvement.
6. **Toujours `<TabBar>` en bas** comme navigation primaire (3-5 sections).
7. **JAMAIS de popup centrée modale standard** — utilise `BottomSheet`, `ActionSheet`, `AlertDialog` ou `Toast`.
8. **Bouton Avatar en haut à droite** de chaque écran principal pour accès rapide au menu utilisateur.
9. **Variables CSS uniquement** — pas de hex en dur (`color: var(--accent)`, jamais `color: #fe8019`).
10. **Smartphone d'abord** — toute interaction doit fonctionner avec un seul pouce.
---
## 🧩 Cas → Composant à utiliser
### Structure d'écran (toujours)
```jsx
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Mon écran" right={<Avatar name="M" onClick={openMenu} />} />
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{/* contenu */}
</div>
</div>
```
Si la page fait partie d'une nav principale, ajoute `<TabBar>` après. Si elle a une action principale flottante, ajoute `<FAB>`.
### Liste à actions cachées
```jsx
<SwipeableRow
onTap={() => open(item)}
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)', onClick: del }]} // swipe gauche
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)', onClick: read }]} // swipe droit
>
<div>{contenu de la ligne}</div>
</SwipeableRow>
```
### Champ texte typé
```jsx
// Email
<TextInput value={...} onChange={...} keyboard="email" autocomplete="email" autocapitalize="off" />
// Mot de passe
<TextInput value={...} onChange={...} type="password" autocomplete="current-password" />
// OTP SMS
<TextInput value={...} onChange={...} keyboard="numeric" autocomplete="one-time-code" maxLength={6} />
// Recherche
<TextInput value={...} onChange={...} keyboard="search" enterHint="search" />
```
### Confirmation destructive
```jsx
<AlertDialog open={open} onClose={...}
icon="alert" iconColor="var(--err)"
title="Supprimer ?"
message="Cette action est irréversible."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: del },
]} />
```
### Choix dans une liste
```jsx
<BottomSheet open={...} onClose={...} title="Choisir">
<RadioGroup value={...} onChange={...} options={[...]} />
</BottomSheet>
```
### Menu d'actions sur élément
```jsx
<ActionSheet open={...} onClose={...} title="Actions"
actions={[
{ label: 'Modifier', icon: 'cog' },
{ label: 'Partager', icon: 'download' },
{ label: 'Supprimer', icon: 'close', danger: true },
]} />
```
### Feedback après action
```jsx
<Toast open={msg !== null} onClose={() => setMsg(null)} message={msg} variant="ok" />
```
### Menu utilisateur (avatar haut-droite)
```jsx
<AvatarMenu open={...} onClose={...} name="Marc" email="marc@..."
items={[
{ icon: 'user', label: 'Mon profil' },
{ icon: 'cog', label: 'Paramètres' },
{ icon: 'power', label: 'Se déconnecter', danger: true },
]} />
```
---
## 🚫 Anti-patterns
-`window.alert / confirm` → utilise `AlertDialog`
-`<button>` nu sans hit target → utilise `IconButton` ou `PrimaryButton`
- ❌ Hover effects → pas d'hover sur mobile, utilise `.touch-press`
- ❌ Popups centrées pour formulaire court → `BottomSheet`
- ❌ Sidebars > 240px → utilise plutôt drawer ou TabBar
- ❌ Tableaux denses → liste swipeable avec actions
- ❌ Couleurs en dur → toujours `var(--token)`
- ❌ Police arbitraire → `var(--font-ui)`, `var(--font-mono)`, `var(--font-terminal)`
- ❌ Boutons texte sans icône pour actions critiques → ajoute toujours une icône
- ❌ Inputs sans `keyboard`/`autocomplete` adaptés → frustre l'utilisateur
---
## 📐 Tailles
| Élément | Taille |
|----------------------|--------------------------------------------------|
| StatusBar | 44px |
| NavBar compact | 52px |
| NavBar large | ~90px |
| TabBar | 70px (avec safe area) |
| ListRow | min 52px |
| PrimaryButton lg | 52px |
| IconButton | 34px (def) / 26px (compact) / 44px (large) |
| FAB | 56 × 56 ronde |
| Toggle | 42 × 22 |
| Radius cartes | 10-14px |
| Radius boutons | 8-12px |
| Avatar | 36px (header) / 48-72px (profile) |
| Espacement standard | 8 / 12 / 14 / 18 / 24px |
---
## 💡 Détails à respecter
- **Safe area bottom** : la TabBar a déjà un `padding-bottom: 18px` pour la home indicator iOS.
- **Backdrop-filter blur** : utilisé sur NavBar/TabBar/AlertBg pour effet vitre.
- **SwipeableRow** snap : ouvre/ferme à 50% de la largeur des actions.
- **AvatarMenu** : un seul ouvert à la fois, ferme au clic extérieur (backdrop).
- **Toast** : auto-ferme à 2.5s sauf prop `duration` différent.
- **PullToRefresh** : seulement quand `scrollTop === 0`.
---
## 🌗 Dark / Light
Tout fonctionne automatiquement via `data-theme="dark|light"` sur un parent. **Toujours tester les deux** :
- En sombre : tokens chauds bruns
- En clair : gris clair usé (pas blanc pur), accent orange plus contrasté
---
## 🔚 En cas de doute
- Composant pas sûr ? → `examples/exemple-mobile-apps.html` montre quasi tous les cas
- Geste pas clair ? → onglet Gestes du smartphone dans `exemple-mobile.html`
- Saisie spéciale ? → `exemple-mobile-saisie.html` + section "Antisèche"
Toujours préférer un composant existant à un custom. Quand tu doutes, **demande**.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — patterns d'app</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 130px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="mobile-apps.jsx"></script>
<script type="text/babel" src="exemple-mobile-apps-combined.jsx"></script>
</body>
</html>
@@ -0,0 +1,341 @@
/* ============================================================
exemple-mobile-saisie-app.jsx — partie 1
Écrans du smartphone (Login, Profile, Form, SwipeList).
La partie Doc + ROOT est dans exemple-mobile-saisie-doc.jsx.
============================================================ */
const { useState: uMS, useEffect: eMS } = React;
/* ============================================================
ÉCRAN 1 — Login
============================================================ */
function ScreenLogin({ onLogin, showToast }) {
const [email, setEmail] = uMS('');
const [pwd, setPwd] = uMS('');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<div style={{
flex: 1, padding: '32px 24px', overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<div style={{ textAlign: 'center', marginTop: 24, marginBottom: 12 }}>
<AvatarLogo icon="server" size={80} />
<div style={{ fontSize: 26, fontWeight: 700, marginTop: 16 }}>Bienvenue</div>
<div style={{ fontSize: 14, color: 'var(--ink-3)', marginTop: 4 }}>Connecte-toi à ton compte</div>
</div>
<FormField label="Email">
<TextInput value={email} onChange={setEmail}
placeholder="prenom@exemple.com"
type="email" icon="bell"
keyboard="email"
autocomplete="email"
autocapitalize="off"
spellCheck={false}
enterHint="next" />
</FormField>
<FormField label="Mot de passe" hint="≥ 8 caractères, 1 chiffre">
<TextInput value={pwd} onChange={setPwd}
placeholder="••••••••"
type="password" icon="power"
keyboard="text"
autocomplete="current-password"
autocapitalize="off"
spellCheck={false}
enterHint="go" />
</FormField>
<a href="#" onClick={(e) => { e.preventDefault(); showToast('Email de réinitialisation envoyé'); }}
style={{ fontSize: 13, color: 'var(--accent)', textAlign: 'right', textDecoration: 'none' }}>
Mot de passe oublié ?
</a>
<div style={{ marginTop: 6 }}>
<PrimaryButton icon="play" onClick={() => { onLogin(); showToast('Bienvenue Marc !'); }}>
Se connecter
</PrimaryButton>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0',
color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11,
}}>
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
OU
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24 }}>
<BiometricButton kind="face" onClick={() => { onLogin(); showToast('Face ID OK'); }} />
<BiometricButton kind="touch" onClick={() => { onLogin(); showToast('Touch ID OK'); }} />
</div>
<div style={{ textAlign: 'center', marginTop: 16, fontSize: 14, color: 'var(--ink-3)' }}>
Pas encore de compte ?{' '}
<a href="#" onClick={(e) => e.preventDefault()} style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
S'inscrire
</a>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 2 — Profile (avec bouton Paramètres haut-droite)
============================================================ */
function ScreenProfile({ openSettings }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Profil" right={
<IconButton icon="cog" label="Paramètres" onClick={openSettings} size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<div style={{
padding: '20px 16px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<AvatarLogo icon="user" size={72} />
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 8 }}>Marc Dupont</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }} className="mono">admin · marc@exemple.com</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--ok-glow)', color: 'var(--ok)',
border: '1px solid var(--ok)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>● connecté</span>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--accent-tint)', color: 'var(--accent)',
border: '1px solid var(--accent)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>premium</span>
</div>
</div>
<ListSection title="Mon compte">
<ListRow icon="user" iconColor="var(--blue)" label="Informations personnelles" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" value="3" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--ok)" label="Sécurité & connexion" onClick={() => {}} />
</ListSection>
<ListSection title="Mes données">
<ListRow icon="download" iconColor="var(--info)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="folder" iconColor="var(--purple)" label="Mes documents" value="124" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="close" iconColor="var(--ink-4)" label="Se déconnecter" onClick={() => {}} />
</ListSection>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 3 — Form (formulaire de saisie complet)
============================================================ */
function ScreenForm({ showToast, openSheet }) {
const [title, setTitle] = uMS('');
const [date, setDate] = uMS('2026-05-21');
const [time, setTime] = uMS('14:30');
const [body, setBody] = uMS('');
const [category, setCategory] = uMS('');
const [priority, setPriority] = uMS('normal');
const [tags, setTags] = uMS({ urgent: false, perso: true, travail: false });
const [confirmed, setConfirmed] = uMS(false);
const [media, setMedia] = uMS([]);
const onMedia = (kind, data) => {
if (kind === 'gps' && data && data.lat) {
setMedia([...media, { kind: 'gps', label: `GPS · ${data.lat.toFixed(4)}, ${data.lon.toFixed(4)}` }]);
} else if (data && data.name) {
setMedia([...media, { kind, label: `${kind} · ${data.name}` }]);
} else {
showToast(`${kind} sélectionné`);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Nouvelle note"
onBack={() => showToast('Retour')}
right={
<button onClick={() => { showToast('Enregistré'); }} style={{
padding: '6px 12px',
background: 'transparent', border: 'none',
color: 'var(--accent)', fontFamily: 'var(--font-ui)',
fontWeight: 700, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>OK</button>
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 80px' }}>
<FormField label="Titre" required>
<TextInput value={title} onChange={setTitle}
placeholder="Titre de la note"
keyboard="text" autocapitalize="sentences"
enterHint="next" maxLength={80} icon="list" />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<FormField label="Date">
<DateInput value={date} onChange={setDate} mode="date" />
</FormField>
<FormField label="Heure">
<DateInput value={time} onChange={setTime} mode="time" />
</FormField>
</div>
<FormField label="Contenu" hint="Décris ce qui doit être fait.">
<TextInput value={body} onChange={setBody}
placeholder="Tape ton texte ici…"
multiline rows={4}
keyboard="text" autocapitalize="sentences"
spellCheck={true} />
</FormField>
<FormField label="Catégorie">
<Dropdown value={category} onChange={setCategory}
placeholder="Choisir une catégorie…"
options={[
{ value: 'todo', label: 'À faire' },
{ value: 'note', label: 'Note simple' },
{ value: 'meeting', label: 'Réunion' },
{ value: 'bug', label: 'Bug à corriger' },
]} />
</FormField>
<FormField label="Priorité">
<RadioGroup value={priority} onChange={setPriority} options={[
{ value: 'low', label: 'Basse', description: 'Sans urgence' },
{ value: 'normal', label: 'Normale', description: 'Par défaut' },
{ value: 'high', label: 'Haute', description: 'À traiter rapidement' },
]} />
</FormField>
<FormField label="Étiquettes">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries({ urgent: 'Urgent', perso: 'Perso', travail: 'Travail' }).map(([k, v]) => (
<CheckboxItem key={k}
checked={tags[k]}
onChange={(c) => setTags({ ...tags, [k]: c })}
label={v} />
))}
</div>
</FormField>
<FormField label="Pièces jointes">
<MediaInsert onPick={onMedia} />
{media.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{media.map((m, i) => (
<div key={i} style={{
padding: '8px 12px', fontSize: 12, fontFamily: 'var(--font-mono)',
background: 'var(--bg-3)', border: '1px solid var(--border-2)',
borderRadius: 8, color: 'var(--ink-2)',
}}>📎 {m.label}</div>
))}
</div>
)}
</FormField>
<FormField label="Code de confirmation" hint="On t'envoie un code par SMS.">
<TextInput value="" onChange={() => {}}
placeholder="123456"
keyboard="numeric"
autocomplete="one-time-code"
maxLength={6} icon="bell" />
</FormField>
<CheckboxItem checked={confirmed} onChange={setConfirmed}
label="J'accepte les conditions"
description="En cochant, tu acceptes notre politique." />
<div style={{ marginTop: 16 }}>
<PrimaryButton icon="download" onClick={() => showToast('Note enregistrée')}>
Enregistrer la note
</PrimaryButton>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 4 — Liste avec SwipeableRow
============================================================ */
function ScreenSwipe({ showToast }) {
const [items, setItems] = uMS([
{ id: 1, title: 'Sauvegarde serveur OK', from: 'cron@srv', time: '14:02', unread: true },
{ id: 2, title: 'Latence élevée détectée', from: 'monitoring', time: '13:58', unread: true },
{ id: 3, title: 'Rappel : réunion équipe', from: 'agenda', time: '11:30', unread: false },
{ id: 4, title: 'Mise à jour disponible', from: 'systeme', time: '09:14', unread: false },
{ id: 5, title: 'Nouveau hôte sur le réseau', from: 'ipwatch', time: '08:42', unread: true },
]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Notifications" subtitle="essaie de swiper une ligne ←→" />
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 80 }}>
{items.map((it) => (
<SwipeableRow key={it.id}
onTap={() => showToast(`Ouvrir : ${it.title}`)}
rightActions={[
{ label: 'Lu', icon: 'play', color: 'var(--info)',
onClick: () => setItems(items.map((x) => x.id === it.id ? { ...x, unread: false } : x)) },
{ label: 'Épingl.', icon: 'bell', color: 'var(--accent)',
onClick: () => showToast('Épinglé') },
]}
leftActions={[
{ label: 'Archiv.', icon: 'folder', color: 'var(--ok)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Archivé'); } },
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Supprimé'); } },
]}>
<div style={{
padding: '14px 16px',
display: 'flex', gap: 12, alignItems: 'flex-start',
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: it.unread ? 'var(--accent)' : 'transparent',
marginTop: 6, flex: '0 0 auto',
boxShadow: it.unread ? '0 0 6px var(--accent-glow)' : 'none',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: it.unread ? 700 : 500, color: 'var(--ink-1)' }}>{it.from}</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }} className="mono">{it.time}</span>
</div>
<div style={{ fontSize: 14, color: 'var(--ink-2)', marginTop: 2 }}>{it.title}</div>
</div>
</div>
</SwipeableRow>
))}
{items.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ink-3)' }}>
Plus de notifications — fais un swipe sur une ligne ←→ pour voir les actions.
</div>
)}
<div style={{
padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)',
}}>
swipe gauche : archiver/supprimer · swipe droit : marquer lu/épingler
</div>
</div>
</div>
);
}
Object.assign(window, { ScreenLogin, ScreenProfile, ScreenForm, ScreenSwipe });
@@ -0,0 +1,486 @@
/* ============================================================
exemple-mobile-saisie-doc.jsx — partie 2
Doc panneau droit (catalogue commenté avec visuels) + ROOT.
============================================================ */
const { useState: uDS, useEffect: eDS } = React;
/* ============================================================
VISUALS ============================================================ */
/* Mini-clavier virtuel selon le type */
function KeyboardVisual({ kind }) {
const wrap = (cells) => (
<div style={{
padding: 10, background: 'var(--bg-1)',
border: '1px solid var(--border-2)', borderRadius: 8,
display: 'flex', flexDirection: 'column', gap: 4,
width: '100%',
}}>{cells.map((c, i) => <React.Fragment key={i}>{c}</React.Fragment>)}</div>
);
const row = (keys, big) => (
<div style={{ display: 'flex', gap: 3, justifyContent: 'center' }}>
{keys.map((k, i) => (
<span key={i} style={{
flex: big ? 1 : '0 1 auto',
minWidth: big ? 0 : 16, height: 22, padding: '0 4px',
background: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--accent)' : 'var(--bg-3)',
color: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--bg-1)' : 'var(--ink-1)',
border: '1px solid var(--border-2)',
borderRadius: 4,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
}}>{k}</span>
))}
</div>
);
if (kind === 'text') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','↵'], true)]);
if (kind === 'numeric') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row(['','0','⌫'], true)]);
if (kind === 'decimal') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row([',','0','⌫'], true)]);
if (kind === 'tel') return wrap([row(['1','2 ABC','3 DEF'], true), row(['4 GHI','5 JKL','6 MNO'], true), row(['7 PQRS','8 TUV','9 WXYZ'], true), row(['+ * #','0 +','⌫'], true)]);
if (kind === 'email') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','@','espace','.','↵'], true)]);
if (kind === 'url') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','.','/','.com','Aller'], true)]);
if (kind === 'search') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','Rechercher'], true)]);
if (kind === 'none') return (
<div style={{
padding: 14, background: 'var(--bg-1)',
border: '1px dashed var(--border-3)', borderRadius: 8,
textAlign: 'center', color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)', fontSize: 11,
}}>(aucun clavier picker custom)</div>
);
return null;
}
/* Mini SVG phone pour montrer les écrans */
function ScreenVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block' }}>
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{inner}
</svg>
);
if (type === 'login') return phone(
<g>
<circle cx="50" cy="40" r="12" fill="var(--accent)"/>
<rect x="20" y="68" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="82" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="100" width="60" height="11" rx="5" fill="var(--accent)"/>
<line x1="22" y1="125" x2="42" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<line x1="58" y1="125" x2="78" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<text x="50" y="128" textAnchor="middle" fontSize="6" fontFamily="JetBrains Mono" fill="var(--ink-4)">OU</text>
<circle cx="42" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
<circle cx="58" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
</g>
);
if (type === 'profile') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Profil</text>
<rect x="80" y="17" width="9" height="9" rx="2" fill="var(--accent-tint)" stroke="var(--accent)" strokeWidth="0.5"/>
<circle cx="50" cy="48" r="12" fill="var(--accent)"/>
<rect x="30" y="65" width="40" height="5" rx="2" fill="var(--ink-2)"/>
<rect x="36" y="74" width="28" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="10" y="92" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="110" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="128" width="80" height="14" rx="4" fill="var(--bg-2)"/>
</g>
);
if (type === 'form') return phone(
<g>
<rect x="10" y="22" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="52" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="50" width="80" height="22" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="78" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<circle cx="16" cy="98" r="2.5" fill="none" stroke="var(--accent)"/>
<rect x="22" y="96" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<circle cx="16" cy="106" r="2.5" fill="var(--accent)"/>
<rect x="22" y="104" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<rect x="10" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--accent)"/>
<rect x="38" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="66" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="158" width="80" height="12" rx="5" fill="var(--accent)"/>
</g>
);
if (type === 'swipe') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Boîte</text>
<rect x="3" y="32" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="52" x2="97" y2="52" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="52" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="72" x2="97" y2="72" stroke="var(--border-1)" strokeWidth="0.4"/>
<g transform="translate(-26, 0)">
<rect x="3" y="72" width="94" height="20" fill="var(--bg-3)"/>
</g>
<rect x="71" y="72" width="13" height="20" fill="var(--info)"/>
<rect x="84" y="72" width="13" height="20" fill="var(--accent)"/>
<text x="77.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Lu</text>
<text x="90.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Pin</text>
<rect x="3" y="92" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="112" x2="97" y2="112" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="112" width="94" height="20" fill="var(--bg-3)"/>
<path d="M 80 102 l -6 0 M 80 102 l 4 -3 M 80 102 l 4 3" stroke="var(--accent)" strokeWidth="1" fill="none"/>
</g>
);
return phone(null);
}
/* ============================================================
DOC PANEL
============================================================ */
function NamedItem({ name, desc, location, preview }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
{preview && (
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
}}>{preview}</div>
)}
</div>
);
}
function ScreenCard({ type, name, when, why, gestures, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<ScreenVisual type={type} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>Écran {name}</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
{gestures && <div className="row-use"><span className="k">Gestes</span><span className="v">{gestures}</span></div>}
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
function Doc() {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Saisie & formulaires mobile</h2>
<p className="desc">
Suite logique de la variante mobile : <strong>écrans de connexion, profil, formulaire complet,
liste swipeable</strong>. Tous les composants sont nommés et le clavier virtuel se configure
précisément (8 types, autocomplete système, touche Entrée personnalisable).
</p>
</section>
{/* ÉCRANS */}
<section id="screens">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> 4 écrans modèles</h2>
<p className="desc">Chaque écran combine plusieurs composants. Bascule entre eux via les onglets en bas du smartphone.</p>
<ScreenCard
type="login"
name="Connexion"
when="Avant tout accès à l'app, ou pour se reconnecter."
why="Format unifié : logo + email + mot de passe + biométrie + lien créa de compte."
gestures="Tap sur champs · Tap sur Face ID / Touch ID · enterKeyHint='go' soumet le formulaire"
example="Onglet ☐ Login du smartphone à gauche" />
<ScreenCard
type="profile"
name="Profil utilisateur"
when="L'utilisateur veut voir/modifier ses infos."
why="Tête de page avec avatar + actions de compte + bouton ⚙ paramètres en haut à droite."
gestures="Tap sur ⚙ ouvre une BottomSheet de paramètres"
example="Onglet ☐ Profil du smartphone" />
<ScreenCard
type="form"
name="Formulaire de saisie"
when="Création/édition d'un objet (note, tâche, contact…)."
why="Tous les types d'inputs en une seule page : titre, dates, textarea, dropdown, radio, checkboxes, médias."
gestures="Tap sur OK valide · onBack remonte d'un cran"
example="Onglet ☐ Formulaire du smartphone" />
<ScreenCard
type="swipe"
name="Liste swipeable"
when="Liste d'éléments avec actions cachées (mails, notifs, tâches)."
why="Économise l'espace : actions hors-écran révélées au geste."
gestures="SwipeLeft → archive/supprime · SwipeRight → marquer lu/épingler · Tap → ouvrir"
example="Onglet ☐ Notifications du smartphone" />
</section>
{/* COMPOSANTS */}
<section id="components">
<h2><Icon name="cog" size={22} style={{ color: 'var(--accent)' }} /> Composants de saisie</h2>
<p className="desc">Tous ont une API homogène : <code className="mono" style={{color:'var(--accent)'}}>value / onChange / label / hint / error</code>. Les inputs supportent en plus le contrôle clavier virtuel.</p>
<NamedItem name="FormField" location="Wrapper de tout champ"
desc="Cadre standard : label en haut, champ au milieu, hint/erreur en bas. À utiliser autour de chaque champ pour homogénéiser." />
<NamedItem name="TextInput" location="Formulaire, Login"
desc="Champ texte unifié avec contrôle complet du clavier virtuel : type d'entrée (text/email/numeric/tel…), auto-complétion système (email, mot de passe, code OTP), texte de la touche Entrée (next, send, search…), majuscules auto, correction orthographique. Mode multiline pour textarea."
preview={<TextInput value="exemple@..." onChange={() => {}} keyboard="email" icon="bell" />} />
<NamedItem name="DateInput" location="Formulaire"
desc="Date/heure picker natif du téléphone. Modes : date, time, datetime-local, month, week. Affiche le picker iOS/Android natif au focus."
preview={<DateInput value="2026-05-21" onChange={() => {}} mode="date" />} />
<NamedItem name="Dropdown" location="Formulaire"
desc="Select natif avec habillage Gruvbox. Sur mobile, ouvre le sélecteur roulette iOS ou le menu déroulant Android. À utiliser dès 4+ options."
preview={<Dropdown value="" onChange={() => {}} placeholder="Choisir…" options={['Option A', 'Option B', 'Option C']} />} />
<NamedItem name="CheckboxItem" location="Formulaire"
desc="Case à cocher avec label + description optionnelle. Pour des options indépendantes (multi-sélection)."
preview={<CheckboxItem checked={true} onChange={() => {}} label="J'accepte les conditions" description="En cochant tu acceptes notre politique." />} />
<NamedItem name="RadioGroup" location="Formulaire"
desc="Liste d'options exclusives empilées verticalement avec puce circulaire. Pour 2-6 options. Au-delà, utilise un Dropdown."
preview={<RadioGroup value="b" onChange={() => {}} options={[
{ value: 'a', label: 'Option A', description: 'Première option' },
{ value: 'b', label: 'Option B', description: 'Deuxième option' },
]} />} />
<NamedItem name="MediaInsert" location="Formulaire"
desc="Grille 3 colonnes de boutons pour ajouter une pièce jointe : Photo (caméra arrière), Image (galerie), Vidéo, Audio (micro), Fichier (doc), Position (GPS via navigator.geolocation). Chaque type définit l'attribut HTML accept et capture."
preview={<MediaInsert onPick={() => {}} />} />
<NamedItem name="AvatarLogo" location="Login, Profil"
desc="Gros logo carré arrondi avec icône et glow accent. Pour l'identité visuelle d'un écran (login, profil, vide d'état)."
preview={<AvatarLogo icon="server" size={48} />} />
<NamedItem name="BiometricButton" location="Login"
desc="Bouton biométrique (Face ID / Touch ID). Style natif iOS — icône large + label. À placer sous le bouton principal de login."
preview={<div style={{display:'flex', gap: 16, justifyContent:'center'}}><BiometricButton kind="face" /><BiometricButton kind="touch" /></div>} />
<NamedItem name="SwipeableRow" location="Liste swipeable"
desc="Ligne d'une liste qui révèle des actions au swipe. leftActions = actions à droite (révélées en swipant vers la gauche), rightActions = actions à gauche (révélées en swipant vers la droite). Chaque action a icon, label, color, onClick. Tap sur la ligne = onTap principal."
preview={
<SwipeableRow
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)' }]}
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)' }]}>
<div style={{ padding: 12, background: 'var(--bg-3)', fontSize: 13 }}>
swipe-moi dans un sens ou l'autre →
</div>
</SwipeableRow>
} />
</section>
{/* CLAVIER VIRTUEL */}
<section id="keyboard">
<h2><Icon name="terminal" size={22} style={{ color: 'var(--accent)' }} /> Clavier virtuel</h2>
<p className="desc">
Sur mobile, le clavier qui s'affiche dépend de la prop <code className="mono" style={{color:'var(--accent)'}}>keyboard</code> (attribut HTML <code className="mono">inputmode</code>).
Choisis le BON type pour faire gagner du temps à l'utilisateur — exemple : <code className="mono">keyboard="numeric"</code> pour un code OTP fait apparaître directement le pavé numérique au lieu du clavier complet.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{KEYBOARD_CATALOG.map((k) => (
<div key={k.name} className="card" style={{ margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="pill-name">{k.name}</span>
</div>
<KeyboardVisual kind={k.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 10, lineHeight: 1.4 }}>{k.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 4 }}>
Usage : {k.usage}
</div>
</div>
))}
</div>
</section>
{/* AUTOCOMPLETE */}
<section id="autocomplete">
<h2><Icon name="refresh" size={22} style={{ color: 'var(--accent)' }} /> Aide à la saisie (autocomplete)</h2>
<p className="desc">
L'attribut <code className="mono" style={{color:'var(--accent)'}}>autocomplete</code> dit au système ce que représente le champ.
Sur iOS/Android, ça déclenche : remplissage automatique (nom, email, adresse), proposition du mot de passe enregistré, génération d'un nouveau mot de passe, lecture auto du code SMS reçu.
</p>
<div className="card">
{AUTOCOMPLETE_CATALOG.map((a) => (
<div key={a.name} className="row-use">
<span className="k">{a.name}</span>
<span className="v">{a.usage}</span>
</div>
))}
</div>
</section>
{/* ENTER KEY HINT */}
<section id="enter-hint">
<h2><Icon name="chevR" size={22} style={{ color: 'var(--accent)' }} /> Touche Entrée — enterKeyHint</h2>
<p className="desc">
La touche en bas à droite du clavier peut afficher un mot différent selon le contexte (au lieu du standard "Entrée").
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{ENTER_HINT_CATALOG.map((e) => (
<div key={e.name} className="card" style={{ margin: 0, padding: 14 }}>
<div style={{
display: 'inline-block',
padding: '4px 12px', borderRadius: 6,
background: 'var(--accent)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
marginBottom: 8,
}}>{e.name}</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{e.usage}</div>
</div>
))}
</div>
</section>
{/* CHEAT SHEET */}
<section id="cheatsheet">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Antisèche · combinaisons utiles</h2>
<div className="card">
<div className="row-use">
<span className="k">Email</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="email"</code> + <code className="mono">autocomplete="email"</code> + <code className="mono">autocapitalize="off"</code></span>
</div>
<div className="row-use">
<span className="k">Mot de passe</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>type="password"</code> + <code className="mono">autocomplete="current-password"</code> (ou <code className="mono">"new-password"</code> en inscription)</span>
</div>
<div className="row-use">
<span className="k">Code OTP SMS</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="numeric"</code> + <code className="mono">autocomplete="one-time-code"</code> + <code className="mono">maxLength=6</code></span>
</div>
<div className="row-use">
<span className="k">Téléphone</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="tel"</code> + <code className="mono">autocomplete="tel"</code></span>
</div>
<div className="row-use">
<span className="k">Recherche</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="search"</code> + <code className="mono">enterHint="search"</code></span>
</div>
<div className="row-use">
<span className="k">Prix / mesure</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="decimal"</code></span>
</div>
<div className="row-use">
<span className="k">Adresse</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocomplete="address-line1"</code>, puis <code className="mono">postal-code</code>, <code className="mono">country</code></span>
</div>
<div className="row-use">
<span className="k">Texte libre</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocapitalize="sentences"</code> + <code className="mono">spellCheck=true</code></span>
</div>
</div>
</section>
</div>
);
}
/* ============================================================
APP ROOT
============================================================ */
function PhoneAppSaisie({ theme }) {
const [tab, setTab] = uDS('login');
const [toast, setToast] = uDS(null);
const [sheet, setSheet] = uDS(false);
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)', color: 'var(--ink-1)',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'login' && <ScreenLogin onLogin={() => setTab('profile')} showToast={showToast} />}
{tab === 'profile' && <ScreenProfile openSettings={() => setSheet(true)} />}
{tab === 'form' && <ScreenForm showToast={showToast} />}
{tab === 'swipe' && <ScreenSwipe showToast={showToast} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'login', icon: 'user', label: 'login' },
{ id: 'profile', icon: 'cog', label: 'profil' },
{ id: 'form', icon: 'list', label: 'form' },
{ id: 'swipe', icon: 'chevR', label: 'notifs' },
]}
/>
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Paramètres rapides">
<ListSection>
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="refresh" iconColor="var(--ok)" label="Sync auto" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
<ListSection>
<ListRow icon="power" iconColor="var(--err)" label="Se déconnecter" danger onClick={() => { setSheet(false); setTab('login'); }} />
</ListSection>
</BottomSheet>
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
function App() {
const [theme, setTheme] = uDS('dark');
const [device, setDevice] = uDS('ios');
eDS(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<React.Fragment>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="list" size={16} />
</div>
<h1>Exemple mobile · saisie <small>login · profil · form · swipe · clavier virtuel</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-mobile.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}><Icon name="chevL" size={12} /> exemple mobile</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneAppSaisie theme={theme} />
</div>
</div>
<div className="legend">↑ teste les écrans, swipe les lignes, joue avec les formulaires</div>
</div>
<Doc />
</div>
</React.Fragment>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — saisie & formulaires</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 140px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-app.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-doc.jsx"></script>
</body>
</html>
@@ -0,0 +1,952 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — mon design system</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background:
radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
/* Topbar de la page */
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
/* Layout : 2 colonnes — phone à gauche, doc à droite */
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
/* Sticky phone */
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
/* Mockup smartphone */
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow:
0 0 0 2px #2a2520,
0 0 0 8px #1a1612,
0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907;
border-radius: 18px;
z-index: 100;
pointer-events: none;
}
/* Phone controls */
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px;
padding: 3px;
gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px;
background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3);
cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent);
color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
/* Side doc */
.doc {
min-width: 0;
}
.doc section {
margin-bottom: 36px;
scroll-margin-top: 80px;
}
.doc h2 {
font-size: 22px;
margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc h2 .name {
font-family: var(--font-mono);
color: var(--accent);
font-size: 18px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px;
background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px;
box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
font-weight: 600;
}
.doc .row-use {
display: grid;
grid-template-columns: 130px 1fr;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.nav-jump {
position: sticky; top: 80px;
padding: 14px 0;
display: flex; flex-direction: column; gap: 4px;
font-family: var(--font-mono); font-size: 12px;
}
.nav-jump a {
padding: 6px 12px;
color: var(--ink-3);
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
}
.nav-jump a:hover {
background: var(--bg-3); color: var(--ink-1);
border-left-color: var(--accent);
}
/* Légende — utilisé un peu partout */
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
/* ============================================================
ECRANS DU SMARTPHONE — chacun illustre un cas d'usage
============================================================ */
/* Écran ACCUEIL : ActionCards en grille + FAB */
function PhoneHome({ goto, showToast }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<StatusBar />
<NavBar large title="Accueil" subtitle="jeudi 21 mai · tout est OK" right={
<IconButton icon="bell" label="Notifications" size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: 16, paddingBottom: 80 }}>
<SearchBar value="" onChange={() => {}} />
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginTop: 14,
}}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="network" iconColor="var(--blue)" title="Réseau" subtitle="8.4 Mb/s" value="OK" />
<ActionCard icon="disk" iconColor="var(--ok)" title="Stockage" subtitle="2 disques" value="28%" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
<div style={{ marginTop: 18 }}>
<div className="label" style={{ marginBottom: 8 }}>Services</div>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
}}>
{[
{ name: 'nginx', status: 'ok', meta: 'actif' },
{ name: 'postgres', status: 'ok', meta: 'actif' },
{ name: 'redis', status: 'warn', meta: 'latent' },
{ name: 'worker_01', status: 'err', meta: 'arrêté' },
].map((s, i, a) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
borderBottom: i < a.length - 1 ? '1px solid var(--border-1)' : 'none',
}}>
<StatusLed status={s.status} pulse={s.status !== 'ok'} />
<span className="mono" style={{ flex: 1, fontSize: 14, color: 'var(--ink-1)' }}>{s.name}</span>
<span className="mono" style={{ fontSize: 11, color: s.status === 'err' ? 'var(--err)' : s.status === 'warn' ? 'var(--warn)' : 'var(--ok)' }}>{s.meta}</span>
</div>
))}
</div>
</div>
</div>
<FAB icon="plus" label="Ajouter" onClick={() => showToast('Action FAB')} />
</div>
);
}
/* Écran DASHBOARD : KPIs + jauges */
function PhoneDashboard() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Dashboard" />
<div style={{ flex: 1, overflowY: 'auto', padding: 14, paddingBottom: 80 }}>
{/* KPIs compacts */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
<BatteryGauge compact value={64} label="CPU" icon="cpu" warnAt={70} errAt={85} />
<BatteryGauge compact value={42} label="Mémoire" icon="memory" />
<BatteryGauge compact value={28} label="Disque" icon="disk" />
<BatteryGauge compact value={92} label="Réseau" icon="network" warnAt={70} errAt={85} />
</div>
{/* Grande jauge */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
display: 'flex', flexDirection: 'column', alignItems: 'center',
marginBottom: 14,
}}>
<div className="label" style={{ alignSelf: 'flex-start', marginBottom: 8 }}>Score santé</div>
<BigRadialGauge value={87} label="stable" />
</div>
{/* Graphique */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
}}>
<div className="label" style={{ marginBottom: 8 }}>Trafic · 24h</div>
<LineChart h={140} labels={[]} series={[
{ color: 'var(--accent)', points: [12,18,14,22,28,35,30,42,38,45,52,48,55,60,52,58,45,50,38,44,36,40,32,38] },
]} />
</div>
</div>
</div>
);
}
/* Écran RÉGLAGES : ListRow style iOS */
function PhoneSettings({ openSheet, openAlert }) {
const [auto, setAuto] = useState(true);
const [notif, setNotif] = useState(false);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Réglages" />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<ListSection title="Compte">
<ListRow icon="user" iconColor="var(--blue)" label="Marc" value="admin" onClick={() => {}} />
<ListRow icon="server" iconColor="var(--accent)" label="Instance" value="prod" onClick={() => {}} />
</ListSection>
<ListSection title="Notifications" hint="Choisis quand l'app doit te déranger.">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh"
right={<Toggle on={auto} onChange={setAuto} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Notifications push"
right={<Toggle on={notif} onChange={setNotif} />} />
</ListSection>
<ListSection title="Apparence">
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => openSheet()} />
<ListRow icon="cog" iconColor="var(--ink-3)" label="Densité" value="Confort" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="download" iconColor="var(--ok)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--err)" label="Supprimer mon compte" danger onClick={openAlert} />
</ListSection>
</div>
</div>
);
}
/* Écran GESTES : terrain de test pour chaque geste */
function PhoneGestures({ activeGesture, setActiveGesture }) {
const filter = activeGesture === 'all' ? [] : [activeGesture];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Gestes" subtitle="teste chaque interaction tactile" />
<div style={{ padding: '0 14px 12px' }}>
<SegmentedControl
value={activeGesture}
onChange={setActiveGesture}
options={[
{ value: 'all', label: 'tous' },
{ value: 'tap', label: 'tap', icon: 'play' },
{ value: 'swipe', label: 'swipe', icon: 'chevR' },
{ value: 'pan', label: 'drag', icon: 'grid' },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px 80px' }}>
<GestureZone label="zone tactile · essaie ici" accept={filter} />
<div className="legend" style={{ marginTop: 8, marginBottom: 6 }}> tap · double-tap · long-press · swipe · pan · pinch</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} style={{
padding: 10,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<Icon name={g.icon} size={12} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-1)' }}>{g.name}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.3 }}>{g.desc}</div>
</div>
))}
</div>
</div>
</div>
);
}
/* ============================================================
APP COMPLÈTE DU PHONE — navigation par TabBar
============================================================ */
function PhoneApp({ theme }) {
const [tab, setTab] = useState('home');
const [sheet, setSheet] = useState(false);
const [alert, setAlert] = useState(false);
const [action, setAction] = useState(false);
const [toast, setToast] = useState(null);
const [activeGesture, setActiveGesture] = useState('all');
const [themeChoice, setThemeChoice] = useState('dark');
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)',
color: 'var(--ink-1)',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'home' && <PhoneHome showToast={showToast} />}
{tab === 'dash' && <PhoneDashboard />}
{tab === 'gestures' && <PhoneGestures activeGesture={activeGesture} setActiveGesture={setActiveGesture} />}
{tab === 'settings' && <PhoneSettings openSheet={() => setSheet(true)} openAlert={() => setAlert(true)} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'home', icon: 'grid', label: 'accueil' },
{ id: 'dash', icon: 'chart', label: 'dashboard' },
{ id: 'gestures', icon: 'play', label: 'gestes' },
{ id: 'settings', icon: 'cog', label: 'réglages' },
]}
/>
{/* BottomSheet : choix du thème (depuis Réglages > Thème) */}
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Choisir le thème">
<SegmentedControl
value={themeChoice}
onChange={setThemeChoice}
options={[
{ value: 'dark', label: 'Sombre', icon: 'moon' },
{ value: 'light', label: 'Clair', icon: 'sun' },
{ value: 'auto', label: 'Auto', icon: 'clock' },
]} />
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 14, lineHeight: 1.5 }}>
Le thème "Auto" suit automatiquement les réglages de ton téléphone (jour/nuit).
</div>
</BottomSheet>
{/* AlertDialog : confirmation destructive */}
<AlertDialog
open={alert} onClose={() => setAlert(false)}
icon="alert" iconColor="var(--err)"
title="Supprimer le compte ?"
message="Cette action est irréversible. Toutes tes données seront perdues."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: () => showToast('Compte supprimé') },
]} />
{/* ActionSheet : ouverte depuis le FAB de l'accueil */}
<ActionSheet
open={action} onClose={() => setAction(false)}
title="Que veux-tu faire ?"
actions={[
{ label: 'Lancer un scan', icon: 'refresh', onClick: () => showToast('Scan lancé') },
{ label: 'Nouveau dashboard', icon: 'plus', onClick: () => showToast('Dashboard créé') },
{ label: 'Importer données', icon: 'download' },
{ label: 'Supprimer tout', icon: 'power', danger: true },
]} />
{/* Toast */}
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
/* ============================================================
PAGE DOC à droite — catalogue avec noms en clair
============================================================ */
function Doc({ currentScreen }) {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="memory" size={22} style={{ color: 'var(--accent)' }} /> Variante mobile</h2>
<p className="desc">
Adaptation smartphone de mon design system (Gruvbox seventies).
<strong> Chaque composant a un nom explicite</strong> que tu peux utiliser pour
le demander à ton agent IA ou à un développeur. Hit targets 44px,
animations fluides, dark + light, optimisé iOS / Android.
</p>
<div className="card">
<div className="row-use"><span className="k">Largeur réf.</span><span className="v">390 px (iPhone 14, Galaxy S22)</span></div>
<div className="row-use"><span className="k">Hit target min.</span><span className="v">44 × 44 px (recommandation Apple/Google)</span></div>
<div className="row-use"><span className="k">Navigation</span><span className="v">TabBar en bas (3-5 sections)</span></div>
<div className="row-use"><span className="k">Action principale</span><span className="v">FAB bottom-right (Material) ou bouton plein largeur (iOS)</span></div>
<div className="row-use"><span className="k">Modales</span><span className="v">BottomSheet (priorité) · ActionSheet · AlertDialog</span></div>
</div>
</section>
{/* COMPOSANTS PHARES */}
<section id="components">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> Composants nommés</h2>
<p className="desc">Vois-les en vrai dans le téléphone à gauche. Le nom est ce que tu emploies dans le code.</p>
<NamedComp name="StatusBar" desc="Barre de statut iOS-like en haut de l'écran (heure, signal, batterie). Purement décorative." location="Tous les écrans" />
<NamedComp name="NavBar" desc="Barre de titre. Variante large pour écran d'accueil, ou compacte avec bouton retour pour écran enfant." location="Tous les écrans" />
<NamedComp name="TabBar" desc="Barre d'onglets en bas, 3-5 sections principales de l'app. C'est ta navigation primaire." location="Toujours visible" />
<NamedComp name="ActionCard" desc="Grande tuile tactile avec icône colorée + titre + valeur. Idéale en grille 2 colonnes pour un dashboard d'accueil." location="Accueil" />
<NamedComp name="ListSection / ListRow" desc="Liste de réglages style iOS. ListRow = une ligne (icône + label + valeur + chevron). Toute ligne fait ≥ 52px." location="Réglages" />
<NamedComp name="PrimaryButton" desc="Gros bouton 52px plein largeur. Variante primary, ghost, danger. Pour l'action principale d'un écran." location="Réglages > formulaires" />
<NamedComp name="SegmentedControl" desc="Sélecteur segmenté pour 2-4 options exclusives (jamais plus, sinon utilise un Select)." location="Gestes (filtre) · BottomSheet (choix thème)" />
<NamedComp name="SearchBar" desc="Champ de recherche avec icône loupe et bouton effacer. Padding tactile généreux." location="Accueil" />
<NamedComp name="FAB" desc="Floating Action Button. Toujours en bas à droite. Une seule action principale par écran. Style Android Material." location="Accueil" />
</section>
{/* FENÊTRES / DIALOGUES */}
<section id="windows">
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Types de fenêtres</h2>
<p className="desc">Sur mobile, on évite les modales centrées. Voici les 4 types à utiliser à la place, chacun avec son cas.</p>
<WindowType
name="BottomSheet"
when="Action contextuelle, formulaire court, choix dans une liste."
why="Accessible au pouce, geste swipe down pour fermer, sensation native."
gesture="SwipeDown ↓ pour fermer · drag du handle en haut"
example="Sur ce smartphone : Réglages > Thème → ouvre une BottomSheet"
/>
<WindowType
name="ActionSheet"
when="Choix parmi 2-6 actions sur un élément (équiv. menu contextuel desktop)."
why="Style iOS natif, l'utilisateur sait que c'est une liste d'options."
gesture="Tap sur une option · Tap hors zone ou bouton Annuler pour fermer"
example="Tape le FAB orange sur l'accueil"
/>
<WindowType
name="AlertDialog"
when="Message critique, demande de confirmation ferme (suppression, déconnexion)."
why="Centré, bloque l'attention. À utiliser avec parcimonie."
gesture="Tap sur Annuler / Confirmer (pas de swipe pour fermer — c'est volontairement bloquant)"
example="Réglages > Supprimer mon compte"
/>
<WindowType
name="Toast"
when="Feedback éphémère après une action (succès, erreur)."
why="Non bloquant, disparaît seul après 2.5s."
gesture="Aucun — disparaît automatiquement"
example="Toute action ci-dessus déclenche un Toast en haut"
/>
</section>
{/* GESTES */}
<section id="gestures">
<h2><Icon name="play" size={22} style={{ color: 'var(--accent)' }} /> Gestes tactiles</h2>
<p className="desc">
Onglet <strong>Gestes</strong> en bas du smartphone zone interactive pour tester
chaque geste. Le nom du geste s'affiche en temps réel.
</p>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginBottom: 16,
}}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} className="card" style={{ padding: 14, margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Icon name={g.icon} size={14} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>{g.name}</span>
</div>
<GestureAnim name={g.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 8 }}>{g.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 2 }}>
Usage : {g.usage}
</div>
</div>
))}
</div>
<div className="card">
<h3 style={{
margin: '0 0 8px', fontFamily: 'var(--font-mono)',
fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>Utilitaire</h3>
<div className="row-use">
<span className="k">useGesture()</span>
<span className="v">Hook React qui transforme un élément en zone tactile. Pose les handlers <code className="mono" style={{ color:'var(--accent)' }}>onTap / onSwipeLeft / onLongPress / onPinch</code> etc.</span>
</div>
<div className="row-use">
<span className="k">GestureZone</span>
<span className="v">Composant prêt-à-l'emploi qui affiche le geste détecté + un journal des 5 derniers. Utilisé dans l'onglet Gestes.</span>
</div>
</div>
</section>
{/* INSTALLATION */}
<section id="install">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Comment utiliser</h2>
<p className="desc">
Ajoute ces lignes en plus de <code className="mono" style={{ color:'var(--accent)' }}>ui-kit.jsx</code> :
</p>
<div className="card" style={{ background:'#15110c', padding: 16 }}>
<pre className="mono" style={{
margin: 0, fontSize: 12, lineHeight: 1.6, color: 'var(--ink-2)',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{`<scr` + `ipt type="text/babel" src="components/ui-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-sheets.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-gestures.jsx"></scr` + `ipt>`}
</pre>
</div>
<p className="desc" style={{ marginTop: 16 }}>
Tu retrouves ensuite dans <code className="mono" style={{ color:'var(--accent)' }}>window</code> tous les composants exposés :
<strong> StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar,
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh, useGesture, GestureZone</strong>.
</p>
</section>
</div>
);
}
function NamedComp({ name, desc, location }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: 72,
}}>
<ComponentPreview name={name} />
</div>
</div>
);
}
/* ============================================================
ComponentPreview — mini-rendu live de chaque composant nommé
============================================================ */
function ComponentPreview({ name }) {
// Réduit la taille via un wrapper compact
const wrap = (children, w = '100%') => (
<div style={{ width: w, maxWidth: 320 }}>{children}</div>
);
if (name === 'StatusBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><StatusBar /></div>);
if (name === 'NavBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><NavBar title="Mon écran" /></div>);
if (name === 'TabBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><TabBar active="a" onSelect={() => {}} items={[
{ id: 'a', icon: 'grid', label: 'accueil' },
{ id: 'b', icon: 'chart', label: 'stats' },
{ id: 'c', icon: 'cog', label: 'réglages' },
]} /></div>);
if (name === 'ActionCard') return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, width: '100%', maxWidth: 280 }}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
);
if (name === 'ListSection / ListRow') return wrap(
<ListSection title="Notifications">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Push" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
);
if (name === 'PrimaryButton') return wrap(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<PrimaryButton icon="download">Enregistrer</PrimaryButton>
<PrimaryButton variant="ghost">Annuler</PrimaryButton>
</div>
);
if (name === 'SegmentedControl') return wrap(
<SegmentedControl value="a" onChange={() => {}} options={[
{ value: 'a', label: 'Sombre', icon: 'moon' },
{ value: 'b', label: 'Clair', icon: 'sun' },
{ value: 'c', label: 'Auto', icon: 'clock' },
]} />
);
if (name === 'SearchBar') return wrap(<SearchBar value="" onChange={() => {}} placeholder="rechercher…" />);
if (name === 'FAB') return (
<div style={{ position: 'relative', width: 220, height: 90, background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, padding: 10, color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>écran…</div>
<div style={{ position: 'absolute', bottom: 10, right: 10 }}>
<button className="touch-press" style={{
width: 48, height: 48, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 14px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25)',
}}><Icon name="plus" size={20} /></button>
</div>
</div>
);
return null;
}
function WindowType({ name, when, why, gesture, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<WindowVisual type={name} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>&lt;{name}/&gt;</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
<div className="row-use"><span className="k">Gestes</span><span className="v">{gesture}</span></div>
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
/* ============================================================
WindowVisual — mini SVG phone + zone modale colorée
============================================================ */
function WindowVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block', margin: '0 auto' }}>
{/* Cadre téléphone */}
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{/* indication contenu */}
<rect x="10" y="18" width="50" height="3" rx="1.5" fill="var(--ink-4)" opacity="0.5"/>
<rect x="10" y="26" width="60" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
<rect x="10" y="32" width="40" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
{inner}
</svg>
);
if (type === 'BottomSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="68" rx="8" fill="var(--accent)" opacity="0.92"/>
<rect x="44" y="114" width="12" height="2.5" rx="1.25" fill="var(--bg-1)"/>
<path d="M 50 145 v 14 M 46 155 l 4 5 l 4 -5" stroke="var(--bg-1)" strokeWidth="1.5" fill="none" opacity="0.7"/>
</g>
);
if (type === 'ActionSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="50" rx="6" fill="var(--accent)" opacity="0.85"/>
<line x1="10" y1="122" x2="90" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="135" x2="90" y2="135" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="148" x2="90" y2="148" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<rect x="6" y="162" width="88" height="14" rx="6" fill="var(--bg-1)" stroke="var(--accent)" strokeWidth="1"/>
<text x="50" y="172" textAnchor="middle" fill="var(--accent)" fontSize="7" fontFamily="Inter" fontWeight="700">Annuler</text>
</g>
);
if (type === 'AlertDialog') return phone(
<g>
<rect x="0" y="0" width="100" height="180" fill="#000" opacity="0.45"/>
<rect x="3" y="2" width="94" height="176" rx="14" fill="none" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="16" y="66" width="68" height="56" rx="8" fill="var(--err)" opacity="0.92"/>
<circle cx="50" cy="82" r="6" fill="var(--bg-1)" opacity="0.95"/>
<line x1="30" y1="96" x2="70" y2="96" stroke="var(--bg-1)" strokeWidth="1.4" opacity="0.85"/>
<line x1="36" y1="102" x2="64" y2="102" stroke="var(--bg-1)" strokeWidth="1" opacity="0.6"/>
<line x1="16" y1="112" x2="84" y2="112" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="50" y1="112" x2="50" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
</g>
);
if (type === 'Toast') return phone(
<g>
<rect x="8" y="18" width="84" height="14" rx="7" fill="var(--ok)" opacity="0.95"/>
<circle cx="16" cy="25" r="2.5" fill="var(--bg-1)"/>
<line x1="22" y1="25" x2="80" y2="25" stroke="var(--bg-1)" strokeWidth="1.5" opacity="0.7"/>
</g>
);
return phone(null);
}
/* ============================================================
GestureAnim — animation SVG par geste
============================================================ */
function GestureAnim({ name }) {
const sty = {
width: '100%', height: 80,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
};
const dot = (cx, cy, r = 6) => <circle cx={cx} cy={cy} r={r} fill="var(--accent)" />;
const trail = (path) => (
<path d={path} stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none" />
);
const arrow = (x, y, dir) => {
const v = { l: 'l 5 -4 m -5 4 l 5 4', r: 'l -5 -4 m 5 4 l -5 4', u: 'l -4 5 m 4 -5 l 4 5', d: 'l -4 -5 m 4 5 l 4 -5' }[dir];
return <path d={`M ${x} ${y} ${v}`} stroke="var(--ink-3)" strokeWidth="1.2" fill="none" strokeLinecap="round"/>;
};
if (name === 'Tap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;22;6" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9" dur="1.6s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'DoubleTap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;14;6;14;6" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9;0;0.9" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'LongPress') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;24" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0" dur="2s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
<text x="50" y="54" textAnchor="middle" fontSize="7" fontFamily="JetBrains Mono" fill="var(--ink-3)">500ms</text>
</svg>
);
if (name === 'SwipeLeft') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 78 30 L 22 30')}
{arrow(22, 30, 'l')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="78;22" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="cy" values="30;30" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeRight') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 22 30 L 78 30')}
{arrow(78, 30, 'r')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="22;78" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeUp') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 52 L 50 10')}
{arrow(50, 10, 'u')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="52;10" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeDown') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 10 L 50 52')}
{arrow(50, 52, 'd')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="10;52" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'Pan') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<path d="M 20 45 Q 35 8 50 30 T 80 18" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none"/>
<circle r="6" fill="var(--accent)">
<animateMotion dur="2s" repeatCount="indefinite" path="M 20 45 Q 35 8 50 30 T 80 18"/>
</circle>
</svg>
);
if (name === 'Pinch') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
</circle>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</circle>
<line y1="30" y2="30" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3">
<animate attributeName="x1" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="x2" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</line>
</svg>
);
return null;
}
/* ============================================================
ROOT
============================================================ */
function App() {
const [theme, setTheme] = useState('dark');
const [device, setDevice] = useState('ios');
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="memory" size={16} />
</div>
<h1>Exemple mobile <small>composants nommés · gestes testables · v1.0</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-tout.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<Icon name="chevL" size={12} /> exemple desktop
</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneApp theme={theme} />
</div>
</div>
<div className="legend">↑ utilise le smartphone comme un vrai téléphone</div>
</div>
<Doc />
</div>
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
@@ -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);
})();
@@ -0,0 +1,204 @@
/* ============================================================
ui-tokens.css
Design tokens Gruvbox Seventies — dark (par défaut) + light.
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
============================================================ */
:root,
[data-theme="dark"] {
/* Couches de fond — sombre délavé, brun-gris chaud */
--bg-0: #221c17; /* niveau le plus profond (rare) */
--bg-1: #2a231d; /* fond app */
--bg-2: #322a23; /* panneaux */
--bg-3: #3c332a; /* cartes */
--bg-4: #4a4035; /* hover */
--bg-5: #5a4f43; /* press / actif */
/* Surfaces translucides */
--surf-glass: rgba(50, 42, 35, 0.72);
--surf-glass-strong: rgba(50, 42, 35, 0.92);
--surf-glass-soft: rgba(50, 42, 35, 0.42);
/* Bordures */
--border-1: rgba(168, 153, 132, 0.18);
--border-2: rgba(168, 153, 132, 0.32);
--border-3: rgba(168, 153, 132, 0.55);
/* Texte */
--ink-1: #f2e5c7; /* cream principal */
--ink-2: #d5c4a1; /* secondaire */
--ink-3: #a89984; /* labels / hints */
--ink-4: #7c6f64; /* désactivé */
/* Accent orange seventies */
--accent: #fe8019;
--accent-soft: #d65d0e;
--accent-glow: rgba(254, 128, 25, 0.35);
--accent-tint: rgba(254, 128, 25, 0.12);
/* Statuts */
--ok: #4dbb26;
--ok-glow: rgba(77, 187, 38, 0.45);
--warn: #fabd2f;
--warn-glow: rgba(250, 189, 47, 0.45);
--err: #fb4934;
--err-glow: rgba(251, 73, 52, 0.4);
--info: #83a598;
--info-glow: rgba(131, 165, 152, 0.4);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #3db0d1;
--blue-glow: rgba(61, 176, 209, 0.45);
--purple: #c882c8;
--purple-glow: rgba(200, 130, 200, 0.45);
/* Ombres */
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
--tile-3d:
inset 0 1px 0 rgba(255, 230, 180, 0.12),
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
0 1px 0 rgba(0, 0, 0, 0.35),
0 2px 4px rgba(0, 0, 0, 0.4),
0 8px 18px rgba(0, 0, 0, 0.5);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 230, 180, 0.18),
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
0 1px 0 rgba(0, 0, 0, 0.4),
0 4px 8px rgba(0, 0, 0, 0.5),
0 14px 28px rgba(0, 0, 0, 0.55);
/* Polices */
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
}
[data-theme="light"] {
/* Gris clair usé, légèrement chaud (pas blanc pur) */
--bg-0: #b8b2a3;
--bg-1: #d5d0c5;
--bg-2: #dcd7cc;
--bg-3: #e3ded3;
--bg-4: #ccc6b8;
--bg-5: #bdb6a7;
--surf-glass: rgba(220, 215, 204, 0.72);
--surf-glass-strong: rgba(220, 215, 204, 0.94);
--surf-glass-soft: rgba(220, 215, 204, 0.42);
--border-1: rgba(60, 56, 54, 0.15);
--border-2: rgba(60, 56, 54, 0.28);
--border-3: rgba(60, 56, 54, 0.5);
--ink-1: #28241f;
--ink-2: #3c3836;
--ink-3: #5a544c;
--ink-4: #8a8278;
--accent: #af3a03;
--accent-soft: #d65d0e;
--accent-glow: rgba(175, 58, 3, 0.28);
--accent-tint: rgba(175, 58, 3, 0.08);
--ok: #3c911c;
--ok-glow: rgba(60, 145, 28, 0.32);
--warn: #b57614;
--warn-glow: rgba(181, 118, 20, 0.35);
--err: #9d0006;
--err-glow: rgba(157, 0, 6, 0.3);
--info: #427b58;
--info-glow: rgba(66, 123, 88, 0.3);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #2d82a3;
--blue-glow: rgba(45, 130, 163, 0.32);
--purple: #8c468c;
--purple-glow: rgba(140, 70, 140, 0.32);
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
/* Relief light : highlight haut blanc cassé + ombre marquée */
--tile-3d:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
0 1px 0 rgba(60, 50, 40, 0.1),
0 2px 4px rgba(60, 50, 40, 0.12),
0 8px 18px rgba(60, 50, 40, 0.18);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
0 1px 0 rgba(60, 50, 40, 0.15),
0 4px 8px rgba(60, 50, 40, 0.18),
0 14px 28px rgba(60, 50, 40, 0.22);
}
/* ============================================================
Reset minimal + base typo
============================================================ */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-ui);
font-size: 14px;
color: var(--ink-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--font-mono); }
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
.label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
}
/* ============================================================
Surfaces — relief 3D marqué, AUCUN effet hover
============================================================ */
.glass {
background: var(--surf-glass);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid var(--border-2);
box-shadow: var(--tile-3d);
}
.glass-strong {
background: var(--surf-glass-strong);
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border: 1px solid var(--border-3);
box-shadow: var(--tile-3d-strong);
}
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
.interactive {
cursor: pointer;
transition: transform .04s ease-out, box-shadow .04s, background .04s;
transform: translateY(0);
}
.interactive:active {
transform: translateY(1px);
box-shadow: var(--shadow-press) !important;
filter: brightness(0.92);
}
/* Scrollbar custom */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
@@ -0,0 +1,378 @@
/* ============================================================
tokens.gnome.css — Tokens pour applications GNOME (GTK 4 / libadwaita)
Gruvbox seventies · v1.0
============================================================
Usage dans une app GTK 4 / libadwaita :
#include <gtk/gtk.h>
GtkCssProvider *provider = gtk_css_provider_new();
gtk_css_provider_load_from_path(provider, "tokens.gnome.css");
gtk_style_context_add_provider_for_display(
gdk_display_get_default(), GTK_STYLE_PROVIDER(provider),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
Python (PyGObject) :
css_provider = Gtk.CssProvider()
css_provider.load_from_path("tokens.gnome.css")
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
GJS :
const provider = new Gtk.CssProvider();
provider.load_from_path('tokens.gnome.css');
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
============================================================ */
/* ============================================================
THÈME SOMBRE (défaut)
============================================================ */
/* Couches de fond (du plus profond au plus haut) */
@define-color bg_0 #221c17;
@define-color bg_1 #2a231d;
@define-color bg_2 #322a23;
@define-color bg_3 #3c332a;
@define-color bg_4 #4a4035;
@define-color bg_5 #5a4f43;
/* Encres / texte */
@define-color ink_1 #f2e5c7;
@define-color ink_2 #d5c4a1;
@define-color ink_3 #a89984;
@define-color ink_4 #7c6f64;
/* Accent orange seventies */
@define-color accent_color #fe8019;
@define-color accent_soft #d65d0e;
@define-color accent_fg_color #221c17;
/* Statuts */
@define-color success_color #4dbb26;
@define-color warning_color #fabd2f;
@define-color error_color #fb4934;
@define-color info_color #83a598;
@define-color blue_color #3db0d1;
@define-color purple_color #c882c8;
/* Bordures */
@define-color border_1 alpha(#a89984, 0.18);
@define-color border_2 alpha(#a89984, 0.32);
@define-color border_3 alpha(#a89984, 0.55);
/* Couleurs sémantiques GNOME / libadwaita (overrides) */
@define-color window_bg_color @bg_1;
@define-color window_fg_color @ink_1;
@define-color view_bg_color @bg_2;
@define-color view_fg_color @ink_1;
@define-color headerbar_bg_color @bg_2;
@define-color headerbar_fg_color @ink_1;
@define-color headerbar_border_color @border_2;
@define-color headerbar_backdrop_color @bg_1;
@define-color sidebar_bg_color @bg_2;
@define-color sidebar_fg_color @ink_1;
@define-color sidebar_backdrop_color @bg_1;
@define-color popover_bg_color @bg_3;
@define-color popover_fg_color @ink_1;
@define-color card_bg_color @bg_3;
@define-color card_fg_color @ink_1;
@define-color shade_color alpha(black, 0.4);
@define-color scrollbar_outline_color alpha(@ink_3, 0.3);
/* ============================================================
COMPOSANTS GTK — habillage Gruvbox seventies
============================================================ */
/* Fond global */
window {
background-color: @window_bg_color;
color: @window_fg_color;
font-family: 'Inter', 'Cantarell', sans-serif;
font-size: 14px;
}
/* HeaderBar (barre de titre) */
headerbar {
background: @bg_2;
color: @ink_1;
border-bottom: 1px solid @border_2;
box-shadow: inset 0 1px 0 alpha(white, 0.04);
min-height: 48px;
}
headerbar .title {
font-weight: 700;
font-size: 15px;
}
headerbar .subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: @ink_3;
}
/* Boutons — relief 3D et accent */
button {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 6px 12px;
font-weight: 500;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.3),
0 1px 2px alpha(black, 0.4);
transition: all 60ms ease;
}
button:active {
background: @bg_4;
box-shadow: inset 0 2px 4px alpha(black, 0.5);
transform: translateY(1px);
}
button:disabled {
color: @ink_4;
opacity: 0.6;
}
/* Bouton "suggested-action" = primary (accent orange) */
button.suggested-action {
background: @accent_color;
color: @accent_fg_color;
border-color: @accent_soft;
box-shadow:
inset 0 1px 0 alpha(white, 0.2),
0 2px 6px alpha(@accent_color, 0.35);
}
button.suggested-action:active {
background: @accent_soft;
}
/* Bouton "destructive-action" = danger */
button.destructive-action {
background: @bg_3;
color: @error_color;
border-color: @error_color;
}
/* Bouton plat (toolbar) */
button.flat {
background: transparent;
border-color: transparent;
box-shadow: none;
}
button.flat:hover {
background: @bg_3;
}
/* Champs de saisie */
entry,
text {
background: @bg_1;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 8px 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.3);
}
entry:focus,
text:focus {
border-color: @accent_color;
outline: 2px solid alpha(@accent_color, 0.18);
outline-offset: -1px;
}
/* Listes / treeview */
list,
treeview {
background: @bg_2;
color: @ink_1;
}
list > row {
padding: 8px 12px;
border-bottom: 1px solid @border_1;
}
list > row:selected,
treeview:selected {
background: alpha(@accent_color, 0.12);
color: @ink_1;
border-left: 3px solid @accent_color;
}
/* Switch (toggle) */
switch {
background: @bg_4;
border: 1px solid @border_2;
border-radius: 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 22px;
min-width: 42px;
}
switch:checked {
background: @accent_color;
border-color: @accent_soft;
box-shadow: 0 0 10px alpha(@accent_color, 0.35);
}
switch slider {
background: @ink_2;
border-radius: 50%;
min-width: 18px;
min-height: 18px;
}
switch:checked slider {
background: @accent_fg_color;
}
/* Scale (slider) */
scale trough {
background: @bg_1;
border-radius: 4px;
min-height: 6px;
}
scale highlight {
background: @accent_color;
border-radius: 4px;
}
scale slider {
background: @ink_1;
border: 2px solid @accent_color;
border-radius: 50%;
min-width: 16px;
min-height: 16px;
box-shadow: 0 1px 4px alpha(black, 0.5);
}
/* Progress bar (jauge horizontale type batterie) */
progressbar trough {
background: @bg_1;
border: 1px solid @border_2;
border-radius: 4px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 12px;
}
progressbar progress {
background: @success_color;
border-radius: 3px;
box-shadow: 0 0 8px alpha(@success_color, 0.45);
}
/* Niveaux de progression sémantiques (à appliquer via add_css_class) */
progressbar.warning progress { background: @warning_color; }
progressbar.error progress { background: @error_color; }
progressbar.info progress { background: @info_color; }
/* Notebook / onglets */
notebook header {
background: @bg_2;
border-bottom: 1px solid @border_2;
}
notebook tab {
padding: 8px 16px;
color: @ink_3;
border-top: 2px solid transparent;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
notebook tab:checked {
color: @ink_1;
border-top-color: @accent_color;
background: @bg_3;
}
/* Popover */
popover contents {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 32px alpha(black, 0.55);
}
/* Menubutton / dropdown */
menubutton button {
padding: 4px 8px;
}
/* Status pill (badge) — à appliquer sur GtkLabel.status */
label.status {
padding: 2px 8px;
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
label.status.ok { background: alpha(@success_color, 0.18); color: @success_color; }
label.status.warn { background: alpha(@warning_color, 0.18); color: @warning_color; }
label.status.error { background: alpha(@error_color, 0.18); color: @error_color; }
label.status.info { background: alpha(@info_color, 0.18); color: @info_color; }
/* Texte monospace / terminal */
label.mono,
.mono {
font-family: 'JetBrains Mono', monospace;
}
label.terminal,
.terminal {
font-family: 'Share Tech Mono', 'VT323', monospace;
letter-spacing: 0.02em;
}
/* Carte tuile (à appliquer via add_css_class("tile")) */
.tile,
.card {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 12px;
padding: 14px;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.4),
0 2px 4px alpha(black, 0.4),
0 6px 14px alpha(black, 0.45);
}
/* Scrollbar */
scrollbar slider {
background: @border_2;
border-radius: 4px;
min-width: 6px;
min-height: 6px;
}
scrollbar slider:hover {
background: @accent_soft;
}
/* ============================================================
THÈME CLAIR — à charger en alternative
Pour appliquer le thème clair, charger ce fichier puis
`tokens.gnome.light.css` (à dupliquer en remplaçant
les @define-color des fonds et encres) OU appliquer
un settings GTK light :
g_object_set(gtk_settings, "gtk-application-prefer-dark-theme",
FALSE, NULL);
Et fournir un fichier dérivé avec les valeurs ci-dessous :
============================================================ */
/*
bg_0: #b8b2a3
bg_1: #d5d0c5
bg_2: #dcd7cc
bg_3: #e3ded3
bg_4: #ccc6b8
bg_5: #bdb6a7
ink_1: #28241f
ink_2: #3c3836
ink_3: #5a544c
ink_4: #8a8278
accent_color: #af3a03
success_color: #3c911c
warning_color: #b57614
error_color: #9d0006
info_color: #427b58
blue_color: #2d82a3
purple_color: #8c468c
*/
@@ -0,0 +1,136 @@
{
"$schema": "design-tokens-v1",
"name": "mon design system — gruvbox seventies",
"version": "1.0.0",
"description": "Design system Gruvbox seventies. Orange brûlé, fond brun délavé en sombre / gris clair usé en clair. Deux thèmes dark/light parfaitement à parité.",
"themes": {
"dark": {
"bg": {
"0": { "value": "#221c17", "description": "Niveau le plus profond, rare" },
"1": { "value": "#2a231d", "description": "Fond application principal" },
"2": { "value": "#322a23", "description": "Panneaux (sidebar, headerbar)" },
"3": { "value": "#3c332a", "description": "Cartes, tuiles" },
"4": { "value": "#4a4035", "description": "Hover, état actif" },
"5": { "value": "#5a4f43", "description": "Press, sélection forte" }
},
"ink": {
"1": { "value": "#f2e5c7", "description": "Texte principal (cream)" },
"2": { "value": "#d5c4a1", "description": "Texte secondaire" },
"3": { "value": "#a89984", "description": "Labels, hints" },
"4": { "value": "#7c6f64", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#fe8019", "description": "Orange Gruvbox seventies" },
"soft": { "value": "#d65d0e", "description": "Orange foncé (hover, bordures)" },
"glow": { "value": "rgba(254, 128, 25, 0.35)" },
"tint": { "value": "rgba(254, 128, 25, 0.12)" }
},
"status": {
"ok": { "value": "#4dbb26" },
"warn": { "value": "#fabd2f" },
"err": { "value": "#fb4934" },
"info": { "value": "#83a598" }
},
"extra": {
"blue": { "value": "#3db0d1" },
"purple": { "value": "#c882c8" }
},
"border": {
"1": { "value": "rgba(168, 153, 132, 0.18)" },
"2": { "value": "rgba(168, 153, 132, 0.32)" },
"3": { "value": "rgba(168, 153, 132, 0.55)" }
}
},
"light": {
"bg": {
"0": { "value": "#b8b2a3", "description": "Niveau le plus profond" },
"1": { "value": "#d5d0c5", "description": "Fond application principal" },
"2": { "value": "#dcd7cc", "description": "Panneaux" },
"3": { "value": "#e3ded3", "description": "Cartes, tuiles" },
"4": { "value": "#ccc6b8", "description": "Hover" },
"5": { "value": "#bdb6a7", "description": "Press" }
},
"ink": {
"1": { "value": "#28241f", "description": "Texte principal" },
"2": { "value": "#3c3836", "description": "Texte secondaire" },
"3": { "value": "#5a544c", "description": "Labels, hints" },
"4": { "value": "#8a8278", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#af3a03", "description": "Orange brûlé (variante contrastée)" },
"soft": { "value": "#d65d0e" },
"glow": { "value": "rgba(175, 58, 3, 0.28)" },
"tint": { "value": "rgba(175, 58, 3, 0.08)" }
},
"status": {
"ok": { "value": "#3c911c" },
"warn": { "value": "#b57614" },
"err": { "value": "#9d0006" },
"info": { "value": "#427b58" }
},
"extra": {
"blue": { "value": "#2d82a3" },
"purple": { "value": "#8c468c" }
},
"border": {
"1": { "value": "rgba(60, 56, 54, 0.15)" },
"2": { "value": "rgba(60, 56, 54, 0.28)" },
"3": { "value": "rgba(60, 56, 54, 0.5)" }
}
}
},
"typography": {
"fonts": {
"ui": { "family": "Inter", "weights": [400, 500, 600, 700], "fallback": ["Cantarell", "system-ui", "sans-serif"] },
"mono": { "family": "JetBrains Mono", "weights": [400, 500, 600, 700], "fallback": ["ui-monospace", "monospace"] },
"terminal": { "family": "Share Tech Mono", "weights": [400], "fallback": ["VT323", "Courier New", "monospace"] }
},
"scale": {
"label": { "size": 11, "weight": 500, "transform": "uppercase", "tracking": "0.08em", "family": "mono" },
"caption": { "size": 12, "weight": 400, "family": "ui" },
"body": { "size": 14, "weight": 400, "family": "ui" },
"body-emph": { "size": 14, "weight": 600, "family": "ui" },
"title": { "size": 18, "weight": 700, "family": "ui" },
"h2": { "size": 22, "weight": 700, "family": "ui" },
"h1": { "size": 28, "weight": 700, "family": "ui" },
"display": { "size": 44, "weight": 700, "family": "ui" },
"kpi": { "size": 28, "weight": 700, "family": "mono" }
}
},
"radius": {
"xs": 3,
"sm": 4,
"md": 6,
"lg": 8,
"xl": 10,
"2xl": 12,
"pill": 999
},
"spacing": {
"1": 4,
"2": 6,
"3": 8,
"4": 10,
"5": 12,
"6": 14,
"7": 16,
"8": 18,
"9": 20,
"10": 24,
"12": 32,
"14": 40,
"16": 56
},
"shadows": {
"1": "0 1px 2px rgba(0,0,0,0.4)",
"2": "0 4px 12px rgba(0,0,0,0.45)",
"3": "0 12px 32px rgba(0,0,0,0.55)",
"press": "inset 0 2px 4px rgba(0,0,0,0.5)",
"tile3d": "inset 0 1px 0 rgba(255,230,180,0.12), inset 0 -1px 0 rgba(0,0,0,0.45), 0 1px 0 rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 8px 18px rgba(0,0,0,0.5)"
},
"motion": {
"fast": "60ms ease",
"normal": "180ms cubic-bezier(.3,.7,.3,1.2)",
"slow": "400ms cubic-bezier(.3,.6,.3,1)"
}
}