diff --git a/design_system/package-smartphone/README.md b/design_system/package-smartphone/README.md
new file mode 100644
index 0000000..526057a
--- /dev/null
+++ b/design_system/package-smartphone/README.md
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 📂 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 ``
+- [ ] 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.
diff --git a/design_system/package-smartphone/components/mobile-apps.jsx b/design_system/package-smartphone/components/mobile-apps.jsx
new file mode 100644
index 0000000..7428f02
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-apps.jsx
@@ -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 (
+ {initials}
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
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',
+ }}>
+
+
+
+
{name}
+ {email &&
{email}
}
+
+
+ {items.map((it, i) => (
+
{ 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',
+ }}>
+
+ {it.label}
+ {!it.danger && }
+
+ ))}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ Passer
+
+
+
+
+
+
+
{slides[i].title}
+
{slides[i].desc}
+
+
+
+ {slides.map((_, j) => (
+ 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',
+ }} />
+ ))}
+
+
isLast ? onFinish() : setI(i + 1)}>
+ {isLast ? 'Commencer' : 'Suivant'}
+
+
+
+ );
+}
+
+/* ============================================================
+ ChatBubble — bulle de message (envoyé/reçu)
+ Nom système : ChatBubble
+ ============================================================ */
+function ChatBubble({ text, time, me, status }) {
+ return (
+
+
+
{text}
+
+ {time}
+ {me && status === 'sent' && ✓ }
+ {me && status === 'read' && ✓✓ }
+
+
+
+ );
+}
+
+/* ============================================================
+ ChatComposer — barre d'envoi en bas (input + + + send)
+ Nom système : ChatComposer
+ ============================================================ */
+function ChatComposer({ onSend }) {
+ const [v, setV] = uA('');
+ return (
+
+
+
+ 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,
+ }} />
+
+ {v ? (
+
{ 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',
+ }}>
+ ) : (
+
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ {['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
+
{d}
+ ))}
+ {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 (
+
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 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+/* ============================================================
+ MapView — placeholder visuel d'une carte avec pins
+ Nom système : MapView
+ ============================================================ */
+function MapView({ pins = [] }) {
+ return (
+
+ {/* fond carte stylisé */}
+
+
+
+
+
+
+
+ {/* routes */}
+
+
+
+ {/* zones */}
+
+
+
+ {/* fleuve */}
+
+
+ {/* pins */}
+ {pins.map((p, i) => (
+
+
+
+
+ {p.label && (
+
{p.label}
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {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 (
+ 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 && }
+ {l}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ QrScannerView — viseur scanner code-barres / QR
+ Nom système : QrScannerView
+ ============================================================ */
+function QrScannerView({ onCapture }) {
+ return (
+
+ {/* fake camera feed = grain animé */}
+
+ {/* visée centrale */}
+
+ {/* 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) => (
+
+ ))}
+ {/* ligne scan animée */}
+
+
+
+ {/* overlay assombri hors visée */}
+
+ {/* texte */}
+
Pointe vers un QR code ou code-barres
+ {/* boutons bas */}
+
+
+ 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',
+ }}>
+
+
+
+ );
+}
+
+/* ============================================================
+ CameraView — viseur appareil photo avec shutter rond
+ Nom système : CameraView
+ ============================================================ */
+function CameraView({ onShoot }) {
+ return (
+
+ {/* fake scene */}
+
+ {/* règle des tiers */}
+
+ {[33.33, 66.66].map((p) => (
+
+
+
+
+ ))}
+
+ {/* top bar */}
+
+ {[
+ { icon: 'moon', label: 'Flash' },
+ { icon: 'clock', label: 'Minuteur' },
+ { icon: 'grid', label: 'Grille' },
+ ].map((b) => (
+
+ ))}
+
+ {/* mode chips */}
+
+ Vidéo
+ Photo
+ Portrait
+
+ {/* bottom controls */}
+
+
+
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',
+ }}/>
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {items.map((it) => (
+
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) },
+ ]}>
+
+
+
+
+
+
{it.name}
+
+ {it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
+
+
+ {it.type === 'folder' &&
}
+
+
+ ))}
+
+ );
+}
+
+Object.assign(window, {
+ Avatar, AvatarMenu,
+ OnboardingSlider,
+ ChatBubble, ChatComposer,
+ CalendarMonth,
+ MapView,
+ FilterChips,
+ QrScannerView, CameraView,
+ FileExplorer,
+});
diff --git a/design_system/package-smartphone/components/mobile-forms.jsx b/design_system/package-smartphone/components/mobile-forms.jsx
new file mode 100644
index 0000000..50dc442
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-forms.jsx
@@ -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 (
+
+ {label && (
+
+ {label}{required && * }
+
+ )}
+ {children}
+ {(error || hint) && (
+
{error || hint}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+
+ {trailing}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ 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',
+ }}
+ />
+
+ );
+}
+
+/* ============================================================
+ Dropdown — select natif stylisé
+ Nom système : Dropdown
+ ============================================================ */
+function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
+ return (
+
+ 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,
+ }}>
+ {placeholder}
+ {options.map((o) => (
+ typeof o === 'string'
+ ? {o}
+ : {o.label}
+ ))}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {checked && }
+
+
+
{label}
+ {description &&
{description}
}
+
+ onChange(e.target.checked)} style={{ display: 'none' }} />
+
+ );
+}
+
+/* ============================================================
+ RadioGroup — groupe d'options exclusives
+ Nom système : RadioGroup
+ ============================================================ */
+function RadioGroup({ value, onChange, options }) {
+ return (
+
+ );
+}
+
+/* ============================================================
+ 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
+ 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 (
+
+ {items.map((it) => (
+
+
+ {it.label}
+ {it.hint}
+ {!it.special && (
+ onPick && onPick(it.id, e.target.files[0])}
+ style={{ display: 'none' }} />
+ )}
+ {it.special && (
+ {
+ 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' }} />
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ AvatarLogo — gros logo rond pour écran de connexion
+ Nom système : AvatarLogo
+ ============================================================ */
+function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
+ return (
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {lbl}
+
+ );
+}
+
+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 });
diff --git a/design_system/package-smartphone/components/mobile-gestures.jsx b/design_system/package-smartphone/components/mobile-gestures.jsx
new file mode 100644
index 0000000..2d5b613
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-gestures.jsx
@@ -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 (
+
+ {label && (
+
{label}
+ )}
+
+ {/* indicateur central */}
+
+ {last ? (
+
+ {last.name}
+ {last.data && (
+
+ {Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
+
+ )}
+
+ ) : (
+
essaie un geste ici
+ )}
+
+
+ {/* trail visuel pendant le pan */}
+ {trail && (
+
+ )}
+
+ {/* Journal */}
+ {log.length > 0 && (
+
+
+ journal
+ {log.length} dernier{log.length > 1 ? 's' : ''}
+
+ {log.map((l, i) => (
+
+ {l.t} {' '}
+ {l.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* 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 });
diff --git a/design_system/package-smartphone/components/mobile-kit.jsx b/design_system/package-smartphone/components/mobile-kit.jsx
new file mode 100644
index 0000000..f0428b8
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-kit.jsx
@@ -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 (
+
+ {time}
+
+ {/* signal bars */}
+
+ {[1, 2, 3, 4].map((b) => (
+
+ ))}
+
+
+ {/* battery */}
+
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {onBack && (
+
+
+
+ )}
+
+ {!large && (
+
+ {title}
+
+ )}
+
+ {right &&
{right}
}
+
+ {large && (
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {items.map((it) => {
+ const isActive = active === it.id;
+ return (
+ 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)',
+ }}>
+
+ {it.label}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+ e.currentTarget.style.background = 'var(--bg-3)' : undefined}
+ onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
+ {icon && (
+
+
+
+ )}
+ {label}
+ {value && {value} }
+ {right === undefined && onClick && }
+ {right}
+
+ );
+}
+
+/* ============================================================
+ ListSection — groupe de ListRow avec titre
+ Nom système : ListSection
+ ============================================================ */
+function ListSection({ title, hint, children }) {
+ return (
+
+ {title && (
+
{title}
+ )}
+
{children}
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ {title}
+ {subtitle && {subtitle} }
+ {value && (
+ {value}
+ )}
+ {badge && (
+ {badge}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+ {children}
+
+ );
+}
+
+/* ============================================================
+ SegmentedControl — sélecteur segmenté iOS-style
+ Nom système : SegmentedControl
+ Usage : 2-4 options exclusives, jamais plus.
+ ============================================================ */
+function SegmentedControl({ value, onChange, options }) {
+ return (
+
+ {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 (
+ 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 && }
+ {l}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ SearchBar — champ de recherche mobile
+ Nom système : SearchBar
+ ============================================================ */
+function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
+ return (
+
+
+ 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 && (
+ 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,
+ }}>
+ )}
+
+ );
+}
+
+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);
+})();
diff --git a/design_system/package-smartphone/components/mobile-sheets.jsx b/design_system/package-smartphone/components/mobile-sheets.jsx
new file mode 100644
index 0000000..3abe582
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-sheets.jsx
@@ -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 (
+
+
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 */}
+
+ {title && (
+
+ )}
+
{children}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
e.stopPropagation()} style={{
+ width: '100%',
+ display: 'flex', flexDirection: 'column', gap: 8,
+ animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
+ }}>
+
+ {title && (
+
{title}
+ )}
+ {actions.map((a, i) => (
+
{ 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 && }
+ {a.label}
+
+ ))}
+
+
{cancelLabel}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ {icon && (
+
+
+
+ )}
+
{title}
+ {message &&
{message}
}
+
+
+ {actions.map((a, i) => (
+ { 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}
+ ))}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ {message}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {/* indicateur */}
+
+
{children}
+
+ );
+}
+
+Object.assign(window, {
+ BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
+});
diff --git a/design_system/package-smartphone/components/mobile-swipeable.jsx b/design_system/package-smartphone/components/mobile-swipeable.jsx
new file mode 100644
index 0000000..6037d1f
--- /dev/null
+++ b/design_system/package-smartphone/components/mobile-swipeable.jsx
@@ -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 (
+
+ {/* Actions à GAUCHE (révélées par swipe droit) */}
+ {rightActions.length > 0 && (
+
+ {rightActions.map((a, i) => (
+ 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 && }
+ {a.label}
+
+ ))}
+
+ )}
+ {/* Actions à DROITE (révélées par swipe gauche) */}
+ {leftActions.length > 0 && (
+
+ {leftActions.map((a, i) => (
+ 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 && }
+ {a.label}
+
+ ))}
+
+ )}
+ {/* Ligne déplaçable */}
+
+ {children}
+
+
+ );
+}
+
+Object.assign(window, { SwipeableRow });
diff --git a/design_system/package-smartphone/components/ui-kit.jsx b/design_system/package-smartphone/components/ui-kit.jsx
new file mode 100644
index 0000000..92f9e76
--- /dev/null
+++ b/design_system/package-smartphone/components/ui-kit.jsx
@@ -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 . 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 (
+
+ );
+};
+
+/* ============================================================
+ 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 (
+
+ {children}
+ {show && (
+ {label}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ Toggle on/off — switch tactile avec glow accent quand ON
+ ============================================================ */
+function Toggle({ on, onChange, label, icon }) {
+ return (
+
+ {icon && }
+ {label && {label} }
+ 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,
+ }}>
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 && (
+
+ )}
+
+ >
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {(icon || label) && (
+
+ {icon && }
+ {label && {label} }
+
+ )}
+
+
+ {value}{unit}
+
+
+ );
+ }
+
+ return (
+
+ {label && (
+
+ {label}
+
+ {value}{unit}
+
+
+ )}
+
+
+ {/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+
+
+ {/* arc background */}
+
+ {/* arc value glow */}
+
+ {/* arc value crisp */}
+
+
+
+
+ {value}%
+
+ {label &&
{label}
}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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 ;
+ })}
+ {[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 {v} ;
+ })}
+ {/* arc bg */}
+
+ {/* arc value glow */}
+
+ {/* arc value */}
+
+ {/* needle */}
+
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ Popup — modale glassmorphism centrée + bouton fermer
+ ============================================================ */
+function Popup({ open, onClose, title, children, footer, width = 460 }) {
+ if (!open) return null;
+ return (
+
+
+
e.stopPropagation()} style={{
+ width, maxWidth: '90%',
+ borderRadius: 12,
+ boxShadow: 'var(--shadow-3)',
+ animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
+ overflow: 'hidden',
+ }}>
+
+
{children}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+ {children}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {groups.map(g => (
+
+
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',
+ }}>
+
+
+ {g.label}
+ {g.count != null && (
+
+ {g.count}
+
+ )}
+
+ {open[g.id] && (
+
+ {g.children.map(c => {
+ const active = c.id === activeId;
+ return (
+
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,
+ }}>
+
+ {c.label}
+ {c.meta && {c.meta} }
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {/* 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 (
+
+
+ {v}
+
+ );
+ })}
+ {/* labels x */}
+ {labels && labels.map((lb, i) => (
+ i % Math.ceil(labels.length / 8) === 0 && (
+ {lb}
+ )
+ ))}
+ {/* 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 (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+/* 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);
+})();
diff --git a/design_system/package-smartphone/consigne_mobile.md b/design_system/package-smartphone/consigne_mobile.md
new file mode 100644
index 0000000..5e86685
--- /dev/null
+++ b/design_system/package-smartphone/consigne_mobile.md
@@ -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 `` 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 `` 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
+
+
+
} />
+
+ {/* contenu */}
+
+
+```
+
+Si la page fait partie d'une nav principale, ajoute `` après. Si elle a une action principale flottante, ajoute ``.
+
+### Liste à actions cachées
+```jsx
+ 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
+>
+ {contenu de la ligne}
+
+```
+
+### Champ texte typé
+
+```jsx
+// Email
+
+// Mot de passe
+
+// OTP SMS
+
+// Recherche
+
+```
+
+### Confirmation destructive
+```jsx
+
+```
+
+### Choix dans une liste
+```jsx
+
+
+
+```
+
+### Menu d'actions sur élément
+```jsx
+
+```
+
+### Feedback après action
+```jsx
+ setMsg(null)} message={msg} variant="ok" />
+```
+
+### Menu utilisateur (avatar haut-droite)
+```jsx
+
+```
+
+---
+
+## 🚫 Anti-patterns
+
+- ❌ `window.alert / confirm` → utilise `AlertDialog`
+- ❌ `` 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**.
diff --git a/design_system/package-smartphone/examples/exemple-mobile-apps-combined.jsx b/design_system/package-smartphone/examples/exemple-mobile-apps-combined.jsx
new file mode 100644
index 0000000..f8e6b18
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile-apps-combined.jsx
@@ -0,0 +1,1116 @@
+/* ============================================================
+ exemple-mobile-apps-app.jsx
+ 10 écrans navigables : Onboarding, Accueil, Chat (liste+détail),
+ Calendrier, Maps, Recherche, Scanner QR, Caméra, Fichiers, Paramètres.
+ Avatar+menu présents sur tous les écrans en haut à droite.
+ ============================================================ */
+
+const { useState: uX, useEffect: eX } = React;
+
+/* ---------- DONNÉES MOCK ---------- */
+const CHATS = [
+ { id: 1, name: 'Marc Dubois', avatar: 'var(--accent)', last: 'Tu peux jeter un œil au scan ?', time: '14:02', unread: 2 },
+ { id: 2, name: 'Équipe Ops', avatar: 'var(--blue)', last: 'Léa : déploiement OK 🚀', time: '13:14', unread: 0 },
+ { id: 3, name: 'Sophie', avatar: 'var(--purple)', last: 'On se voit ce soir ?', time: '11:30', unread: 1 },
+ { id: 4, name: 'Antoine', avatar: 'var(--ok)', last: 'Merci pour le tuyau !', time: 'hier', unread: 0 },
+ { id: 5, name: 'Notifs syst.', avatar: 'var(--warn)', last: 'Backup horaire OK · 812 Mo', time: 'hier', unread: 0 },
+];
+
+const CHAT_MESSAGES = [
+ { id: 1, me: false, text: 'Salut ! Tu as eu le temps de regarder le scan d\'hier soir ?', time: '13:58' },
+ { id: 2, me: true, text: 'Oui, je viens de finir. Le node-03 a un soucis de latence', time: '13:59', status: 'read' },
+ { id: 3, me: false, text: 'Aïe. Tu peux me partager les détails ?', time: '14:00' },
+ { id: 4, me: true, text: 'Je t\'envoie le rapport dans 5 min', time: '14:01', status: 'read' },
+ { id: 5, me: false, text: 'Top, merci 🙏', time: '14:02' },
+];
+
+const FILES = [
+ { name: 'Documents', type: 'folder', date: '21 mai' },
+ { name: 'Photos 2026', type: 'folder', date: '12 mai' },
+ { name: 'rapport-scan.pdf', type: 'pdf', date: '21 mai 14:02', size: 384000 },
+ { name: 'capture.png', type: 'image', date: '21 mai 13:58', size: 1280000 },
+ { name: 'meeting.mp4', type: 'video', date: '20 mai', size: 124000000 },
+ { name: 'notes.md', type: 'code', date: '20 mai', size: 4800 },
+ { name: 'audio-memo.m4a',type: 'audio', date: '19 mai', size: 820000 },
+ { name: 'backup.zip', type: 'archive', date: '19 mai 03:00', size: 850000000 },
+];
+
+const ONBOARDING_SLIDES = [
+ { icon: 'server', color: 'var(--accent)', title: 'Bienvenue 👋', desc: 'Cette app te permet de superviser tes serveurs et capteurs depuis ton téléphone.' },
+ { icon: 'bell', color: 'var(--blue)', title: 'Tu es alerté en temps réel', desc: 'Reçois une notification dès qu\'un service tombe ou qu\'une métrique sort des seuils.' },
+ { icon: 'network', color: 'var(--purple)', title: 'Maîtrise ton réseau', desc: 'Lance un scan, repère les nouveaux hôtes, ouvre une session SSH depuis le mobile.' },
+];
+
+const NOW = new Date(2026, 4, 21); // 21 mai 2026
+const EVENTS = new Set([3, 7, 12, 14, 21, 24, 28]);
+
+/* ============================================================
+ HeaderAvatar — header partagé (titre + avatar rond)
+ Le bouton Avatar ouvre AvatarMenu (popup).
+ ============================================================ */
+function HeaderAvatar({ title, large, subtitle, onBack, onAvatar, avatarColor = 'var(--accent)' }) {
+ return (
+ }
+ />
+ );
+}
+
+/* ============================================================
+ ÉCRANS
+ ============================================================ */
+
+/* --- Accueil : grille d'apps --- */
+function ScreenHome({ goto, onAvatar }) {
+ const apps = [
+ { id: 'chat', icon: 'bell', color: 'var(--accent)', title: 'Messages', subtitle: '3 non lus', badge: 3 },
+ { id: 'calendar', icon: 'clock', color: 'var(--blue)', title: 'Calendrier', subtitle: 'mai 2026' },
+ { id: 'maps', icon: 'network', color: 'var(--ok)', title: 'Carte', subtitle: '8 pins' },
+ { id: 'search', icon: 'search', color: 'var(--info)', title: 'Recherche', subtitle: 'global' },
+ { id: 'scanner', icon: 'grid', color: 'var(--purple)', title: 'Scanner QR', subtitle: 'caméra' },
+ { id: 'camera', icon: 'memory', color: 'var(--warn)', title: 'Appareil photo', subtitle: 'caméra' },
+ { id: 'files', icon: 'folder', color: 'var(--info)', title: 'Fichiers', subtitle: '124 \u00e9l\u00e9ments' },
+ { id: 'settings', icon: 'cog', color: 'var(--ink-3)', title: 'Paramètres', subtitle: 'préférences' },
+ { id: 'onboard', icon: 'play', color: 'var(--accent-soft)', title: 'Onboarding', subtitle: 'revoir l\'intro' },
+ ];
+ return (
+
+
+
+
+
{}} placeholder="Rechercher dans tout…" />
+
+ {apps.map((a) => (
+
goto(a.id)} />
+ ))}
+
+
+
+ );
+}
+
+/* --- Chat list --- */
+function ScreenChatList({ onAvatar, open }) {
+ return (
+
+
+
a + c.unread, 0)} non lus`} onAvatar={onAvatar} />
+
+ {}} placeholder="Rechercher une conversation" />
+
+
+ {CHATS.map((c) => (
+
open(c)}
+ rightActions={[
+ { label: 'Lu', icon: 'play', color: 'var(--info)' },
+ { label: 'Épingl.', icon: 'bell', color: 'var(--accent)' },
+ ]}
+ leftActions={[
+ { label: 'Archiv.', icon: 'folder', color: 'var(--ok)' },
+ { label: 'Suppr.', icon: 'close', color: 'var(--err)' },
+ ]}>
+
+
+
+
+ {c.name}
+ {c.time}
+
+
{c.last}
+
+ {c.unread > 0 && (
+
{c.unread}
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+/* --- Chat detail --- */
+function ScreenChatDetail({ chat, onBack, onAvatar }) {
+ const [msgs, setMsgs] = uX(CHAT_MESSAGES);
+ const send = (text) => setMsgs([...msgs, { id: Date.now(), me: true, text, time: 'maint.', status: 'sent' }]);
+ return (
+
+
+
+
+
+
+
{chat.name}
+
● en ligne
+
+
+
+
+ {msgs.map((m) => )}
+
+
+
+ );
+}
+
+/* --- Calendar --- */
+function ScreenCalendar({ onAvatar, showToast }) {
+ const [sel, setSel] = uX(NOW);
+ return (
+
+
+
+
+ { setSel(d); showToast(`${d.getDate()} mai sélectionné`); }}
+ events={EVENTS} />
+
+ {}} />
+ {}} />
+ {}} />
+
+
+
showToast('Nouvel évènement')} />
+
+ );
+}
+
+/* --- Maps --- */
+function ScreenMaps({ onAvatar }) {
+ const [active, setActive] = uX(0);
+ const pins = [
+ { x: 30, y: 35, color: 'var(--accent)', icon: 'server', label: 'Bureau' },
+ { x: 60, y: 50, color: 'var(--ok)', icon: 'grid', label: 'Maison' },
+ { x: 75, y: 25, color: 'var(--blue)', icon: 'bell', label: 'Café' },
+ { x: 22, y: 70, color: 'var(--err)', icon: 'power', label: 'Atelier' },
+ { x: 50, y: 80, color: 'var(--warn)', icon: 'clock' },
+ { x: 80, y: 65, color: 'var(--purple)', icon: 'memory' },
+ ];
+ return (
+
+
+
} />
+
+ {}} placeholder="Adresse, lieu…" />
+
+
+
+ {/* contrôles flottants */}
+
+
+
+
+
+ {/* bottom info */}
+
+
+
+
{pins[active].label || 'Lieu sans nom'}
+
48.8566° N · 2.3522° E
+
+
+
+
+
+ );
+}
+
+/* --- Search avec filtres --- */
+function ScreenSearch({ onBack, onAvatar }) {
+ const [q, setQ] = uX('');
+ const [filters, setFilters] = uX(['all']);
+ const allResults = [
+ { id: 1, type: 'people', title: 'Sophie Martin', meta: 'sophie@exemple.com' },
+ { id: 2, type: 'message', title: 'Tu peux jeter un œil…', meta: 'Marc · hier 14:02' },
+ { id: 3, type: 'file', title: 'rapport-scan.pdf', meta: '21 mai · 384 Ko' },
+ { id: 4, type: 'event', title: 'Standup équipe', meta: 'mardi 09:00' },
+ { id: 5, type: 'place', title: 'Bureau Paris 11e', meta: '48.8566° N · 2.3522° E' },
+ { id: 6, type: 'file', title: 'capture.png', meta: '21 mai · 1.2 Mo' },
+ ];
+ const results = allResults.filter((r) => {
+ if (q && !r.title.toLowerCase().includes(q.toLowerCase())) return false;
+ if (filters.includes('all')) return true;
+ return filters.includes(r.type);
+ });
+ const iconFor = (t) => ({ people: 'user', message: 'bell', file: 'folder', event: 'clock', place: 'network' })[t];
+ const colorFor = (t) => ({ people: 'var(--accent)', message: 'var(--blue)', file: 'var(--info)', event: 'var(--purple)', place: 'var(--ok)' })[t];
+ return (
+
+
+
} />
+
+
+
+ setFilters(v.length ? v : ['all'])}
+ options={[
+ { value: 'all', label: 'Tout', icon: 'grid' },
+ { value: 'people', label: 'Personnes', icon: 'user' },
+ { value: 'message', label: 'Messages', icon: 'bell' },
+ { value: 'file', label: 'Fichiers', icon: 'folder' },
+ { value: 'event', label: 'Agenda', icon: 'clock' },
+ { value: 'place', label: 'Lieux', icon: 'network' },
+ ]} />
+
+
+
+
{results.length} résultat{results.length > 1 ? 's' : ''}
+
+ {results.map((r) => (
+
{}} />
+ ))}
+ {results.length === 0 && (
+
+ Aucun résultat pour "{q} "
+
+ )}
+
+
+
+ );
+}
+
+/* --- QR Scanner --- */
+function ScreenScanner({ onBack, showToast }) {
+ return (
+
+
+
+
+
+
Scanner QR / code-barres
+
+
+ showToast('Code détecté : https://exemple.com')} />
+
+
+ );
+}
+
+/* --- Caméra --- */
+function ScreenCamera({ onBack, showToast }) {
+ return (
+
+
+
+
+
+
+ showToast('Photo prise')} />
+
+
+ );
+}
+
+/* --- Fichiers --- */
+function ScreenFiles({ onBack, onAvatar, showToast }) {
+ return (
+
+
+
} />
+
+ {}} placeholder="Rechercher un fichier" />
+
+
+
+ Mes fichiers / Espace personnel
+
+
+ showToast(`Ouvrir : ${it.name}`)}
+ onAction={(act, it) => showToast(`${act} · ${it.name}`)} />
+
+
showToast('Importer un fichier')} />
+
+ );
+}
+
+/* --- Settings --- */
+function ScreenSettings({ onBack, onAvatar, showToast, openSheet }) {
+ const [notif, setNotif] = uX(true);
+ const [bio, setBio] = uX(false);
+ const [sync, setSync] = uX(true);
+ return (
+
+
+
+
+
+ showToast('Profil')} />
+ {}} />
+
+
+ openSheet('theme')} />
+ openSheet('lang')} />
+ } />
+ } />
+ } />
+
+
+ {}} />
+ {}} />
+ showToast('Cache effacé')} />
+
+
+ {}} />
+ {}} />
+ {}} />
+
+
+ showToast('Déconnexion')} />
+
+
+
+ );
+}
+
+Object.assign(window, {
+ ScreenHome, ScreenChatList, ScreenChatDetail,
+ ScreenCalendar, ScreenMaps, ScreenSearch,
+ ScreenScanner, ScreenCamera, ScreenFiles, ScreenSettings,
+ ONBOARDING_SLIDES, CHATS, CHAT_MESSAGES, FILES, EVENTS,
+});
+
+
+/* ============================================================
+ exemple-mobile-apps-doc.jsx
+ Doc panneau droite (catalogue commenté avec visuels) + ROOT.
+ ============================================================ */
+
+const { useState: uAD, useEffect: eAD } = React;
+
+/* ============================================================
+ ScreenVisual — mini SVG de chaque écran (pour la doc)
+ ============================================================ */
+function ScreenVisual({ type }) {
+ const frame = (inner, bg) => (
+
+
+
+ {inner}
+
+ );
+ if (type === 'onboarding') return frame(
+
+
+
+
+
+ {/* dots */}
+
+
+
+ {/* button */}
+
+
+ );
+ if (type === 'home') return frame(
+
+
+
+
+
+
+ {/* 9 grid */}
+ {[0,1,2,0,1,2,0,1,2].map((c, i) => {
+ const r = Math.floor(i / 3);
+ return ;
+ })}
+
+ );
+ if (type === 'chat-list') return frame(
+
+
+
+
+ {[0,1,2,3].map((i) => (
+
+
+
+
+
+ {i === 0 && }
+
+ ))}
+
+ );
+ if (type === 'chat-detail') return frame(
+
+
+
+
+
+
+ {/* messages */}
+
+
+
+
+ {/* composer */}
+
+
+
+
+ );
+ if (type === 'calendar') return frame(
+
+
+
+
+ {/* day labels */}
+ {[0,1,2,3,4,5,6].map((d) => (
+ {['L','M','M','J','V','S','D'][d]}
+ ))}
+ {/* days */}
+ {Array.from({length:28}).map((_, i) => {
+ const x = 8 + (i % 7) * 12;
+ const y = 52 + Math.floor(i / 7) * 13;
+ const sel = i === 20;
+ const today = i === 7;
+ return (
+
+
+ {i + 1}
+ {[2,6,11,13,20].includes(i) && }
+
+ );
+ })}
+
+
+
+ );
+ if (type === 'maps') return frame(
+
+
+
+
+ {/* routes */}
+
+
+ {/* pins */}
+ {[[30,55,'var(--accent)'],[60,80,'var(--ok)'],[75,45,'var(--blue)'],[22,110,'var(--err)']].map(([x,y,c], i) => (
+
+
+
+ ))}
+ {/* bottom card */}
+
+
+
+
+
+ );
+ if (type === 'search') return frame(
+
+
+
+ {/* chips */}
+
+
+
+
+ {/* results */}
+ {[0,1,2,3,4].map((i) => (
+
+
+
+
+
+
+ ))}
+
+ );
+ if (type === 'scanner') return frame(
+
+
+ {/* coins */}
+
+
+
+
+ {/* scan line */}
+
+ {/* shutter */}
+
+ ,
+ '#000'
+ );
+ if (type === 'camera') return frame(
+
+
+
+
+
+
+
+
+
+ {/* règle des tiers */}
+
+
+
+
+ {/* shutter */}
+
+
+
+ ,
+ '#000'
+ );
+ if (type === 'files') return frame(
+
+
+
+ {[
+ ['folder','var(--accent)','Documents'],
+ ['folder','var(--accent)','Photos'],
+ ['list','var(--err)','rapport.pdf'],
+ ['grid','var(--blue)','capture.png'],
+ ['play','var(--purple)','meeting.mp4'],
+ ['terminal','var(--ok)','memo.m4a'],
+ ].map((it, i) => (
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+ if (type === 'settings') return frame(
+
+
+
+
+ {[
+ ['user','var(--blue)','Compte'],
+ ['cog','var(--ink-3)','Préférences'],
+ ['bell','var(--accent)','Notifications', true],
+ ['refresh','var(--ok)','Sync', true],
+ ['folder','var(--purple)','Données'],
+ ['power','var(--err)','Se déconnecter', null, true],
+ ].map((it, i) => (
+
+
+
+
+ {it[3] && }
+
+ ))}
+
+ );
+ if (type === 'avatar-menu') return frame(
+
+
+
+ M
+ {/* écran assombri derrière */}
+
+ {/* popup menu */}
+
+
+
+
+
+ {/* items */}
+ {[0,1,2,3].map((i) => (
+
+
+
+
+
+ ))}
+
+ );
+ return frame(null);
+}
+
+/* ============================================================
+ ScreenCard — carte de présentation d'un écran
+ ============================================================ */
+function ScreenCard({ type, name, when, why, components, gestures }) {
+ return (
+
+
+
+
+
Écran {name}
+
Quand {when}
+
Pourquoi {why}
+ {components &&
Composants {components}
}
+ {gestures &&
Gestes {gestures}
}
+
+
+
+ );
+}
+
+/* ============================================================
+ NamedItem (avec preview live)
+ ============================================================ */
+function NamedItem({ name, desc, location, preview }) {
+ return (
+
+
+ <{name}/>
+ {location && 📍 {location} }
+
+
{desc}
+ {preview && (
+
{preview}
+ )}
+
+ );
+}
+
+/* ============================================================
+ DOC PANEL
+ ============================================================ */
+function Doc() {
+ return (
+
+
+ Patterns d'app courants
+
+ Ensemble de patterns que toute app mobile moderne utilise : onboarding,
+ chat, calendrier, maps, recherche, scanner, caméra, fichiers, paramètres ,
+ plus le bouton avatar en haut à droite qui ouvre un menu utilisateur.
+ Chaque écran est navigable dans le smartphone à gauche.
+
+
+
+
+ 10 écrans modèles
+ Bascule entre eux depuis l'Accueil du smartphone (grille d'icônes). L'écran Onboarding se lance automatiquement au premier passage.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bouton Avatar + menu utilisateur
+
+ Pattern récurrent : un bouton rond avec initiales en haut à droite de chaque écran.
+ Au tap, il ouvre un menu déroulant avec accès rapide à Profil, Paramètres, Aide et Déconnexion.
+ Pour le tester : tape sur le rond orange en haut à droite de n'importe quel écran (sauf Onboarding/Scanner/Caméra).
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
Marc Dupont
+
marc@exemple.com
+
+
+ {[
+ { icon: 'user', label: 'Mon profil' },
+ { icon: 'cog', label: 'Paramètres' },
+ { icon: 'bell', label: 'Notifications' },
+ { icon: 'power', label: 'Se déconnecter', danger: true },
+ ].map((it, i) => (
+ 0 ? '1px solid var(--border-1)' : 'none',
+ color: it.danger ? 'var(--err)' : 'var(--ink-1)', fontSize: 13,
+ }}>
+
+ {it.label}
+
+ ))}
+
+ } />
+
+
+
+ Nouveaux composants
+ Tous accessibles globalement après chargement de mobile-apps.jsx.
+
+
+ {[0,1,2].map((i) => (
+
+ ))}
+ } />
+
+
+
+
+
+ } />
+
+ {}} />} />
+
+
+ {}} events={new Set([3, 7, 14, 21])} />
+ } />
+
+
+
+ } />
+
+ {}} options={[
+ { value: 'a', label: 'Tout', icon: 'grid' },
+ { value: 'b', label: 'Personnes', icon: 'user' },
+ { value: 'c', label: 'Fichiers', icon: 'folder' },
+ { value: 'd', label: 'Messages', icon: 'bell' },
+ ]} />} />
+
+
+ {}} />
+ } />
+
+
+ {}} />
+ } />
+
+
+ {}} />
+
+ } />
+
+
+
+ Antisèche — pattern par cas
+
+
Premier lancement OnboardingSlider en plein écran, store flag "vu" en localStorage
+
Liste à actions SwipeableRow avec leftActions (destructives) + rightActions (utiles)
+
Identité utilisateur Avatar en haut à droite + AvatarMenu au tap → profil/réglages/déconnexion
+
Recherche multi-types SearchBar + FilterChips + ListRow (un seul container pour tous types)
+
Caméra/Scanner Plein écran sombre, header floating semi-transparent, contrôles flottants en bas
+
Carte MapView + boutons flottants (zoom, position) + BottomSheet/glass-strong pour les détails du pin
+
Réglages Empilement de ListSection avec en bas un ListRow danger "Se déconnecter"
+
+
+
+ );
+}
+
+/* ============================================================
+ APP ROOT
+ ============================================================ */
+function PhoneAppApps({ theme }) {
+ const [screen, setScreen] = uAD('home');
+ const [openedChat, setOpenedChat] = uAD(null);
+ const [toast, setToast] = uAD(null);
+ const [avatarOpen, setAvatarOpen] = uAD(false);
+ const [sheet, setSheet] = uAD(null);
+
+ const showToast = (msg) => setToast(msg);
+ const goto = (s) => { setScreen(s); setOpenedChat(null); };
+ const onAvatar = () => setAvatarOpen(true);
+
+ return (
+
+
+ {screen === 'onboard' && (
+ goto('home')} />
+ )}
+ {screen === 'home' && }
+ {screen === 'chat' && !openedChat && }
+ {screen === 'chat' && openedChat && setOpenedChat(null)} onAvatar={onAvatar} />}
+ {screen === 'calendar' && }
+ {screen === 'maps' && }
+ {screen === 'search' && goto('home')} onAvatar={onAvatar} />}
+ {screen === 'scanner' && goto('home')} showToast={showToast} />}
+ {screen === 'camera' && goto('home')} showToast={showToast} />}
+ {screen === 'files' && goto('home')} onAvatar={onAvatar} showToast={showToast} />}
+ {screen === 'settings' && goto('home')} onAvatar={onAvatar} showToast={showToast} openSheet={setSheet} />}
+
+
+ {screen !== 'onboard' && screen !== 'scanner' && screen !== 'camera' && (
+
{ goto(id); }}
+ items={[
+ { id: 'home', icon: 'grid', label: 'accueil' },
+ { id: 'chat', icon: 'bell', label: 'chat' },
+ { id: 'calendar', icon: 'clock', label: 'agenda' },
+ { id: 'maps', icon: 'network', label: 'carte' },
+ ]}
+ />
+ )}
+
+ setAvatarOpen(false)}
+ name="Marc Dupont"
+ email="marc@exemple.com"
+ items={[
+ { icon: 'user', label: 'Mon profil', onClick: () => showToast('Profil') },
+ { icon: 'cog', label: 'Paramètres', onClick: () => goto('settings') },
+ { icon: 'bell', label: 'Notifications', onClick: () => showToast('Notifications') },
+ { icon: 'folder', label: 'Mes fichiers', onClick: () => goto('files') },
+ { icon: 'list', label: "Centre d'aide", onClick: () => showToast('Aide') },
+ { icon: 'power', label: 'Se déconnecter', danger: true, onClick: () => goto('onboard') },
+ ]}
+ />
+
+ setSheet(null)} title="Choisir le thème">
+ {}} options={[
+ { value: 'dark', label: 'Sombre', icon: 'moon' },
+ { value: 'light', label: 'Clair', icon: 'sun' },
+ ]} />
+
+ setSheet(null)} title="Langue">
+ {}} options={[
+ { value: 'fr', label: 'Français' },
+ { value: 'en', label: 'English' },
+ { value: 'de', label: 'Deutsch' },
+ ]} />
+
+
+ setToast(null)} message={toast} variant="ok" />
+
+ );
+}
+
+function App() {
+ const [theme, setTheme] = uAD('dark');
+ const [device, setDevice] = uAD('ios');
+ eAD(() => { document.documentElement.dataset.theme = theme; }, [theme]);
+
+ return (
+
+
+
+
+
+ Exemple mobile · patterns chat · calendrier · maps · scanner · caméra · fichiers · avatar menu
+
+ exemple saisie
+
+
+
+
+
+
+ setDevice('ios')}>iOS
+ setDevice('android')}>Android
+
+
+ setTheme('dark')}>Sombre
+ setTheme('light')}>Clair
+
+
+
+ {device === 'ios' &&
}
+
+
+
↑ commence par l'accueil, puis explore les 9 autres écrans
+
+
+
+
+ );
+}
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render( );
diff --git a/design_system/package-smartphone/examples/exemple-mobile-apps.html b/design_system/package-smartphone/examples/exemple-mobile-apps.html
new file mode 100644
index 0000000..2ea7f46
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile-apps.html
@@ -0,0 +1,162 @@
+
+
+
+
+ Exemple mobile — patterns d'app
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design_system/package-smartphone/examples/exemple-mobile-saisie-app.jsx b/design_system/package-smartphone/examples/exemple-mobile-saisie-app.jsx
new file mode 100644
index 0000000..b57b0ec
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile-saisie-app.jsx
@@ -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 (
+
+ );
+}
+
+/* ============================================================
+ ÉCRAN 2 — Profile (avec bouton Paramètres haut-droite)
+ ============================================================ */
+function ScreenProfile({ openSettings }) {
+ return (
+
+
+
+ } />
+
+
+
+
Marc Dupont
+
admin · marc@exemple.com
+
+ ● connecté
+ premium
+
+
+
+
+ {}} />
+ {}} />
+ {}} />
+
+
+
+ {}} />
+ {}} />
+
+
+
+ {}} />
+
+
+
+ );
+}
+
+/* ============================================================
+ É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 (
+
+
+
showToast('Retour')}
+ right={
+ { 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
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Object.entries({ urgent: 'Urgent', perso: 'Perso', travail: 'Travail' }).map(([k, v]) => (
+ setTags({ ...tags, [k]: c })}
+ label={v} />
+ ))}
+
+
+
+
+
+ {media.length > 0 && (
+
+ {media.map((m, i) => (
+
📎 {m.label}
+ ))}
+
+ )}
+
+
+
+ {}}
+ placeholder="123456"
+ keyboard="numeric"
+ autocomplete="one-time-code"
+ maxLength={6} icon="bell" />
+
+
+
+
+
+
showToast('Note enregistrée')}>
+ Enregistrer la note
+
+
+
+
+ );
+}
+
+/* ============================================================
+ É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 (
+
+
+
+
+ {items.map((it) => (
+
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é'); } },
+ ]}>
+
+
+
+
+ {it.from}
+ {it.time}
+
+
{it.title}
+
+
+
+ ))}
+ {items.length === 0 && (
+
+ Plus de notifications — fais un swipe sur une ligne ←→ pour voir les actions.
+
+ )}
+
+ ← swipe gauche : archiver/supprimer · swipe droit : marquer lu/épingler →
+
+
+
+ );
+}
+
+Object.assign(window, { ScreenLogin, ScreenProfile, ScreenForm, ScreenSwipe });
diff --git a/design_system/package-smartphone/examples/exemple-mobile-saisie-doc.jsx b/design_system/package-smartphone/examples/exemple-mobile-saisie-doc.jsx
new file mode 100644
index 0000000..b668181
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile-saisie-doc.jsx
@@ -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) => (
+ {cells.map((c, i) => {c} )}
+ );
+ const row = (keys, big) => (
+
+ {keys.map((k, i) => (
+ {k}
+ ))}
+
+ );
+ 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 (
+ (aucun clavier — picker custom)
+ );
+ return null;
+}
+
+/* Mini SVG phone pour montrer les écrans */
+function ScreenVisual({ type }) {
+ const phone = (inner) => (
+
+
+
+ {inner}
+
+ );
+ if (type === 'login') return phone(
+
+
+
+
+
+
+
+ OU
+
+
+
+ );
+ if (type === 'profile') return phone(
+
+
+ Profil
+
+
+
+
+
+
+
+
+ );
+ if (type === 'form') return phone(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ if (type === 'swipe') return phone(
+
+
+ Boîte
+
+
+
+
+
+
+
+
+
+ Lu
+ Pin
+
+
+
+
+
+ );
+ return phone(null);
+}
+
+/* ============================================================
+ DOC PANEL
+ ============================================================ */
+
+function NamedItem({ name, desc, location, preview }) {
+ return (
+
+
+ <{name}/>
+ {location && 📍 {location} }
+
+
{desc}
+ {preview && (
+
{preview}
+ )}
+
+ );
+}
+
+function ScreenCard({ type, name, when, why, gestures, example }) {
+ return (
+
+
+
+
+
Écran {name}
+
Quand {when}
+
Pourquoi {why}
+ {gestures &&
Gestes {gestures}
}
+
Tester {example}
+
+
+
+ );
+}
+
+function Doc() {
+ return (
+
+ {/* INTRO */}
+
+ Saisie & formulaires mobile
+
+ Suite logique de la variante mobile : écrans de connexion, profil, formulaire complet,
+ liste swipeable . Tous les composants sont nommés et le clavier virtuel se configure
+ précisément (8 types, autocomplete système, touche Entrée personnalisable).
+
+
+
+ {/* ÉCRANS */}
+
+ 4 écrans modèles
+ Chaque écran combine plusieurs composants. Bascule entre eux via les onglets en bas du smartphone.
+
+
+
+
+
+
+
+
+
+
+ {/* COMPOSANTS */}
+
+ Composants de saisie
+ Tous ont une API homogène : value / onChange / label / hint / error. Les inputs supportent en plus le contrôle clavier virtuel.
+
+
+
+ {}} keyboard="email" icon="bell" />} />
+
+ {}} mode="date" />} />
+
+ {}} placeholder="Choisir…" options={['Option A', 'Option B', 'Option C']} />} />
+
+ {}} label="J'accepte les conditions" description="En cochant tu acceptes notre politique." />} />
+
+ {}} options={[
+ { value: 'a', label: 'Option A', description: 'Première option' },
+ { value: 'b', label: 'Option B', description: 'Deuxième option' },
+ ]} />} />
+
+ {}} />} />
+
+ } />
+
+ } />
+
+
+
+ ← swipe-moi dans un sens ou l'autre →
+
+
+ } />
+
+
+ {/* CLAVIER VIRTUEL */}
+
+ Clavier virtuel
+
+ Sur mobile, le clavier qui s'affiche dépend de la prop keyboard (attribut HTML inputmode).
+ Choisis le BON type pour faire gagner du temps à l'utilisateur — exemple : keyboard="numeric" pour un code OTP fait apparaître directement le pavé numérique au lieu du clavier complet.
+
+
+ {KEYBOARD_CATALOG.map((k) => (
+
+
+ {k.name}
+
+
+
{k.desc}
+
+ Usage : {k.usage}
+
+
+ ))}
+
+
+
+ {/* AUTOCOMPLETE */}
+
+ Aide à la saisie (autocomplete)
+
+ L'attribut autocomplete 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.
+
+
+ {AUTOCOMPLETE_CATALOG.map((a) => (
+
+ {a.name}
+ {a.usage}
+
+ ))}
+
+
+
+ {/* ENTER KEY HINT */}
+
+ Touche Entrée — enterKeyHint
+
+ La touche en bas à droite du clavier peut afficher un mot différent selon le contexte (au lieu du standard "Entrée").
+
+
+ {ENTER_HINT_CATALOG.map((e) => (
+
+ ))}
+
+
+
+ {/* CHEAT SHEET */}
+
+ Antisèche · combinaisons utiles
+
+
+ Email
+ keyboard="email" + autocomplete="email" + autocapitalize="off"
+
+
+ Mot de passe
+ type="password" + autocomplete="current-password" (ou "new-password" en inscription)
+
+
+ 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
+ autocomplete="address-line1", puis postal-code, country
+
+
+ Texte libre
+ autocapitalize="sentences" + spellCheck=true
+
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {tab === 'login' && setTab('profile')} showToast={showToast} />}
+ {tab === 'profile' && setSheet(true)} />}
+ {tab === 'form' && }
+ {tab === 'swipe' && }
+
+
+
+
+
setSheet(false)} title="Paramètres rapides">
+
+ {}} />
+ {}} />} />
+ {}} />} />
+
+
+ { setSheet(false); setTab('login'); }} />
+
+
+
+
setToast(null)} message={toast} variant="ok" />
+
+ );
+}
+
+function App() {
+ const [theme, setTheme] = uDS('dark');
+ const [device, setDevice] = uDS('ios');
+ eDS(() => { document.documentElement.dataset.theme = theme; }, [theme]);
+
+ return (
+
+
+
+
+
+ Exemple mobile · saisie login · profil · form · swipe · clavier virtuel
+
+ exemple mobile
+
+
+
+
+
+
+ setDevice('ios')}>iOS
+ setDevice('android')}>Android
+
+
+ setTheme('dark')}>Sombre
+ setTheme('light')}>Clair
+
+
+
+ {device === 'ios' &&
}
+
+
+
↑ teste les écrans, swipe les lignes, joue avec les formulaires
+
+
+
+
+
+ );
+}
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render( );
diff --git a/design_system/package-smartphone/examples/exemple-mobile-saisie.html b/design_system/package-smartphone/examples/exemple-mobile-saisie.html
new file mode 100644
index 0000000..4480ab9
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile-saisie.html
@@ -0,0 +1,162 @@
+
+
+
+
+ Exemple mobile — saisie & formulaires
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design_system/package-smartphone/examples/exemple-mobile.html b/design_system/package-smartphone/examples/exemple-mobile.html
new file mode 100644
index 0000000..d32a7d4
--- /dev/null
+++ b/design_system/package-smartphone/examples/exemple-mobile.html
@@ -0,0 +1,952 @@
+
+
+
+
+ Exemple mobile — mon design system
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design_system/package-smartphone/examples/mobile-apps.jsx b/design_system/package-smartphone/examples/mobile-apps.jsx
new file mode 100644
index 0000000..7428f02
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-apps.jsx
@@ -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 (
+ {initials}
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
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',
+ }}>
+
+
+
+
{name}
+ {email &&
{email}
}
+
+
+ {items.map((it, i) => (
+
{ 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',
+ }}>
+
+ {it.label}
+ {!it.danger && }
+
+ ))}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ Passer
+
+
+
+
+
+
+
{slides[i].title}
+
{slides[i].desc}
+
+
+
+ {slides.map((_, j) => (
+ 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',
+ }} />
+ ))}
+
+
isLast ? onFinish() : setI(i + 1)}>
+ {isLast ? 'Commencer' : 'Suivant'}
+
+
+
+ );
+}
+
+/* ============================================================
+ ChatBubble — bulle de message (envoyé/reçu)
+ Nom système : ChatBubble
+ ============================================================ */
+function ChatBubble({ text, time, me, status }) {
+ return (
+
+
+
{text}
+
+ {time}
+ {me && status === 'sent' && ✓ }
+ {me && status === 'read' && ✓✓ }
+
+
+
+ );
+}
+
+/* ============================================================
+ ChatComposer — barre d'envoi en bas (input + + + send)
+ Nom système : ChatComposer
+ ============================================================ */
+function ChatComposer({ onSend }) {
+ const [v, setV] = uA('');
+ return (
+
+
+
+ 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,
+ }} />
+
+ {v ? (
+
{ 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',
+ }}>
+ ) : (
+
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ {['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
+
{d}
+ ))}
+ {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 (
+
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 && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+/* ============================================================
+ MapView — placeholder visuel d'une carte avec pins
+ Nom système : MapView
+ ============================================================ */
+function MapView({ pins = [] }) {
+ return (
+
+ {/* fond carte stylisé */}
+
+
+
+
+
+
+
+ {/* routes */}
+
+
+
+ {/* zones */}
+
+
+
+ {/* fleuve */}
+
+
+ {/* pins */}
+ {pins.map((p, i) => (
+
+
+
+
+ {p.label && (
+
{p.label}
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {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 (
+ 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 && }
+ {l}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ QrScannerView — viseur scanner code-barres / QR
+ Nom système : QrScannerView
+ ============================================================ */
+function QrScannerView({ onCapture }) {
+ return (
+
+ {/* fake camera feed = grain animé */}
+
+ {/* visée centrale */}
+
+ {/* 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) => (
+
+ ))}
+ {/* ligne scan animée */}
+
+
+
+ {/* overlay assombri hors visée */}
+
+ {/* texte */}
+
Pointe vers un QR code ou code-barres
+ {/* boutons bas */}
+
+
+ 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',
+ }}>
+
+
+
+ );
+}
+
+/* ============================================================
+ CameraView — viseur appareil photo avec shutter rond
+ Nom système : CameraView
+ ============================================================ */
+function CameraView({ onShoot }) {
+ return (
+
+ {/* fake scene */}
+
+ {/* règle des tiers */}
+
+ {[33.33, 66.66].map((p) => (
+
+
+
+
+ ))}
+
+ {/* top bar */}
+
+ {[
+ { icon: 'moon', label: 'Flash' },
+ { icon: 'clock', label: 'Minuteur' },
+ { icon: 'grid', label: 'Grille' },
+ ].map((b) => (
+
+ ))}
+
+ {/* mode chips */}
+
+ Vidéo
+ Photo
+ Portrait
+
+ {/* bottom controls */}
+
+
+
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',
+ }}/>
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {items.map((it) => (
+
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) },
+ ]}>
+
+
+
+
+
+
{it.name}
+
+ {it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
+
+
+ {it.type === 'folder' &&
}
+
+
+ ))}
+
+ );
+}
+
+Object.assign(window, {
+ Avatar, AvatarMenu,
+ OnboardingSlider,
+ ChatBubble, ChatComposer,
+ CalendarMonth,
+ MapView,
+ FilterChips,
+ QrScannerView, CameraView,
+ FileExplorer,
+});
diff --git a/design_system/package-smartphone/examples/mobile-forms.jsx b/design_system/package-smartphone/examples/mobile-forms.jsx
new file mode 100644
index 0000000..50dc442
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-forms.jsx
@@ -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 (
+
+ {label && (
+
+ {label}{required && * }
+
+ )}
+ {children}
+ {(error || hint) && (
+
{error || hint}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+
+ {trailing}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ 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',
+ }}
+ />
+
+ );
+}
+
+/* ============================================================
+ Dropdown — select natif stylisé
+ Nom système : Dropdown
+ ============================================================ */
+function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
+ return (
+
+ 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,
+ }}>
+ {placeholder}
+ {options.map((o) => (
+ typeof o === 'string'
+ ? {o}
+ : {o.label}
+ ))}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {checked && }
+
+
+
{label}
+ {description &&
{description}
}
+
+ onChange(e.target.checked)} style={{ display: 'none' }} />
+
+ );
+}
+
+/* ============================================================
+ RadioGroup — groupe d'options exclusives
+ Nom système : RadioGroup
+ ============================================================ */
+function RadioGroup({ value, onChange, options }) {
+ return (
+
+ );
+}
+
+/* ============================================================
+ 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
+ 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 (
+
+ {items.map((it) => (
+
+
+ {it.label}
+ {it.hint}
+ {!it.special && (
+ onPick && onPick(it.id, e.target.files[0])}
+ style={{ display: 'none' }} />
+ )}
+ {it.special && (
+ {
+ 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' }} />
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ AvatarLogo — gros logo rond pour écran de connexion
+ Nom système : AvatarLogo
+ ============================================================ */
+function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
+ return (
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {lbl}
+
+ );
+}
+
+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 });
diff --git a/design_system/package-smartphone/examples/mobile-gestures.jsx b/design_system/package-smartphone/examples/mobile-gestures.jsx
new file mode 100644
index 0000000..2d5b613
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-gestures.jsx
@@ -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 (
+
+ {label && (
+
{label}
+ )}
+
+ {/* indicateur central */}
+
+ {last ? (
+
+ {last.name}
+ {last.data && (
+
+ {Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
+
+ )}
+
+ ) : (
+
essaie un geste ici
+ )}
+
+
+ {/* trail visuel pendant le pan */}
+ {trail && (
+
+ )}
+
+ {/* Journal */}
+ {log.length > 0 && (
+
+
+ journal
+ {log.length} dernier{log.length > 1 ? 's' : ''}
+
+ {log.map((l, i) => (
+
+ {l.t} {' '}
+ {l.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* 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 });
diff --git a/design_system/package-smartphone/examples/mobile-kit.jsx b/design_system/package-smartphone/examples/mobile-kit.jsx
new file mode 100644
index 0000000..f0428b8
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-kit.jsx
@@ -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 (
+
+ {time}
+
+ {/* signal bars */}
+
+ {[1, 2, 3, 4].map((b) => (
+
+ ))}
+
+
+ {/* battery */}
+
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+ {onBack && (
+
+
+
+ )}
+
+ {!large && (
+
+ {title}
+
+ )}
+
+ {right &&
{right}
}
+
+ {large && (
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {items.map((it) => {
+ const isActive = active === it.id;
+ return (
+ 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)',
+ }}>
+
+ {it.label}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+ e.currentTarget.style.background = 'var(--bg-3)' : undefined}
+ onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
+ {icon && (
+
+
+
+ )}
+ {label}
+ {value && {value} }
+ {right === undefined && onClick && }
+ {right}
+
+ );
+}
+
+/* ============================================================
+ ListSection — groupe de ListRow avec titre
+ Nom système : ListSection
+ ============================================================ */
+function ListSection({ title, hint, children }) {
+ return (
+
+ {title && (
+
{title}
+ )}
+
{children}
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ {title}
+ {subtitle && {subtitle} }
+ {value && (
+ {value}
+ )}
+ {badge && (
+ {badge}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+ {children}
+
+ );
+}
+
+/* ============================================================
+ SegmentedControl — sélecteur segmenté iOS-style
+ Nom système : SegmentedControl
+ Usage : 2-4 options exclusives, jamais plus.
+ ============================================================ */
+function SegmentedControl({ value, onChange, options }) {
+ return (
+
+ {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 (
+ 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 && }
+ {l}
+
+ );
+ })}
+
+ );
+}
+
+/* ============================================================
+ SearchBar — champ de recherche mobile
+ Nom système : SearchBar
+ ============================================================ */
+function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
+ return (
+
+
+ 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 && (
+ 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,
+ }}>
+ )}
+
+ );
+}
+
+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);
+})();
diff --git a/design_system/package-smartphone/examples/mobile-sheets.jsx b/design_system/package-smartphone/examples/mobile-sheets.jsx
new file mode 100644
index 0000000..3abe582
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-sheets.jsx
@@ -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 (
+
+
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 */}
+
+ {title && (
+
+ )}
+
{children}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
e.stopPropagation()} style={{
+ width: '100%',
+ display: 'flex', flexDirection: 'column', gap: 8,
+ animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
+ }}>
+
+ {title && (
+
{title}
+ )}
+ {actions.map((a, i) => (
+
{ 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 && }
+ {a.label}
+
+ ))}
+
+
{cancelLabel}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ {icon && (
+
+
+
+ )}
+
{title}
+ {message &&
{message}
}
+
+
+ {actions.map((a, i) => (
+ { 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}
+ ))}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ {message}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {/* indicateur */}
+
+
{children}
+
+ );
+}
+
+Object.assign(window, {
+ BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
+});
diff --git a/design_system/package-smartphone/examples/mobile-swipeable.jsx b/design_system/package-smartphone/examples/mobile-swipeable.jsx
new file mode 100644
index 0000000..6037d1f
--- /dev/null
+++ b/design_system/package-smartphone/examples/mobile-swipeable.jsx
@@ -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 (
+
+ {/* Actions à GAUCHE (révélées par swipe droit) */}
+ {rightActions.length > 0 && (
+
+ {rightActions.map((a, i) => (
+ 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 && }
+ {a.label}
+
+ ))}
+
+ )}
+ {/* Actions à DROITE (révélées par swipe gauche) */}
+ {leftActions.length > 0 && (
+
+ {leftActions.map((a, i) => (
+ 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 && }
+ {a.label}
+
+ ))}
+
+ )}
+ {/* Ligne déplaçable */}
+
+ {children}
+
+
+ );
+}
+
+Object.assign(window, { SwipeableRow });
diff --git a/design_system/package-smartphone/examples/ui-kit.jsx b/design_system/package-smartphone/examples/ui-kit.jsx
new file mode 100644
index 0000000..92f9e76
--- /dev/null
+++ b/design_system/package-smartphone/examples/ui-kit.jsx
@@ -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 . 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 (
+
+ );
+};
+
+/* ============================================================
+ 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 (
+
+ {children}
+ {show && (
+ {label}
+ )}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ Toggle on/off — switch tactile avec glow accent quand ON
+ ============================================================ */
+function Toggle({ on, onChange, label, icon }) {
+ return (
+
+ {icon && }
+ {label && {label} }
+ 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,
+ }}>
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 && (
+
+ )}
+
+ >
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {(icon || label) && (
+
+ {icon && }
+ {label && {label} }
+
+ )}
+
+
+ {value}{unit}
+
+
+ );
+ }
+
+ return (
+
+ {label && (
+
+ {label}
+
+ {value}{unit}
+
+
+ )}
+
+
+ {/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+
+
+ {/* arc background */}
+
+ {/* arc value glow */}
+
+ {/* arc value crisp */}
+
+
+
+
+ {value}%
+
+ {label &&
{label}
}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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 ;
+ })}
+ {[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 {v} ;
+ })}
+ {/* arc bg */}
+
+ {/* arc value glow */}
+
+ {/* arc value */}
+
+ {/* needle */}
+
+
+
+
+
+
+ );
+}
+
+/* ============================================================
+ Popup — modale glassmorphism centrée + bouton fermer
+ ============================================================ */
+function Popup({ open, onClose, title, children, footer, width = 460 }) {
+ if (!open) return null;
+ return (
+
+
+
e.stopPropagation()} style={{
+ width, maxWidth: '90%',
+ borderRadius: 12,
+ boxShadow: 'var(--shadow-3)',
+ animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
+ overflow: 'hidden',
+ }}>
+
+
{children}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {icon && }
+ {children}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {groups.map(g => (
+
+
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',
+ }}>
+
+
+ {g.label}
+ {g.count != null && (
+
+ {g.count}
+
+ )}
+
+ {open[g.id] && (
+
+ {g.children.map(c => {
+ const active = c.id === activeId;
+ return (
+
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,
+ }}>
+
+ {c.label}
+ {c.meta && {c.meta} }
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+
+
+
+ );
+}
+
+/* ============================================================
+ 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 (
+
+ {/* 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 (
+
+
+ {v}
+
+ );
+ })}
+ {/* labels x */}
+ {labels && labels.map((lb, i) => (
+ i % Math.ceil(labels.length / 8) === 0 && (
+ {lb}
+ )
+ ))}
+ {/* 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 (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+/* 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);
+})();
diff --git a/design_system/package-smartphone/tokens/tokens.css b/design_system/package-smartphone/tokens/tokens.css
new file mode 100644
index 0000000..348bfe2
--- /dev/null
+++ b/design_system/package-smartphone/tokens/tokens.css
@@ -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); }
diff --git a/design_system/package-smartphone/tokens/tokens.gnome.css b/design_system/package-smartphone/tokens/tokens.gnome.css
new file mode 100644
index 0000000..390b806
--- /dev/null
+++ b/design_system/package-smartphone/tokens/tokens.gnome.css
@@ -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
+ 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
+*/
diff --git a/design_system/package-smartphone/tokens/tokens.json b/design_system/package-smartphone/tokens/tokens.json
new file mode 100644
index 0000000..915e0bf
--- /dev/null
+++ b/design_system/package-smartphone/tokens/tokens.json
@@ -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)"
+ }
+}