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:
@@ -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"><{name}/></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"><{name}/></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' }}><{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>
|
||||
<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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user