init: structure initiale du projet esp_jardin
Spec, plan d'implémentation, design system, documentation de déploiement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.pio
|
||||
.superpowers/
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
@@ -0,0 +1,99 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. tu t'exprimera uniquement en francais, les page web et les commentaire seront en francais
|
||||
brainstorming + recherche web -> plan.md et feature.md
|
||||
ecrire un fichier readme.md de deploiement
|
||||
|
||||
## Project
|
||||
|
||||
**esp_jardin** — ESP32 firmware for a garden environmental monitoring station. Acquires temperatures from 3 DS18B20 sensors on OneWire (GPIO 4), stores 24h rolling history in RAM, and exposes a real-time web interface (WebSocket) and a REST API.
|
||||
|
||||
Full specifications: [`Fichier de Consignes - esp_jardin.md`](Fichier\ de\ Consignes\ -\ esp_jardin.md)
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
pio run # Compile only
|
||||
pio run -t upload # Compile + flash via USB
|
||||
pio device monitor # Open serial monitor (115200 baud)
|
||||
pio run -t upload && pio device monitor # Flash then monitor
|
||||
pio run -t clean # Clean build artifacts
|
||||
```
|
||||
|
||||
Environment: `esp32dev` (Espressif32, Arduino framework) — defined in [platformio.ini](platformio.ini).
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The project must be **modular** — no logic in `main.cpp` beyond initialisation and the state machine loop.
|
||||
|
||||
```
|
||||
include/
|
||||
config.h # Constants and structs derived from parametrage.md
|
||||
network.h # WiFi (STA/AP hybrid), mDNS, OTA, WebSockets
|
||||
sensors.h # OneWire, DallasTemperature, circular RAM buffer
|
||||
web_server.h # HTTP routing, REST API declarations
|
||||
mqtt_manager.h # MQTT client declarations
|
||||
src/
|
||||
main.cpp # setup(), loop() — state machine only, no blocking
|
||||
network.cpp
|
||||
sensors.cpp
|
||||
web_server.cpp
|
||||
mqtt_manager.cpp
|
||||
data/
|
||||
index.html # Served from SPIFFS/LittleFS
|
||||
design_system/ # CSS tokens, React UI-kit (read-only reference)
|
||||
```
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
**No blocking code** — `delay()` is forbidden. Use `millis()` for all timing.
|
||||
|
||||
**Temperature handling:**
|
||||
- Format to 1 decimal place (`%.1f`)
|
||||
- Discard sensor errors: −127.0°C (CRC fail) and 85.0°C (power-on default)
|
||||
- History: circular buffer of 288 points/sensor (uint32_t timestamp + 2× float = 12 bytes each)
|
||||
|
||||
**WiFi:** Boot in STA mode; fall back to AP (`ESP_CHEF_JARDIN`) after 30s timeout. Reconnect loop must never block the main loop.
|
||||
|
||||
**WebSocket:** Push new readings as JSON to all connected clients immediately after acquisition. No HTTP polling for real-time data.
|
||||
|
||||
**MQTT per-sensor:** Each sensor publishes independently with deadband filtering (skip publish if delta < threshold AND max interval not elapsed).
|
||||
|
||||
**REST endpoints:**
|
||||
- `GET /api/status` — WiFi RSSI, uptime, free RAM
|
||||
- `GET /api/temperatures` — current readings as JSON
|
||||
- `GET /api/history` — full 24h buffer as JSON array
|
||||
|
||||
## GPIO Allocation
|
||||
|
||||
| GPIO | Function | Notes |
|
||||
|------|----------|-------|
|
||||
| 4 | OneWire bus (DS18B20) | 4.7kΩ pull-up to 3.3V required |
|
||||
| 21 | I2C SDA (future) | BH1750 / SHT31 |
|
||||
| 22 | I2C SCL (future) | |
|
||||
| 32/33 | ADC1 (future) | Soil moisture — ADC1 only (ADC2 conflicts with WiFi) |
|
||||
| 25/26 | Digital out (future) | Relays / actuators |
|
||||
| 14/27 | Interrupts (future) | Rain gauge / push button |
|
||||
|
||||
## Web Interface — Design System
|
||||
|
||||
The web UI must use the **Gruvbox Seventies** design system from [`design_system/`](design_system/). Full rules in [`design_system/consigne_design_system.md`](design_system/consigne_design_system.md).
|
||||
|
||||
**Absolute rules:**
|
||||
- Always use CSS variables — never hardcode hex colors (`var(--accent)` not `#fe8019`)
|
||||
- Always declare `data-theme="dark"` (or `"light"`) on `<html>` or a parent wrapper
|
||||
- Use existing components from `ui-kit.jsx` — check before creating anything new
|
||||
- Icons: use `<Icon name="…">` with mapped names — no emoji, no inline SVG
|
||||
- Fonts: `--font-ui` (Inter) for UI, `--font-mono` (JetBrains Mono) for numeric data, `--font-terminal` (Share Tech Mono) for logs
|
||||
- Tiles: `border-radius: 10–12px`, use `className="glass"` for styling
|
||||
- Labels: uppercase + `letter-spacing: 0.08em`
|
||||
|
||||
**For temperature display:** `<LineChart>` for 24h history, `<RadialGauge>` or `<BatteryGauge>` for live KPIs, `<StatusLed status="err" pulse />` for sensor faults.
|
||||
|
||||
Chart.js (or equivalent via CDN) initialises from `/api/history` then increments via WebSocket messages.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Comments in French (project convention from specs)
|
||||
- All configurations and default values sourced from `parametrage.md` → reflected in `config.h`
|
||||
- OTA upload protected with a password
|
||||
@@ -0,0 +1,167 @@
|
||||
# **CAHIER DES CHARGES & CONSIGNES DE DÉVELOPPEMENT : ESP\_JARDIN**
|
||||
|
||||
Ce document sert de prompt maître (Master Prompt) et de cahier des charges technique pour guider le développement de l'application embarquée esp\_jardin.
|
||||
|
||||
## **1\. Contexte, Vision & Objectifs**
|
||||
|
||||
L'objectif est de concevoir le firmware d'une station d'acquisition environnementale autonome basée sur un microcontrôleur **ESP32**, nommée **esp\_jardin**.
|
||||
Cet appareil doit acquérir les températures de trois sondes DS18B20, conserver un historique glissant de 24 heures en RAM, et proposer une double interface d'accès aux données :
|
||||
|
||||
1. Une **interface web locale moderne, responsive et en temps réel** (via WebSockets), accessible depuis les ordinateurs et smartphones.
|
||||
2. Une **API REST ultra-rapide** pour l'intégration automatique (ex: serveurs MCP, scripts d'automation, Home Assistant).
|
||||
|
||||
Le projet est développé sous **VS Code avec l'extension PlatformIO** en s'appuyant sur le **framework Arduino (C++)**, jugé optimal pour l'écosystème de librairies asynchrones requis.
|
||||
|
||||
tu debutera par une phase de recherche sur internet, suivi d’un brainstorming avec question sur les lib a utiliser ( avantage inconvenient) , les sondes a utiliser, le cablage, les evolution futures du projet). ensuite tu mettre a jours le dossier du projet en respectant l’architecture de plateformio, tu mettra a jours le parametrage de platformio pour la carte utilisée, ensuite tu creera le plan de deploiement. pense bien a faire l’analyse du design\_system. verifier la coherence de ce fichier de consigne avec les bonnes pratiques et mettre ajours si besoin.
|
||||
|
||||
## **2\. Phase 1 : Analyse Matérielle, GPIO & Évolutivité**
|
||||
|
||||
L'ESP32 possède des caractéristiques électriques et de boot strictes (Strapping Pins, Input-only pins, restrictions d'usage de l'ADC2 avec le WiFi). La sélection des GPIO pour les sondes actuelles et les extensions futures a été analysée de manière à préserver la stabilité du système.
|
||||
|
||||
### **Tableau d'allocation et de mapping des GPIO**
|
||||
|
||||
| GPIO Natif (ESP32) | Broche Standard (DevKit V1) | Broche alternative (Notation Arduino) | Fonction Actuelle | Type & Caractéristiques | Évolutivité / Usage Futur Prévu |
|
||||
| :---- | :---- | :---- | :---- | :---- | :---- |
|
||||
| **GPIO 4** | D4 | 4 | **Bus OneWire (DS18B20)** | Numérique I/O | Supporte l'ajout en parallèle d'autres sondes DS18B20. |
|
||||
| **GPIO 21** | D21 | 21 | *Libre* | I/O (Bus I2C SDA) | Capteur de luminosité (BH1750) ou humidité/T° d'air (SHT31). |
|
||||
| **GPIO 22** | D22 | 22 | *Libre* | I/O (Bus I2C SCL) | Horloge RTC externe ou capteurs I2C partagés. |
|
||||
| **GPIO 32** | D32 | 32 | *Libre* | ADC1\_CH4 (Analogique) | Capteur d'humidité de sol (capacitif ou résistif). |
|
||||
| **GPIO 33** | D33 | 33 | *Libre* | ADC1\_CH5 (Analogique) | Second capteur d'humidité ou entrée analogique. |
|
||||
| **GPIO 25** | D25 | 25 | *Libre* | Numérique I/O / DAC | Commande d'actionneur / Relais (ex : Électrovanne arrosage). |
|
||||
| **GPIO 26** | D26 | 26 | *Libre* | Numérique I/O / DAC | Commande d'actionneur 2 / Relais ou avertisseur sonore. |
|
||||
| **GPIO 14** | D14 | 14 | *Libre* | Numérique (Interruption) | Capteur d'impulsions (ex: Pluviomètre à augets). |
|
||||
| **GPIO 27** | D27 | 27 | *Libre* | Numérique (Interruption) | Bouton poussoir physique (ex: Reset paramètres ou forçage AP). |
|
||||
|
||||
*Note sur le bus OneWire : Une résistance de pull-up externe de 4.7kΩ est obligatoire entre le VCC (3.3V) et la broche de données (GPIO 4).*
|
||||
|
||||
## **3\. Architecture Logicielle & Arborescence PlatformIO**
|
||||
|
||||
L'architecture du projet doit être modulaire (fichiers .h et .cpp séparés) pour éviter de surcharger le point d'entrée principal.
|
||||
esp\_jardin/
|
||||
├── platformio.ini \# Configuration PlatformIO et dépendances bibliothèques
|
||||
├── parametrage.md \# Fichier source des configurations par défaut (SSID, Topics, Intervalles...)
|
||||
├── include/
|
||||
│ ├── config.h \# Généré d'après parametrage.md (Structures, constantes globales)
|
||||
│ ├── network.h \# Déclarations WiFi (AP/STA), mDNS, OTA, WebSockets
|
||||
│ ├── sensors.h \# Déclarations OneWire, DallasTemperature et gestion d'historique RAM
|
||||
│ ├── web\_server.h \# Déclarations serveur HTTP, API REST
|
||||
│ └── mqtt\_manager.h \# Déclarations client MQTT, logique de publication
|
||||
├── src/
|
||||
│ ├── main.cpp \# Initialisation, machine à états et loop non bloquante
|
||||
│ ├── network.cpp \# Gestion WiFi hybride, OTA, WebSockets
|
||||
│ ├── sensors.cpp \# Acquisition T°C, validation métrologique, tampon circulaire RAM
|
||||
│ ├── web\_server.cpp \# Routage endpoints REST, parsing JSON, service des pages web
|
||||
│ └── mqtt\_manager.cpp \# Routage des messages MQTT, asynchronisme
|
||||
├── data/ \# Fichiers optionnels pour SPIFFS/LittleFS (ex: CSS, JS, images)
|
||||
│ └── index.html \# Page web dynamique responsive principale
|
||||
└── design\_system/ \# Maquettes, styles CSS et frameworks JS fournis par le designer
|
||||
└── ...
|
||||
|
||||
## **4\. Source de Vérité Unique : parametrage.md**
|
||||
|
||||
Toutes les configurations et constantes par défaut de l'application devront être implémentées d'après les valeurs suivantes, écrites dans parametrage.md à la racine :
|
||||
\# Paramétrage Initial \- esp\_jardin
|
||||
|
||||
\#\# Connexion WiFi
|
||||
\- Mode Station (STA) :
|
||||
\- SSID: "Mon\_Reseau\_WiFi"
|
||||
\- PASS: "Mon\_Mot\_De\_Passe\_Securise"
|
||||
\- Mode Access Point (AP de secours) :
|
||||
\- AP\_SSID: "ESP\_CHEF\_JARDIN"
|
||||
\- AP\_PASS: "Jardin2026"
|
||||
\- Connection\_Timeout: 30000 ms (30 secondes)
|
||||
|
||||
\#\# Acquisition & Fréquences
|
||||
\- Fréquence de mesure (échantillonnage de base) : 10 secondes (10000 ms)
|
||||
\- Taille de l'historique en RAM : 288 points par sonde (correspond à 24h avec 1 mesure échantillonnée toutes les 5 min pour le graphique).
|
||||
|
||||
\#\# Broker MQTT
|
||||
\- IP Broker: "10.0.0.3"
|
||||
\- Port: 1883
|
||||
\- MQTT\_User: ""
|
||||
\- MQTT\_Pass: ""
|
||||
|
||||
\#\# Paramètres Spécifiques par Capteur (MQTT Custom)
|
||||
\- Sonde 1 (Index 0 \- ex : Température Exterieur) :
|
||||
\- Nom : "T°C Ext"
|
||||
\- Topic: "maison/jardin/ext/temperature"
|
||||
\- Intervalle de publication : 60000 ms (1 minute)
|
||||
\- Variation minimale requise (Deadband) : 0.2 °C
|
||||
\- Sonde 2 (Index 1 \- ex : Température Serre) :
|
||||
\- Nom : "T°C Serre"
|
||||
\- Topic: "maison/jardin/serre/temperature"
|
||||
\- Intervalle de publication : 60000 ms (1 minute)
|
||||
\- Variation minimale requise (Deadband) : 0.1 °C
|
||||
\- Sonde 3 (Index 1 \- ex : Température du Sol) :
|
||||
\- Nom : "T°C Sol"
|
||||
\- Topic: "maison/jardin/sol/temperature"
|
||||
\- Intervalle de publication : 60000 ms (1 minute)
|
||||
\- Variation minimale requise (Deadband) : 0.1 °C
|
||||
|
||||
## **5\. Spécifications Fonctionnelles Détaillées**
|
||||
|
||||
### **5.1. Gestion de la connectivité réseau**
|
||||
|
||||
1. **WiFi Hybride non-bloquant :** Au boot, l'ESP tente de se connecter en mode STA avec les identifiants fournis. S'il échoue après un temps d'attente spécifié, il initialise un point d'accès autonome (AP) nommé ESP\_CHEF\_JARDIN.
|
||||
2. **mDNS :** L'appareil s'enregistre sur le réseau local. L'interface d'administration et les API doivent être joignables à l'adresse DNS multicast http://esp\_jardin.local.
|
||||
3. **Mise à jour OTA :** Intégration de la bibliothèque ArduinoOTA avec mot de passe pour téléverser de nouveaux binaires à distance sans liaison physique USB.
|
||||
|
||||
### **5.2. Acquisition, Traitement RAM & Validation Métrologique**
|
||||
|
||||
1. **Précision numérique :** Les relevés de température doivent être formatés avec rigueur à **un seul chiffre après la virgule** (type float traité ou formaté via %.1f).
|
||||
2. **Tableau Circulaire en RAM (Historique 24h) :**
|
||||
* L'historique des 24 heures glissantes doit utiliser un tableau circulaire (CircularBuffer ou logique d'indexation fixe dans un tableau de 288 structures par sonde) pour s'affranchir de l'usure de la mémoire Flash.
|
||||
* *Validation mémoire :* Chaque élément stockant le timestamp (uint32\_t) \+ les deux températures (2x float) représente 12 octets. 288 mesures consomment \~3.45 Ko, ce qui est extrêmement sûr pour les 520 Ko de SRAM de l'ESP32 (impact \< 1%).
|
||||
3. **Robustesse matérielle :** Gestion des pannes de sondes Dallas (vérification des CRC et interception des codes d'erreur matériels types \-127.0 ou 85.0). Ces valeurs invalides ne doivent pas être écrites en RAM ni envoyées au broker MQTT.
|
||||
|
||||
### **5.3. Interface Web Interactive (Responsive & WebSocket)**
|
||||
|
||||
1. **WebSocket Temps Réel :** Dès qu'une nouvelle mesure est validée sur le bus OneWire, la valeur rafraîchie est sérialisée en JSON et poussée instantanément sur tous les clients connectés par WebSocket. Le polling HTTP est proscrit pour le temps réel.
|
||||
2. **Design System & Adaptabilité :**
|
||||
* L'interface doit s'appuyer sur la charte et les classes CSS du dossier design\_system.
|
||||
* L'application Web doit être fluide et ergonomique sur les écrans étroits (smartphones) comme sur les écrans larges (moniteurs / laptops).
|
||||
3. **Graphique Glissant :** Intégration d'un graphique (Chart.js ou équivalent via un CDN optimisé) représentant les 24h de mesures stockées en RAM. À l'ouverture, le graphique est initialisé avec l'historique complet (requête REST API), puis s'incrémente au fil de l'eau via les réceptions WebSocket.
|
||||
4. **Console d'Administration :** Intégration d'un formulaire pour ajuster dynamiquement l'intervalle d'échantillonnage et les configurations des serveurs MQTT.
|
||||
|
||||
### **5.4. API REST Standardisée**
|
||||
|
||||
Créer des endpoints de requêtes HTTP GET pour la lecture directe et l'intégration externe (scripts automatisés, serveurs de protocoles MCP) :
|
||||
|
||||
* **GET /api/status :** Renvoie l'état système (RSSI WiFi, Uptime, Mémoire RAM libre).
|
||||
* **GET /api/temperatures :** Renvoie l'état instantané au format standardisé :
|
||||
{"sonde\_1": 19.3, "sonde\_2": 11.8, "unit": "C"}.
|
||||
* **GET /api/history :** Renvoie l'historique complet sous forme de tableau d'objets JSON pour reconstruire la courbe sur des serveurs tiers.
|
||||
|
||||
### **5.5. Publication MQTT Avancée**
|
||||
|
||||
Chaque capteur doit être géré de manière indépendante pour sa transmission :
|
||||
|
||||
1. **Cycles asynchrones :** La publication s'effectue à l'aide d'une tâche temporelle non bloquante (utilisation stricte de millis()).
|
||||
2. **Filtrage "Deadband" :** Si la différence absolue entre la température actuelle et la dernière valeur publiée est inférieure au seuil de variation (ex : 0.2°C pour la sonde 1), la publication est ignorée pour limiter la saturation de la bande passante, sauf si l'intervalle maximal de secours est dépassé.
|
||||
|
||||
## **6\. Plan de Déploiement, Validation & Tests (Livrables attendus)**
|
||||
|
||||
### **Étape A : Initialisation (Rapport d'analyse d'entrée)**
|
||||
|
||||
L'agent doit formaliser l'architecture du projet et les fichiers platformio.ini et parametrage.md initiaux.
|
||||
|
||||
### **Étape B : Production du Code Source Modulaire**
|
||||
|
||||
Production des fichiers de code .h et .cpp sans **aucun blocage** de type delay(). L'agent doit commenter le code en français de manière didactique.
|
||||
|
||||
### **Étape C : README de déploiement et d'utilisation**
|
||||
|
||||
Rédaction d'un fichier README.md complet intégrant :
|
||||
|
||||
1. Schéma électrique simplifié de connexion (rappel de la résistance pull-up 4.7kΩ).
|
||||
2. Directives de compilation et d'écriture de la configuration.
|
||||
3. Guide de première connexion au point d'accès de secours ESP\_CHEF\_JARDIN et configuration.
|
||||
4. Schémas de données pour l'API REST.
|
||||
|
||||
### **Étape D : Protocole de validation de robustesse**
|
||||
|
||||
Section expliquant comment tester :
|
||||
|
||||
1. La déconnexion sauvage du WiFi (le programme doit tenter une reconnexion automatique en boucle sans bloquer la boucle principale).
|
||||
2. La déconnexion d'une des sondes physiques (affichage d'un code erreur visuel sur l'interface au lieu d'une fausse température de \-127°C).
|
||||
3. L'indisponibilité temporaire du broker MQTT (mise en file d'attente ou rejet propre sans faire planter le microcontrôleur).
|
||||
@@ -0,0 +1,185 @@
|
||||
# ESP Jardin — Station de monitoring environnemental
|
||||
|
||||
Firmware ESP32 pour une station d'acquisition de températures avec interface web temps réel, API REST et publication MQTT.
|
||||
|
||||
---
|
||||
|
||||
## Matériel requis
|
||||
|
||||
| Composant | Quantité | Notes |
|
||||
|---|---|---|
|
||||
| ESP32 DevKit V1 | 1 | ou équivalent 38 broches |
|
||||
| Sonde DS18B20 | 3 | waterproof recommandé |
|
||||
| Résistance 4.7 kΩ | 1 | pull-up obligatoire |
|
||||
| Câbles Dupont | — | |
|
||||
|
||||
### Câblage
|
||||
|
||||
```
|
||||
DS18B20 (×3 en parallèle)
|
||||
VCC → 3.3V
|
||||
GND → GND
|
||||
DATA → GPIO 4
|
||||
|
||||
Résistance 4.7 kΩ entre 3.3V et GPIO 4 (pull-up)
|
||||
```
|
||||
|
||||
> **Important :** Sans la résistance pull-up, les sondes retournent systématiquement −127°C.
|
||||
|
||||
---
|
||||
|
||||
## Prérequis logiciels
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + extension [PlatformIO](https://platformio.org/)
|
||||
- Python 3 (requis par PlatformIO)
|
||||
|
||||
---
|
||||
|
||||
## Installation et premier flash
|
||||
|
||||
### 1. Configurer le WiFi
|
||||
|
||||
Éditer `include/config.h` :
|
||||
|
||||
```cpp
|
||||
#define WIFI_SSID "VotreSSID"
|
||||
#define WIFI_PASS "VotreMotDePasse"
|
||||
```
|
||||
|
||||
### 2. Configurer le broker MQTT (optionnel)
|
||||
|
||||
Dans `include/config.h` :
|
||||
|
||||
```cpp
|
||||
#define MQTT_BROKER "192.168.1.x" // IP du broker Mosquitto
|
||||
#define MQTT_PORT 1883
|
||||
```
|
||||
|
||||
### 3. Compiler et flasher
|
||||
|
||||
```bash
|
||||
# Flash du firmware
|
||||
pio run -t upload
|
||||
|
||||
# Flash de l'interface web (LittleFS)
|
||||
pio run -t uploadfs
|
||||
|
||||
# Moniteur série (débogage)
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
### 4. Vérifier le boot
|
||||
|
||||
```
|
||||
[BOOT] esp_jardin v1.0 — démarrage...
|
||||
[WIFI] Connexion STA → SSID: VotreSSID
|
||||
[WIFI] Connecté — IP: 192.168.1.42
|
||||
[mDNS] Accessible via http://esp_jardin.local
|
||||
[OTA] Prêt
|
||||
[FS] LittleFS monté
|
||||
[HTTP] Serveur web démarré sur port 80
|
||||
[SONDES] 3 capteur(s) DS18B20 détecté(s) sur GPIO 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accès à l'interface
|
||||
|
||||
Une fois connecté à votre réseau :
|
||||
|
||||
```
|
||||
http://esp_jardin.local # Via mDNS
|
||||
http://192.168.1.42 # Via IP directe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mode Access Point de secours
|
||||
|
||||
Si le WiFi est indisponible au démarrage, l'ESP crée automatiquement un point d'accès :
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|---|---|
|
||||
| SSID | `ESP_CHEF_JARDIN` |
|
||||
| Mot de passe | `Jardin2026` |
|
||||
| IP de l'interface | `192.168.4.1` |
|
||||
|
||||
L'interface web reste accessible. Le broker MQTT est désactivé en mode AP.
|
||||
L'ESP tente de se reconnecter au WiFi STA toutes les 60 secondes.
|
||||
|
||||
---
|
||||
|
||||
## API REST
|
||||
|
||||
| Méthode | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | État système (WiFi, MQTT, RAM, uptime) |
|
||||
| GET | `/api/temperatures` | Températures instantanées |
|
||||
| GET | `/api/history` | Historique 24h (288 points) |
|
||||
| POST | `/api/config` | Mise à jour configuration |
|
||||
|
||||
**Exemple `/api/temperatures` :**
|
||||
```json
|
||||
{ "sonde_1": "19.3", "sonde_2": "28.7", "sonde_3": null, "unit": "C" }
|
||||
```
|
||||
`null` = sonde en erreur ou déconnectée.
|
||||
|
||||
**Exemple POST `/api/config` :**
|
||||
```bash
|
||||
curl -X POST http://esp_jardin.local/api/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"intervalleMs": 5000, "mqttBroker": "10.0.0.3", "mqttPort": 1883}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MQTT
|
||||
|
||||
Topics publiés (retain=true) :
|
||||
|
||||
| Sonde | Topic | Deadband |
|
||||
|---|---|---|
|
||||
| T°C Extérieur | `maison/jardin/ext/temperature` | 0.2°C |
|
||||
| T°C Serre | `maison/jardin/serre/temperature` | 0.1°C |
|
||||
| T°C Sol | `maison/jardin/sol/temperature` | 0.1°C |
|
||||
|
||||
Payload : valeur numérique en string, ex : `"19.3"`. Les erreurs ne sont jamais publiées.
|
||||
|
||||
---
|
||||
|
||||
## Mise à jour OTA (après déploiement)
|
||||
|
||||
```bash
|
||||
# Remplacer 192.168.1.42 par l'IP réelle de la carte
|
||||
pio run -t upload --upload-port 192.168.1.42
|
||||
|
||||
# Mot de passe OTA : Jardin2026
|
||||
```
|
||||
|
||||
Le mot de passe OTA peut être changé dans `include/config.h` (`OTA_PASS`) et `platformio.ini` (`upload_flags = --auth=...`).
|
||||
|
||||
---
|
||||
|
||||
## GPIO disponibles pour extensions futures
|
||||
|
||||
| GPIO | Usage futur | Interface |
|
||||
|---|---|---|
|
||||
| 21 / 22 | Capteur BH1750 (luminosité) ou SHT31 (T°/Humidité) | I2C |
|
||||
| 32 / 33 | Capteur humidité sol | ADC1 (compatible WiFi) |
|
||||
| 25 / 26 | Relais / électrovanne | Digital out |
|
||||
| 14 | Pluviomètre à augets | Interruption |
|
||||
| 27 | Bouton reset / forçage AP | Interruption |
|
||||
|
||||
> **Ne jamais utiliser ADC2** (GPIO 34–39) quand le WiFi est actif — conflit hardware ESP32.
|
||||
|
||||
---
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Symptôme | Cause probable | Solution |
|
||||
|---|---|---|
|
||||
| Sonde affiche −127°C | Résistance pull-up absente ou câble défectueux | Vérifier la résistance 4.7 kΩ sur GPIO 4 |
|
||||
| Sonde affiche 85.0°C | Sonde en court-circuit ou alimentation insuffisante | Vérifier l'alimentation 3.3V |
|
||||
| Interface web inaccessible | LittleFS non flashé | `pio run -t uploadfs` |
|
||||
| `esp_jardin.local` ne répond pas | mDNS non supporté sur certains réseaux | Utiliser l'IP directe |
|
||||
| OTA échoue | Mauvais mot de passe | Vérifier `OTA_PASS` dans `config.h` |
|
||||
@@ -0,0 +1 @@
|
||||
- deepsleep ?
|
||||
@@ -0,0 +1,304 @@
|
||||
# mon design system — Gruvbox seventies
|
||||
|
||||
> Design system rétro-futuriste pour applications de monitoring, ops, IoT, domotique.
|
||||
> Orange brûlé, fond brun délavé en sombre / gris clair usé en clair.
|
||||
> **Version 1.0** · deux thèmes (dark + light), 14+ composants React, palette GTK pour GNOME.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage rapide (web)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="dark">
|
||||
<head>
|
||||
<!-- 1. Polices Google -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- 2. Icônes Font Awesome 6 -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- 3. Tokens (variables CSS) -->
|
||||
<link rel="stylesheet" href="tokens/tokens.css">
|
||||
|
||||
<!-- 4. React + Babel -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- 5. Composants UI -->
|
||||
<script type="text/babel" src="components/ui-kit.jsx"></script>
|
||||
<script type="text/babel">
|
||||
// Tes composants ici
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Pour voir tout fonctionner, ouvre `examples/exemple-minimal.html`.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Contenu du package
|
||||
|
||||
```
|
||||
export/
|
||||
├── README.md ← Ce fichier
|
||||
├── consigne_design_system.md ← Brief pour agents IA (Claude, ChatGPT…)
|
||||
├── tokens/
|
||||
│ ├── tokens.css ← Variables CSS web (dark + light)
|
||||
│ ├── tokens.gnome.css ← GTK 4 / libadwaita (apps GNOME)
|
||||
│ └── tokens.json ← Format générique (Tailwind, Figma…)
|
||||
├── components/
|
||||
│ └── ui-kit.jsx ← 14 composants React (Button, IconButton, Toggle, Tooltip,
|
||||
│ StatusLed, BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
│ Popup, TreeNav, Sparkline, LineChart, Icon, …)
|
||||
└── examples/
|
||||
└── exemple-minimal.html ← Démo minimale autoportante
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Ce qui est paramétrable
|
||||
|
||||
### 1. Thème global
|
||||
|
||||
```html
|
||||
<html data-theme="dark"> <!-- ou "light" -->
|
||||
```
|
||||
|
||||
Tu peux mettre `data-theme` sur **n'importe quel parent** pour basculer un sous-arbre uniquement (utile pour une preview en mode opposé dans un menu de réglages).
|
||||
|
||||
### 2. Toutes les couleurs (CSS variables)
|
||||
|
||||
Édite `tokens.css` ou surcharge dans ton propre CSS :
|
||||
|
||||
```css
|
||||
:root[data-theme="dark"] {
|
||||
--accent: #fe8019; /* Couleur principale (orange seventies) */
|
||||
--accent-soft: #d65d0e;
|
||||
--bg-1: #2a231d; /* Fond app */
|
||||
--bg-3: #3c332a; /* Cartes */
|
||||
--ink-1: #f2e5c7; /* Texte */
|
||||
--ok: #4dbb26;
|
||||
--warn: #fabd2f;
|
||||
--err: #fb4934;
|
||||
--blue: #3db0d1; /* Datavis additionnel */
|
||||
--purple: #c882c8;
|
||||
}
|
||||
```
|
||||
|
||||
**4 statuts** (ok / warn / err / info) + **2 couleurs datavis** (blue / purple) + **6 niveaux de fond** + **4 niveaux d'encre** + **3 niveaux de bordure**.
|
||||
|
||||
### 3. Polices
|
||||
|
||||
Trois familles, toutes substituables :
|
||||
|
||||
| Variable | Usage | Défaut |
|
||||
|-----------------|-------------------------------------|---------------------|
|
||||
| `--font-ui` | Interface (titres, corps, boutons) | Inter |
|
||||
| `--font-mono` | Données, code, valeurs numériques | JetBrains Mono |
|
||||
| `--font-terminal` | Logs, terminal embarqué, vibe rétro | Share Tech Mono |
|
||||
|
||||
Pour changer, remplace simplement les `@import` Google Fonts et redéfinis les variables.
|
||||
|
||||
### 4. Ombres et relief
|
||||
|
||||
```css
|
||||
--tile-3d /* Relief 3D marqué pour cartes */
|
||||
--shadow-1, -2, -3 /* Niveaux d'élévation */
|
||||
--shadow-press /* Inset pour état pressé */
|
||||
--hover-glow /* Halo accent au survol */
|
||||
```
|
||||
|
||||
### 5. Composants — props paramétrables
|
||||
|
||||
Chaque composant accepte des props pour personnaliser sans toucher au CSS. Exemples :
|
||||
|
||||
```jsx
|
||||
<Button variant="primary|ghost|danger|default" size="sm|md|lg" icon="play">Texte</Button>
|
||||
|
||||
<IconButton icon="cog" label="Tooltip obligatoire" primary danger active />
|
||||
|
||||
<Toggle on={state} onChange={setState} label="Auto" icon="refresh" />
|
||||
|
||||
<BatteryGauge
|
||||
value={64} max={100} unit="%"
|
||||
label="CPU"
|
||||
warnAt={70} errAt={85} // seuils de couleur
|
||||
compact // mode 1 ligne
|
||||
icon="cpu"
|
||||
color="var(--blue)" // couleur fixe (sinon auto selon seuils)
|
||||
/>
|
||||
|
||||
<RadialGauge value={87} label="SCORE" size={120} />
|
||||
<BigRadialGauge value={87} label="santé système" />
|
||||
|
||||
<Popup open={open} onClose={fn} title="…" footer={…}>
|
||||
Contenu
|
||||
</Popup>
|
||||
|
||||
<TreeNav groups={[
|
||||
{ id, icon: 'server', label, count, open, children: [
|
||||
{ id, label, status: 'ok|warn|err', meta }
|
||||
]}
|
||||
]} activeId={id} onSelect={fn} />
|
||||
```
|
||||
|
||||
Voir la doc complète des props : `Component Reference.html` dans le projet original.
|
||||
|
||||
---
|
||||
|
||||
## 🐧 Utilisation dans une app GNOME (GTK 4 / libadwaita)
|
||||
|
||||
Charge `tokens/tokens.gnome.css` comme provider CSS au démarrage de l'app.
|
||||
|
||||
**Python (PyGObject)** :
|
||||
```python
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
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** :
|
||||
```javascript
|
||||
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
|
||||
);
|
||||
```
|
||||
|
||||
**Rust (gtk4-rs)** :
|
||||
```rust
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_path("tokens.gnome.css");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
```
|
||||
|
||||
Le fichier override directement les couleurs sémantiques de libadwaita (`@window_bg_color`, `@accent_color`, etc.) ET ajoute des styles spécifiques pour les widgets courants : `button.suggested-action`, `entry`, `switch`, `scale`, `progressbar`, `notebook`, `popover`…
|
||||
|
||||
Classes CSS supplémentaires à appliquer via `add_css_class()` :
|
||||
- `.tile` / `.card` — Tuile en relief 3D
|
||||
- `.mono` — Texte monospace JetBrains Mono
|
||||
- `.terminal` — Texte terminal Share Tech Mono
|
||||
- `.status.ok` / `.status.warn` / `.status.error` / `.status.info` — Badge de statut
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Intégration dans d'autres outils
|
||||
|
||||
### Tailwind CSS
|
||||
|
||||
Convertis `tokens.json` en `tailwind.config.js` :
|
||||
|
||||
```js
|
||||
const tokens = require('./tokens/tokens.json');
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: tokens.themes.dark.accent.primary.value,
|
||||
ok: tokens.themes.dark.status.ok.value,
|
||||
// …
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [tokens.typography.fonts.ui.family, ...tokens.typography.fonts.ui.fallback],
|
||||
mono: [tokens.typography.fonts.mono.family],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Figma / outils de design
|
||||
|
||||
`tokens.json` suit un schéma compatible avec la plupart des plugins de tokens (Figma Tokens, Style Dictionary). Importe-le directement.
|
||||
|
||||
### Variables Sass / SCSS
|
||||
|
||||
```scss
|
||||
@use 'sass:map';
|
||||
$tokens: (
|
||||
accent: #fe8019,
|
||||
bg-1: #2a231d,
|
||||
ok: #4dbb26,
|
||||
);
|
||||
// …
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Personnalisation avancée
|
||||
|
||||
### Créer un thème dérivé
|
||||
|
||||
Duplique `tokens.css`, change le nom du sélecteur (`[data-theme="ocean"]` par exemple) et modifie les variables. Charge les deux fichiers — `data-theme` choisira automatiquement.
|
||||
|
||||
### Ajouter une couleur status custom
|
||||
|
||||
```css
|
||||
:root[data-theme="dark"] {
|
||||
--critical: #ff0080;
|
||||
--critical-glow: rgba(255, 0, 128, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
Utilisable ensuite partout : `<StatusLed status="critical">` nécessite une PR dans `ui-kit.jsx` (carte `map` dans `StatusLed`), mais en raw CSS tu peux utiliser la variable directement.
|
||||
|
||||
### Désactiver les effets
|
||||
|
||||
Tous les effets de `transition` / `transform` / `box-shadow` sont concentrés dans les classes `.interactive`, `.bg-hover`, `.gauge-hover`. Surcharge-les en CSS si besoin :
|
||||
|
||||
```css
|
||||
.interactive { transition: none !important; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist d'intégration
|
||||
|
||||
- [ ] Polices Google Fonts chargées (Inter, JetBrains Mono, Share Tech Mono)
|
||||
- [ ] Font Awesome 6 chargé
|
||||
- [ ] `tokens.css` (web) **ou** `tokens.gnome.css` (GTK) chargé
|
||||
- [ ] Attribut `data-theme="dark"` (ou "light") sur `<html>` ou un parent
|
||||
- [ ] React 18 + Babel chargés (uniquement pour `ui-kit.jsx`)
|
||||
- [ ] `ui-kit.jsx` chargé en `type="text/babel"`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Statuts du système
|
||||
|
||||
| Couleur | Token | Hex (dark) | Hex (light) | Usage |
|
||||
|---------|--------|------------|-------------|-----------------------------|
|
||||
| Accent | `--accent` | `#fe8019` | `#af3a03` | Primaire, focus, sélection |
|
||||
| OK | `--ok` | `#4dbb26` | `#3c911c` | Succès, état nominal |
|
||||
| Warn | `--warn` | `#fabd2f` | `#b57614` | Attention, latence élevée |
|
||||
| Err | `--err` | `#fb4934` | `#9d0006` | Erreur, alerte critique |
|
||||
| Info | `--info` | `#83a598` | `#427b58` | Information neutre |
|
||||
| Blue | `--blue` | `#3db0d1` | `#2d82a3` | Datavis catégorie 2 |
|
||||
| Purple | `--purple` | `#c882c8` | `#8c468c` | Datavis catégorie 3 |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Pour les agents IA
|
||||
|
||||
Si tu utilises ce design system avec une IA (Claude, GPT, Copilot, etc.), partage-lui le fichier **`consigne_design_system.md`**. Il y trouvera toutes les règles d'utilisation, conventions, contre-exemples à éviter.
|
||||
|
||||
---
|
||||
|
||||
**Licence** · Usage libre dans tes projets. Pas de garantie.
|
||||
@@ -0,0 +1,656 @@
|
||||
/* ============================================================
|
||||
ui-kit.jsx
|
||||
Composants haute-fid Gruvbox Seventies.
|
||||
Tout est purement décoratif/interactif côté composant.
|
||||
Effets : transparence (glass), hover glow, click 3D, tooltips.
|
||||
============================================================ */
|
||||
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
/* ============================================================
|
||||
Icônes — Font Awesome 6 Free.
|
||||
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
|
||||
dans le <head>. Le composant garde la MÊME API qu'avant (name,
|
||||
size, style) pour ne rien casser ailleurs.
|
||||
============================================================ */
|
||||
const ICON_MAP = {
|
||||
cpu: 'microchip',
|
||||
memory: 'memory',
|
||||
disk: 'hard-drive',
|
||||
network: 'network-wired',
|
||||
clock: 'clock',
|
||||
grid: 'table-cells',
|
||||
list: 'list',
|
||||
cog: 'gear',
|
||||
alert: 'triangle-exclamation',
|
||||
bell: 'bell',
|
||||
server: 'server',
|
||||
chart: 'chart-line',
|
||||
bars: 'chart-simple',
|
||||
terminal: 'terminal',
|
||||
refresh: 'arrows-rotate',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
power: 'power-off',
|
||||
sun: 'sun',
|
||||
moon: 'moon',
|
||||
search: 'magnifying-glass',
|
||||
close: 'xmark',
|
||||
chevR: 'chevron-right',
|
||||
chevL: 'chevron-left',
|
||||
chevD: 'chevron-down',
|
||||
chevU: 'chevron-up',
|
||||
plus: 'plus',
|
||||
filter: 'filter',
|
||||
download: 'download',
|
||||
folder: 'folder',
|
||||
node: 'circle-nodes',
|
||||
user: 'user',
|
||||
};
|
||||
|
||||
const Icon = ({ name, size = 16, style }) => {
|
||||
const fa = ICON_MAP[name] || 'circle-question';
|
||||
return (
|
||||
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
|
||||
fontSize: size,
|
||||
width: size,
|
||||
height: size,
|
||||
lineHeight: `${size}px`,
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '0 0 auto',
|
||||
color: 'currentColor',
|
||||
...style,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
Tooltip — apparaît au hover après 300ms, position auto.
|
||||
============================================================ */
|
||||
function Tooltip({ children, label, side = 'top' }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const t = useRef();
|
||||
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
|
||||
const onLeave = () => { clearTimeout(t.current); setShow(false); };
|
||||
const sides = {
|
||||
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
};
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex' }}
|
||||
onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{children}
|
||||
{show && (
|
||||
<span className="glass-strong" style={{
|
||||
position: 'absolute', ...sides[side],
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
color: 'var(--ink-1)',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
letterSpacing: '0.02em',
|
||||
}}>{label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
IconButton — bouton icône seul + tooltip obligatoire.
|
||||
============================================================ */
|
||||
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
|
||||
const bg = active ? 'var(--accent-tint)'
|
||||
: primary ? 'var(--accent)'
|
||||
: 'var(--bg-3)';
|
||||
const fg = active ? 'var(--accent)'
|
||||
: primary ? 'var(--bg-1)'
|
||||
: danger ? 'var(--err)'
|
||||
: 'var(--ink-2)';
|
||||
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
width: size, height: size,
|
||||
background: bg,
|
||||
color: fg,
|
||||
border: `1px solid ${bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 0, cursor: 'pointer',
|
||||
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
<Icon name={icon} size={Math.round(size * 0.5)} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toggle on/off — switch tactile avec glow accent quand ON
|
||||
============================================================ */
|
||||
function Toggle({ on, onChange, label, icon }) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
|
||||
<button onClick={() => onChange(!on)} className="interactive" style={{
|
||||
width: 42, height: 22, borderRadius: 12,
|
||||
background: on ? 'var(--accent)' : 'var(--bg-4)',
|
||||
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
|
||||
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
|
||||
position: 'relative', cursor: 'pointer', padding: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 1, left: on ? 21 : 1,
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
|
||||
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Status LED — pastille pulsante (effet halo si critique)
|
||||
============================================================ */
|
||||
function StatusLed({ status = 'ok', size = 10, pulse }) {
|
||||
const map = {
|
||||
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
|
||||
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
|
||||
err: { c: 'var(--err)', g: 'var(--err-glow)' },
|
||||
off: { c: 'var(--ink-4)', g: 'transparent' },
|
||||
info: { c: 'var(--info)', g: 'var(--info-glow)' },
|
||||
};
|
||||
const { c, g } = map[status];
|
||||
const id = `pulse-${status}-${size}`;
|
||||
return (
|
||||
<>
|
||||
{pulse && (
|
||||
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
|
||||
)}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
|
||||
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
|
||||
flex: '0 0 auto',
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BatteryGauge — jauge horizontale style batterie
|
||||
- Pas de bandes (couleur unie + léger gloss interne)
|
||||
- Pas de graduations verticales
|
||||
- Hover : glow lumineux dans la couleur de la jauge
|
||||
- Mode compact : label [bar] valeur sur une seule ligne
|
||||
============================================================ */
|
||||
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||
const color = colorOverride
|
||||
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
|
||||
const glowVar = pct >= errAt ? 'var(--err-glow)'
|
||||
: pct >= warnAt ? 'var(--warn-glow)'
|
||||
: 'var(--ok-glow)';
|
||||
|
||||
// Variante compacte : label [bar] valeur sur une seule ligne
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{(icon || label) && (
|
||||
<span style={{
|
||||
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
minWidth: 90,
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
|
||||
</span>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
flex: 1, height: 12, borderRadius: 3,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden', position: 'relative',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 2,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
</div>
|
||||
<span className="mono" style={{
|
||||
flex: '0 0 auto', fontSize: 13,
|
||||
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
|
||||
}}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{label && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="label">{label}</span>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
position: 'relative',
|
||||
height, borderRadius: 4,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 3,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RadialGauge — jauge ronde, version épurée
|
||||
============================================================ */
|
||||
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
|
||||
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
|
||||
const r = size / 2 - 10;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 6;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (pct / 100) * circ;
|
||||
return (
|
||||
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
|
||||
<defs>
|
||||
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* arc background */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter={`url(#glow-${label})`} opacity="0.7" />
|
||||
{/* arc value crisp */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
</svg>
|
||||
<div style={{ marginTop: -10, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
|
||||
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
|
||||
</div>
|
||||
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BigRadialGauge — la grande jauge cockpit "santé système"
|
||||
============================================================ */
|
||||
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
||||
const size = 320;
|
||||
const r = 130;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 30;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (value / 100) * circ;
|
||||
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
|
||||
return (
|
||||
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
|
||||
<svg width={size} height={size * 0.85}>
|
||||
<defs>
|
||||
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
</filter>
|
||||
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
|
||||
<stop offset="1" stopColor={color}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* tics */}
|
||||
{Array.from({ length: 21 }).map((_, i) => {
|
||||
const a = Math.PI - (i / 20) * Math.PI;
|
||||
const major = i % 5 === 0;
|
||||
const inner = major ? r + 8 : r + 11;
|
||||
const outer = major ? r + 20 : r + 15;
|
||||
return <line key={i}
|
||||
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
|
||||
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
|
||||
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
|
||||
/>;
|
||||
})}
|
||||
{[0, 50, 100].map(v => {
|
||||
const a = Math.PI - (v / 100) * Math.PI;
|
||||
const x = cx + Math.cos(a) * (r + 32);
|
||||
const y = cy - Math.sin(a) * (r + 32) + 4;
|
||||
return <text key={v} x={x} y={y} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="11"
|
||||
fill="var(--ink-3)">{v}</text>;
|
||||
})}
|
||||
{/* arc bg */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter="url(#biggauge-glow)" opacity="0.55" />
|
||||
{/* arc value */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
{/* needle */}
|
||||
<line x1={cx} y1={cy}
|
||||
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
|
||||
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
|
||||
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
|
||||
<div className="mono" style={{
|
||||
fontSize: 64, fontWeight: 700, lineHeight: 1,
|
||||
color: 'var(--ink-1)',
|
||||
textShadow: `0 0 20px ${color}33`,
|
||||
}}>{value}</div>
|
||||
<div className="label" style={{ marginTop: 6 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Popup — modale glassmorphism centrée + bouton fermer
|
||||
============================================================ */
|
||||
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadein .2s ease-out',
|
||||
}} onClick={onClose}>
|
||||
<style>{`
|
||||
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||
`}</style>
|
||||
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
|
||||
width, maxWidth: '90%',
|
||||
borderRadius: 12,
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'var(--bg-3)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
|
||||
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>{children}</div>
|
||||
{footer && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border-1)',
|
||||
background: 'var(--bg-2)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||
}}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Button — bouton classique avec variantes
|
||||
============================================================ */
|
||||
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
|
||||
const sizes = {
|
||||
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
|
||||
md: { padding: '7px 14px', fontSize: 13, h: 34 },
|
||||
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
|
||||
}[size];
|
||||
const variants = {
|
||||
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
|
||||
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
|
||||
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
|
||||
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
|
||||
}[variant];
|
||||
return (
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
height: sizes.h,
|
||||
padding: sizes.padding,
|
||||
background: variants.bg,
|
||||
color: variants.fg,
|
||||
border: `1px solid ${variants.bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TreeNav — arbre dépliable avec icône en tête (style B)
|
||||
============================================================ */
|
||||
function TreeNav({ groups, activeId, onSelect }) {
|
||||
const [open, setOpen] = useState(() =>
|
||||
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{groups.map(g => (
|
||||
<div key={g.id}>
|
||||
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px', borderRadius: 6,
|
||||
color: 'var(--ink-2)',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<Icon name="chevR" size={12} style={{
|
||||
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
|
||||
transition: 'transform .15s',
|
||||
color: 'var(--ink-3)',
|
||||
}} />
|
||||
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
|
||||
{g.count != null && (
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
{g.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open[g.id] && (
|
||||
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
|
||||
{g.children.map(c => {
|
||||
const active = c.id === activeId;
|
||||
return (
|
||||
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: active ? 'var(--accent-tint)' : 'transparent',
|
||||
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
|
||||
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginLeft: active ? 0 : 2,
|
||||
fontSize: 12.5,
|
||||
}}>
|
||||
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
|
||||
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
|
||||
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Sparkline pour les KPI
|
||||
============================================================ */
|
||||
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
|
||||
const w = 100;
|
||||
const max = Math.max(...points);
|
||||
const min = Math.min(...points);
|
||||
const range = max - min || 1;
|
||||
const step = w / (points.length - 1);
|
||||
const path = points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
<path d={area} fill={color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LineChart — grand graph multi-séries
|
||||
============================================================ */
|
||||
function LineChart({ series, h = 200, labels }) {
|
||||
const w = 600;
|
||||
const padding = { l: 36, r: 12, t: 12, b: 24 };
|
||||
const innerW = w - padding.l - padding.r;
|
||||
const innerH = h - padding.t - padding.b;
|
||||
const all = series.flatMap(s => s.points);
|
||||
const max = Math.max(...all) * 1.1;
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const ptsCount = series[0].points.length;
|
||||
const step = innerW / (ptsCount - 1);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
{/* grid horizontal */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(p => {
|
||||
const y = padding.t + innerH * p;
|
||||
const v = Math.round(max - range * p);
|
||||
return (
|
||||
<g key={p}>
|
||||
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
|
||||
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={padding.l - 6} y={y + 3} textAnchor="end"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* labels x */}
|
||||
{labels && labels.map((lb, i) => (
|
||||
i % Math.ceil(labels.length / 8) === 0 && (
|
||||
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
|
||||
)
|
||||
))}
|
||||
{/* séries */}
|
||||
{series.map((s, si) => {
|
||||
const path = s.points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
|
||||
return (
|
||||
<g key={si}>
|
||||
<path d={area} fill={s.color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* Expose */
|
||||
Object.assign(window, {
|
||||
Icon, Tooltip, IconButton, Toggle, StatusLed,
|
||||
BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
Popup, Button, TreeNav, Sparkline, LineChart,
|
||||
});
|
||||
|
||||
/* Effets hover sur les jauges (sans effet au clic) */
|
||||
(function injectGaugeHoverStyles() {
|
||||
if (document.getElementById('gauge-hover-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'gauge-hover-styles';
|
||||
s.textContent = `
|
||||
.bg-hover:hover .bg-bar {
|
||||
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
|
||||
}
|
||||
.bg-hover:hover .bg-fill {
|
||||
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.gauge-hover { transition: filter .2s; }
|
||||
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
@@ -0,0 +1,363 @@
|
||||
# Consignes — mon design system (Gruvbox seventies)
|
||||
|
||||
> **Tu es un agent IA chargé de produire ou modifier du code utilisant ce design system.**
|
||||
> Lis ce fichier en entier avant d'écrire la moindre ligne. Suis les règles à la lettre.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Identité du système
|
||||
|
||||
- **Nom** : mon design system — Gruvbox seventies
|
||||
- **Vibe** : rétro-industriel, console de monitoring, SCADA, terminal années 70
|
||||
- **Palette** : orange brûlé Gruvbox + fond brun délavé (pas noir intense) ou gris clair usé (pas blanc pur)
|
||||
- **Cas d'usage cibles** : tableaux de bord, monitoring, IoT, domotique, ops, scanners réseau
|
||||
- **Public** : utilisateurs techniques (admin sys, devs, makers) — densité d'info élevée acceptée
|
||||
|
||||
---
|
||||
|
||||
## 📁 Fichiers à connaître
|
||||
|
||||
| Fichier | Contient |
|
||||
|---------------------------------|-------------------------------------------------------|
|
||||
| `tokens/tokens.css` | Variables CSS web (`:root[data-theme="dark|light"]`) |
|
||||
| `tokens/tokens.gnome.css` | Tokens GTK 4 / libadwaita (`@define-color`) |
|
||||
| `tokens/tokens.json` | Tokens en JSON pour outils externes |
|
||||
| `components/ui-kit.jsx` | 14 composants React (Button, Icon, Popup…) |
|
||||
| `examples/exemple-minimal.html` | Démo de référence |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Règles absolues — ne JAMAIS enfreindre
|
||||
|
||||
1. **Toujours utiliser les variables CSS**, jamais des hex en dur dans le code utilisateur.
|
||||
✅ `color: var(--accent)`
|
||||
❌ `color: #fe8019`
|
||||
|
||||
2. **Toujours déclarer `data-theme`** sur un parent (`<html>` ou un wrapper).
|
||||
Sans ça, les variables ne sont pas définies et l'UI casse silencieusement.
|
||||
|
||||
3. **Composants existants** — ne jamais en réinventer. Vérifier d'abord la liste ci-dessous.
|
||||
|
||||
4. **Icônes** — utiliser le composant `<Icon name="…">` avec les noms mappés. JAMAIS d'emoji, JAMAIS de SVG inline custom pour un cas où une icône Font Awesome existe.
|
||||
|
||||
5. **Pas d'effet hover** sur les boutons / tuiles / composants généraux (sauf jauges et tuiles Heimdall qui en ont un). Seulement **pression 3D au clic** via `.interactive`.
|
||||
|
||||
6. **Toujours des tooltips** sur les boutons icônes seuls (`<IconButton>` exige `label`).
|
||||
|
||||
7. **Pas de bordure arrondie excessive**. Tuiles : `border-radius: 10-12px`. Boutons : `8px`. Pastilles : `999px`.
|
||||
|
||||
8. **Polices** — respecter strictement les 3 familles :
|
||||
- **Inter** → UI (titres, corps, boutons, labels d'interface généraux)
|
||||
- **JetBrains Mono** → données numériques, valeurs, code, IDs, IPs
|
||||
- **Share Tech Mono** → logs, terminal embarqué, ambiance rétro
|
||||
Toute autre police = bug.
|
||||
|
||||
9. **Tonalité** : labels en `text-transform: uppercase` + `letter-spacing: 0.08em` (classe `.label` déjà fournie).
|
||||
|
||||
10. **Densité** : pas de padding inutile. Ce DS est dense par nature. Tuiles : padding 14-18px. Boutons : 6-10px vertical.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Tokens disponibles
|
||||
|
||||
### Couleurs (toutes définies en `dark` ET `light`)
|
||||
|
||||
#### Fonds (du plus profond au plus haut)
|
||||
```
|
||||
--bg-0 très rare, niveau le plus bas
|
||||
--bg-1 fond application principal
|
||||
--bg-2 panneaux (sidebar, headerbar)
|
||||
--bg-3 cartes, tuiles ← LE PLUS UTILISÉ
|
||||
--bg-4 hover, état actif
|
||||
--bg-5 press, sélection forte
|
||||
```
|
||||
|
||||
#### Texte (du plus contrasté au moins)
|
||||
```
|
||||
--ink-1 texte principal
|
||||
--ink-2 texte secondaire
|
||||
--ink-3 labels, hints
|
||||
--ink-4 désactivé
|
||||
```
|
||||
|
||||
#### Accent
|
||||
```
|
||||
--accent couleur primaire (orange Gruvbox seventies)
|
||||
--accent-soft variante foncée (bordures, hover)
|
||||
--accent-glow halo (rgba)
|
||||
--accent-tint teinte transparente (fonds discrets)
|
||||
```
|
||||
|
||||
#### Statuts
|
||||
```
|
||||
--ok #4dbb26 (vert flashy)
|
||||
--warn #fabd2f (jaune)
|
||||
--err #fb4934 (rouge)
|
||||
--info #83a598 (vert-bleu pastel)
|
||||
```
|
||||
|
||||
#### Datavis additionnel
|
||||
```
|
||||
--blue #3db0d1
|
||||
--purple #c882c8
|
||||
```
|
||||
|
||||
#### Bordures
|
||||
```
|
||||
--border-1, --border-2, --border-3 du plus subtil au plus marqué
|
||||
```
|
||||
|
||||
#### Ombres / relief
|
||||
```
|
||||
--shadow-1, -2, -3 élévations standards
|
||||
--shadow-press état pressé (inset)
|
||||
--tile-3d relief 3D marqué pour cartes ← À utiliser sur les tuiles importantes
|
||||
```
|
||||
|
||||
### Polices
|
||||
```
|
||||
--font-ui 'Inter', system-ui, sans-serif
|
||||
--font-mono 'JetBrains Mono', monospace
|
||||
--font-terminal 'Share Tech Mono', monospace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Composants — quand utiliser quoi
|
||||
|
||||
| Besoin | Composant | Exemple |
|
||||
|--------|-----------|---------|
|
||||
| Bouton texte avec ou sans icône | `<Button variant="primary|ghost|danger|default">` | Action principale, secondaire |
|
||||
| Bouton icône seul | `<IconButton icon="…" label="…">` | Toolbars, headers (le `label` devient tooltip) |
|
||||
| On/off | `<Toggle on={…} onChange={…} label icon>` | Activer/désactiver une option |
|
||||
| État système | `<StatusLed status="ok|warn|err|info|off" pulse>` | LED pulsante pour critique |
|
||||
| Jauge ronde standard | `<RadialGauge value={…} label size>` | KPI compact, cockpit |
|
||||
| Jauge ronde héro | `<BigRadialGauge value={…} label>` | Métrique principale unique |
|
||||
| Jauge barre standard | `<BatteryGauge value label>` | Stack vertical de ressources |
|
||||
| Jauge barre **inline** | `<BatteryGauge compact value label icon>` | Listes denses, label + barre + valeur sur 1 ligne |
|
||||
| Modale | `<Popup open onClose title footer>` | Confirmation, config détaillée |
|
||||
| Tree dépliable | `<TreeNav groups activeId onSelect>` | Sidebar hiérarchique (clusters/nodes) |
|
||||
| Mini graphe | `<Sparkline points color>` | Dans une tuile KPI |
|
||||
| Graphe ligne | `<LineChart series labels h>` | Évolution temporelle multi-séries |
|
||||
| Tooltip | `<Tooltip label side><…/></Tooltip>` | Toute icône isolée |
|
||||
| Icône | `<Icon name="…" size>` | JAMAIS d'emoji, JAMAIS de SVG custom |
|
||||
|
||||
### Icônes disponibles (noms logiques → Font Awesome)
|
||||
|
||||
`cpu`, `memory`, `disk`, `network`, `clock`, `grid`, `list`, `cog`, `alert`, `bell`, `server`, `chart`, `bars`, `terminal`, `refresh`, `play`, `pause`, `power`, `sun`, `moon`, `search`, `close`, `chevR`, `chevL`, `chevD`, `chevU`, `plus`, `filter`, `download`, `folder`, `node`, `user`.
|
||||
|
||||
Pour un nouveau besoin → utiliser une icône Font Awesome (préfixe `fa-solid fa-…`) en ajoutant l'alias dans `ICON_MAP` au sein de `ui-kit.jsx`.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Patterns d'agencement standards
|
||||
|
||||
### Layout dashboard 3 colonnes
|
||||
```
|
||||
┌─ Header (tabs workspace + search + actions + statut connexion) ─┐
|
||||
├──────┬────────────────────────────────┬──────────────────────────┤
|
||||
│ Tree │ Center cockpit (KPIs + jauges) │ Logs/Terminal repliable │
|
||||
│ nav │ │ │
|
||||
├──────┴────────────────────────────────┴──────────────────────────┤
|
||||
│ Status bar (mode · workspace · stats · horloge) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tuile KPI standard
|
||||
```jsx
|
||||
<div className="glass" style={{ padding: 12, borderRadius: 10, ...}}>
|
||||
<Icon name="cpu" /> <span className="label">CPU</span>
|
||||
<span className="mono">{value}<span className="label">%</span></span>
|
||||
<Sparkline points={trend} color="var(--accent)" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status bar inférieure
|
||||
- Première cellule = mode courant en fond accent (style tmux)
|
||||
- Cellules séparées par `border-right: 1px solid var(--border-1)`
|
||||
- Police `Share Tech Mono` 11-12px
|
||||
- Horloge à droite
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-patterns à éviter
|
||||
|
||||
### NE PAS faire :
|
||||
|
||||
❌ **Mettre des emoji** pour un état :
|
||||
```jsx
|
||||
<span>✅ Système OK</span> // NON
|
||||
<><StatusLed status="ok" /> Système OK</> // OUI
|
||||
```
|
||||
|
||||
❌ **Inventer de nouvelles couleurs hors palette** :
|
||||
```jsx
|
||||
style={{ color: '#ff00aa' }} // NON — utilise les tokens
|
||||
```
|
||||
|
||||
❌ **Police arbitraire** :
|
||||
```jsx
|
||||
fontFamily: 'Roboto' // NON
|
||||
fontFamily: 'var(--font-ui)' // OUI
|
||||
```
|
||||
|
||||
❌ **Bordures arrondies à 24px+** sur des cartes (vibe trop SaaS pastel).
|
||||
|
||||
❌ **Tooltip absent sur une icône isolée** :
|
||||
```jsx
|
||||
<button><Icon name="cog" /></button> // NON
|
||||
<IconButton icon="cog" label="Configurer" onClick={fn} /> // OUI
|
||||
```
|
||||
|
||||
❌ **`window.alert` / `confirm`** — toujours utiliser `<Popup>`.
|
||||
|
||||
❌ **Texte secondaire en `--ink-1`** — choisir la bonne couche d'encre selon la hiérarchie.
|
||||
|
||||
❌ **Sur-utiliser le glow / shadow** — réservé aux accents importants.
|
||||
|
||||
❌ **Mélanger les casses de label** — labels = uppercase mono, titres = sentence case.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Patterns recommandés
|
||||
|
||||
### Hiérarchie de fond
|
||||
- App / page → `--bg-1`
|
||||
- Sidebar / headerbar → `--bg-2`
|
||||
- Tuiles / cartes principales → `--bg-3` ou `.glass`
|
||||
- Input fields / containers profonds → `--bg-1` avec inset shadow
|
||||
|
||||
### Effet glass standard
|
||||
```jsx
|
||||
className="glass" // backdrop-filter + bg semi-transparent + tile-3d shadow
|
||||
```
|
||||
ou pour plus marqué :
|
||||
```jsx
|
||||
className="glass-strong"
|
||||
```
|
||||
|
||||
### Validation visuelle d'un état critique
|
||||
```jsx
|
||||
<StatusLed status="err" pulse /> // pastille pulsante
|
||||
<Button variant="danger" icon="power">…</Button>
|
||||
// + bordure rouge sur le conteneur :
|
||||
style={{ border: '1px solid var(--err)', boxShadow: 'inset 0 1px 0 rgba(251,73,52,0.2), 0 0 18px rgba(251,73,52,0.15)' }}
|
||||
```
|
||||
|
||||
### Sticky footer d'actions (form)
|
||||
```jsx
|
||||
<div className="glass-strong" style={{
|
||||
padding: '12px 20px',
|
||||
display: 'flex', gap: 12, alignItems: 'center',
|
||||
borderTop: '1px solid var(--border-2)',
|
||||
}}>
|
||||
<StatusLed status={dirty ? 'warn' : 'ok'} pulse={dirty} />
|
||||
<span className="terminal">{dirty ? 'modifications non sauvegardées' : 'à jour'}</span>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<Button variant="ghost">Annuler</Button>
|
||||
<Button variant="primary" icon="download">Enregistrer</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌗 Gestion des deux thèmes
|
||||
|
||||
**Règle d'or** : tout ce qui s'affiche doit être lisible et cohérent dans les deux thèmes.
|
||||
|
||||
Avant de livrer un écran, **mentalement (ou réellement) bascule `data-theme`** et vérifie :
|
||||
- Les couleurs personnalisées (en dur) cassent forcément → utilise les tokens
|
||||
- Les opacités blanches (`rgba(255,255,255,…)`) en dark passent mal en light → préfère les variables `--border-*`
|
||||
- Les ombres très profondes en dark sont invisibles en light → utilise `--shadow-*` qui s'adapte
|
||||
|
||||
Pour basculer dynamiquement :
|
||||
```jsx
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Cas particulier : applications GNOME
|
||||
|
||||
Pour GTK 4 / libadwaita :
|
||||
1. Charger `tokens/tokens.gnome.css` via `GtkCssProvider`
|
||||
2. Le fichier **override les couleurs sémantiques libadwaita** (`@accent_color`, `@window_bg_color`, etc.) — les widgets standards se ré-habillent automatiquement
|
||||
3. Ajouter `add_css_class("tile")` pour le relief 3D, `("mono")` pour monospace, `("terminal")` pour Share Tech Mono
|
||||
4. Pour les boutons accent : utiliser la classe libadwaita standard `suggested-action` (déjà restylée)
|
||||
5. Pour danger : classe `destructive-action`
|
||||
|
||||
Polices : penser à installer ou bundler Inter / JetBrains Mono / Share Tech Mono dans le `.flatpak` / `.deb` (sinon GTK fallback sur Cantarell / DejaVu).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quand l'utilisateur demande quelque chose…
|
||||
|
||||
### "Ajoute un bouton de déconnexion"
|
||||
→ `<IconButton icon="power" label="Se déconnecter" danger />` ou
|
||||
`<Button variant="danger" icon="power">Déconnexion</Button>`
|
||||
|
||||
### "Affiche le statut du serveur"
|
||||
→ Combinaison `<StatusLed status="ok|warn|err" pulse />` + label texte. Le pulse uniquement si c'est critique/nouveau.
|
||||
|
||||
### "Mets une jauge CPU"
|
||||
→ `<BatteryGauge compact value={cpu} label="cpu" icon="cpu" warnAt={70} errAt={85} />` (inline)
|
||||
ou `<RadialGauge value={cpu} label="CPU" />` (visuel)
|
||||
|
||||
### "Crée une modale de confirmation"
|
||||
→ `<Popup>` avec `footer={<><Button variant="ghost">Annuler</Button><Button variant="primary">Confirmer</Button></>}`
|
||||
|
||||
### "Liste hiérarchique des serveurs"
|
||||
→ `<TreeNav>` avec `groups: [{ id, icon: 'server', label, count, open, children: [{ id, label, status, meta }] }]`
|
||||
|
||||
### "Affiche les logs"
|
||||
→ Conteneur avec `font-family: var(--font-terminal)` + lignes colorées par niveau (ERROR → var(--err), WARN → var(--warn), INFO → var(--ink-2)).
|
||||
|
||||
### "Ajoute une option dark/light dans les réglages"
|
||||
→ `<RadioGroup options={[{value:'dark', icon:'moon'}, {value:'light', icon:'sun'}, {value:'auto', icon:'clock'}]}>` + effet de bord :
|
||||
```jsx
|
||||
React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Tailles standards à respecter
|
||||
|
||||
| Élément | Taille / Padding |
|
||||
|---------------|------------------------------------------|
|
||||
| Boutons sm | h: 28px · pad: 5px 10px · font: 12px |
|
||||
| Boutons md | h: 34px · pad: 7px 14px · font: 13px |
|
||||
| Boutons lg | h: 40px · pad: 10px 18px · font: 14px |
|
||||
| IconButton | 34px (default) · 26px (compact) |
|
||||
| Inputs | pad: 9px 12px · font: 13px |
|
||||
| Toggle | 42 × 22px |
|
||||
| StatusLed | 8-14px diamètre |
|
||||
| Header app | 48-56px hauteur |
|
||||
| Sidebar | 200-260px largeur |
|
||||
| Volet logs | 320-360px largeur |
|
||||
| Status bar | 24-28px hauteur |
|
||||
| Radius tuile | 10-12px |
|
||||
| Radius button | 8px |
|
||||
| Espacement | 8 / 12 / 14 / 18 / 24px (rythme bas) |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Trucs pour ne pas se tromper
|
||||
|
||||
1. **Avant de créer un composant, cherche d'abord** dans `ui-kit.jsx`. 90% du temps il existe déjà.
|
||||
2. **Avant d'inventer une couleur**, regarde les tokens. Tu as 6 fonds, 4 encres, 4 statuts, 2 datavis = largement assez.
|
||||
3. **Si tu hésites sur une taille de police** : labels = 11px mono uppercase, body = 13-14px, kpi = 18-28px mono bold.
|
||||
4. **Quand tu ajoutes une tuile**, mets `className="glass"` (ou `glass-strong` pour les modales) — tout le styling est inclus.
|
||||
5. **Pour un état critique**, combine plusieurs signaux : couleur + pulse LED + icône + position visuelle. Pas juste une couleur.
|
||||
6. **Quand l'utilisateur demande "un peu d'effet"** : pas de hover (sauf jauges), oui à la pression 3D, oui aux animations d'entrée 200-400ms `cubic-bezier(.3,.7,.3,1.2)`.
|
||||
|
||||
---
|
||||
|
||||
## 🔚 En cas de doute
|
||||
|
||||
- Pas sûr d'une couleur ? → tokens
|
||||
- Pas sûr d'un composant ? → `ui-kit.jsx`
|
||||
- Pas sûr d'un layout ? → `examples/exemple-minimal.html`
|
||||
- Pas sûr d'une convention ? → ce fichier
|
||||
|
||||
Toujours préférer la cohérence avec l'existant à l'innovation.
|
||||
Quand tu doutes, **demande-moi** plutôt que de deviner.
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Exemple minimal — mon design system</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- 1. Polices -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- 2. Icônes -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- 3. Tokens du design system -->
|
||||
<link rel="stylesheet" href="../tokens/tokens.css">
|
||||
|
||||
<style>
|
||||
body { padding: 32px; }
|
||||
.row { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
h2 { font-size: 20px; margin: 32px 0 12px; color: var(--ink-1); }
|
||||
h2:first-child { margin-top: 0; }
|
||||
p { color: var(--ink-3); margin: 0 0 8px; }
|
||||
</style>
|
||||
|
||||
<!-- 4. React + composants -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="../components/ui-kit.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const [theme, setTheme] = React.useState('dark');
|
||||
const [popupOpen, setPopupOpen] = React.useState(false);
|
||||
const [auto, setAuto] = React.useState(true);
|
||||
|
||||
React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24 }}>
|
||||
<Icon name="grid" size={28} style={{ color: 'var(--accent)' }} />
|
||||
<h1 style={{ margin: 0, fontSize: 28 }}>Exemple minimal</h1>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<IconButton icon={theme === 'dark' ? 'sun' : 'moon'}
|
||||
label={theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} />
|
||||
</div>
|
||||
|
||||
<h2>Boutons</h2>
|
||||
<div className="row">
|
||||
<Button>défaut</Button>
|
||||
<Button variant="primary" icon="play">primaire</Button>
|
||||
<Button variant="ghost" icon="filter">ghost</Button>
|
||||
<Button variant="danger" icon="power">danger</Button>
|
||||
</div>
|
||||
|
||||
<h2>Boutons icônes (avec tooltip)</h2>
|
||||
<div className="row">
|
||||
<IconButton icon="refresh" label="Rafraîchir" />
|
||||
<IconButton icon="cog" label="Configurer" primary />
|
||||
<IconButton icon="bell" label="Notifications" />
|
||||
<IconButton icon="power" label="Arrêter" danger />
|
||||
</div>
|
||||
|
||||
<h2>Statuts</h2>
|
||||
<div className="row">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="ok" /> ok</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="warn" pulse /> warn</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="err" pulse /> err</span>
|
||||
<Toggle on={auto} onChange={setAuto} label="Auto-refresh" icon="refresh" />
|
||||
</div>
|
||||
|
||||
<h2>Jauges</h2>
|
||||
<div className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<RadialGauge value={28} label="DISQUE" />
|
||||
<RadialGauge value={64} label="CPU" warnAt={70} errAt={85} />
|
||||
<RadialGauge value={92} label="RÉSEAU" warnAt={70} errAt={85} />
|
||||
</div>
|
||||
<div style={{ maxWidth: 520, marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<BatteryGauge compact value={88} label="mémoire" icon="memory" />
|
||||
<BatteryGauge compact value={64} label="cpu" icon="cpu" warnAt={70} errAt={85} />
|
||||
<BatteryGauge compact value={28} label="disque" icon="disk" />
|
||||
<BatteryGauge compact value={92} label="réseau" icon="network" warnAt={70} errAt={85} />
|
||||
</div>
|
||||
|
||||
<h2>Popup</h2>
|
||||
<Button variant="primary" icon="cog" onClick={() => setPopupOpen(true)}>Ouvrir la popup</Button>
|
||||
<Popup open={popupOpen} onClose={() => setPopupOpen(false)}
|
||||
title="Confirmer l'action"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setPopupOpen(false)}>Annuler</Button>
|
||||
<Button variant="primary" onClick={() => setPopupOpen(false)}>OK</Button>
|
||||
</>}>
|
||||
<div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.5 }}>
|
||||
Une popup glassmorphism centrée. Clic à l'extérieur ou Échap pour fermer.
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
/* ============================================================
|
||||
ui-tokens.css
|
||||
Design tokens Gruvbox Seventies — dark (par défaut) + light.
|
||||
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
|
||||
============================================================ */
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
/* Couches de fond — sombre délavé, brun-gris chaud */
|
||||
--bg-0: #221c17; /* niveau le plus profond (rare) */
|
||||
--bg-1: #2a231d; /* fond app */
|
||||
--bg-2: #322a23; /* panneaux */
|
||||
--bg-3: #3c332a; /* cartes */
|
||||
--bg-4: #4a4035; /* hover */
|
||||
--bg-5: #5a4f43; /* press / actif */
|
||||
|
||||
/* Surfaces translucides */
|
||||
--surf-glass: rgba(50, 42, 35, 0.72);
|
||||
--surf-glass-strong: rgba(50, 42, 35, 0.92);
|
||||
--surf-glass-soft: rgba(50, 42, 35, 0.42);
|
||||
|
||||
/* Bordures */
|
||||
--border-1: rgba(168, 153, 132, 0.18);
|
||||
--border-2: rgba(168, 153, 132, 0.32);
|
||||
--border-3: rgba(168, 153, 132, 0.55);
|
||||
|
||||
/* Texte */
|
||||
--ink-1: #f2e5c7; /* cream principal */
|
||||
--ink-2: #d5c4a1; /* secondaire */
|
||||
--ink-3: #a89984; /* labels / hints */
|
||||
--ink-4: #7c6f64; /* désactivé */
|
||||
|
||||
/* Accent orange seventies */
|
||||
--accent: #fe8019;
|
||||
--accent-soft: #d65d0e;
|
||||
--accent-glow: rgba(254, 128, 25, 0.35);
|
||||
--accent-tint: rgba(254, 128, 25, 0.12);
|
||||
|
||||
/* Statuts */
|
||||
--ok: #4dbb26;
|
||||
--ok-glow: rgba(77, 187, 38, 0.45);
|
||||
--warn: #fabd2f;
|
||||
--warn-glow: rgba(250, 189, 47, 0.45);
|
||||
--err: #fb4934;
|
||||
--err-glow: rgba(251, 73, 52, 0.4);
|
||||
--info: #83a598;
|
||||
--info-glow: rgba(131, 165, 152, 0.4);
|
||||
|
||||
/* Couleurs additionnelles (datavis, badges, catégories) */
|
||||
--blue: #3db0d1;
|
||||
--blue-glow: rgba(61, 176, 209, 0.45);
|
||||
--purple: #c882c8;
|
||||
--purple-glow: rgba(200, 130, 200, 0.45);
|
||||
|
||||
/* Ombres */
|
||||
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
|
||||
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
|
||||
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
|
||||
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
|
||||
|
||||
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
|
||||
--tile-3d:
|
||||
inset 0 1px 0 rgba(255, 230, 180, 0.12),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
|
||||
0 1px 0 rgba(0, 0, 0, 0.35),
|
||||
0 2px 4px rgba(0, 0, 0, 0.4),
|
||||
0 8px 18px rgba(0, 0, 0, 0.5);
|
||||
--tile-3d-strong:
|
||||
inset 0 1px 0 rgba(255, 230, 180, 0.18),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
|
||||
0 1px 0 rgba(0, 0, 0, 0.4),
|
||||
0 4px 8px rgba(0, 0, 0, 0.5),
|
||||
0 14px 28px rgba(0, 0, 0, 0.55);
|
||||
|
||||
/* Polices */
|
||||
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* Gris clair usé, légèrement chaud (pas blanc pur) */
|
||||
--bg-0: #b8b2a3;
|
||||
--bg-1: #d5d0c5;
|
||||
--bg-2: #dcd7cc;
|
||||
--bg-3: #e3ded3;
|
||||
--bg-4: #ccc6b8;
|
||||
--bg-5: #bdb6a7;
|
||||
|
||||
--surf-glass: rgba(220, 215, 204, 0.72);
|
||||
--surf-glass-strong: rgba(220, 215, 204, 0.94);
|
||||
--surf-glass-soft: rgba(220, 215, 204, 0.42);
|
||||
|
||||
--border-1: rgba(60, 56, 54, 0.15);
|
||||
--border-2: rgba(60, 56, 54, 0.28);
|
||||
--border-3: rgba(60, 56, 54, 0.5);
|
||||
|
||||
--ink-1: #28241f;
|
||||
--ink-2: #3c3836;
|
||||
--ink-3: #5a544c;
|
||||
--ink-4: #8a8278;
|
||||
|
||||
--accent: #af3a03;
|
||||
--accent-soft: #d65d0e;
|
||||
--accent-glow: rgba(175, 58, 3, 0.28);
|
||||
--accent-tint: rgba(175, 58, 3, 0.08);
|
||||
|
||||
--ok: #3c911c;
|
||||
--ok-glow: rgba(60, 145, 28, 0.32);
|
||||
--warn: #b57614;
|
||||
--warn-glow: rgba(181, 118, 20, 0.35);
|
||||
--err: #9d0006;
|
||||
--err-glow: rgba(157, 0, 6, 0.3);
|
||||
--info: #427b58;
|
||||
--info-glow: rgba(66, 123, 88, 0.3);
|
||||
|
||||
/* Couleurs additionnelles (datavis, badges, catégories) */
|
||||
--blue: #2d82a3;
|
||||
--blue-glow: rgba(45, 130, 163, 0.32);
|
||||
--purple: #8c468c;
|
||||
--purple-glow: rgba(140, 70, 140, 0.32);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
|
||||
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
|
||||
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
|
||||
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
|
||||
|
||||
/* Relief light : highlight haut blanc cassé + ombre marquée */
|
||||
--tile-3d:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
|
||||
0 1px 0 rgba(60, 50, 40, 0.1),
|
||||
0 2px 4px rgba(60, 50, 40, 0.12),
|
||||
0 8px 18px rgba(60, 50, 40, 0.18);
|
||||
--tile-3d-strong:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
|
||||
0 1px 0 rgba(60, 50, 40, 0.15),
|
||||
0 4px 8px rgba(60, 50, 40, 0.18),
|
||||
0 14px 28px rgba(60, 50, 40, 0.22);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Reset minimal + base typo
|
||||
============================================================ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
color: var(--ink-1);
|
||||
background: var(--bg-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Surfaces — relief 3D marqué, AUCUN effet hover
|
||||
============================================================ */
|
||||
.glass {
|
||||
background: var(--surf-glass);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
border: 1px solid var(--border-2);
|
||||
box-shadow: var(--tile-3d);
|
||||
}
|
||||
.glass-strong {
|
||||
background: var(--surf-glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
||||
border: 1px solid var(--border-3);
|
||||
box-shadow: var(--tile-3d-strong);
|
||||
}
|
||||
|
||||
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
transition: transform .04s ease-out, box-shadow .04s, background .04s;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.interactive:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--shadow-press) !important;
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
/* Scrollbar custom */
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
|
||||
@@ -0,0 +1,378 @@
|
||||
/* ============================================================
|
||||
tokens.gnome.css — Tokens pour applications GNOME (GTK 4 / libadwaita)
|
||||
Gruvbox seventies · v1.0
|
||||
============================================================
|
||||
|
||||
Usage dans une app GTK 4 / libadwaita :
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
GtkCssProvider *provider = gtk_css_provider_new();
|
||||
gtk_css_provider_load_from_path(provider, "tokens.gnome.css");
|
||||
gtk_style_context_add_provider_for_display(
|
||||
gdk_display_get_default(), GTK_STYLE_PROVIDER(provider),
|
||||
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
|
||||
Python (PyGObject) :
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path("tokens.gnome.css")
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
|
||||
GJS :
|
||||
const provider = new Gtk.CssProvider();
|
||||
provider.load_from_path('tokens.gnome.css');
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
THÈME SOMBRE (défaut)
|
||||
============================================================ */
|
||||
|
||||
/* Couches de fond (du plus profond au plus haut) */
|
||||
@define-color bg_0 #221c17;
|
||||
@define-color bg_1 #2a231d;
|
||||
@define-color bg_2 #322a23;
|
||||
@define-color bg_3 #3c332a;
|
||||
@define-color bg_4 #4a4035;
|
||||
@define-color bg_5 #5a4f43;
|
||||
|
||||
/* Encres / texte */
|
||||
@define-color ink_1 #f2e5c7;
|
||||
@define-color ink_2 #d5c4a1;
|
||||
@define-color ink_3 #a89984;
|
||||
@define-color ink_4 #7c6f64;
|
||||
|
||||
/* Accent orange seventies */
|
||||
@define-color accent_color #fe8019;
|
||||
@define-color accent_soft #d65d0e;
|
||||
@define-color accent_fg_color #221c17;
|
||||
|
||||
/* Statuts */
|
||||
@define-color success_color #4dbb26;
|
||||
@define-color warning_color #fabd2f;
|
||||
@define-color error_color #fb4934;
|
||||
@define-color info_color #83a598;
|
||||
@define-color blue_color #3db0d1;
|
||||
@define-color purple_color #c882c8;
|
||||
|
||||
/* Bordures */
|
||||
@define-color border_1 alpha(#a89984, 0.18);
|
||||
@define-color border_2 alpha(#a89984, 0.32);
|
||||
@define-color border_3 alpha(#a89984, 0.55);
|
||||
|
||||
/* Couleurs sémantiques GNOME / libadwaita (overrides) */
|
||||
@define-color window_bg_color @bg_1;
|
||||
@define-color window_fg_color @ink_1;
|
||||
@define-color view_bg_color @bg_2;
|
||||
@define-color view_fg_color @ink_1;
|
||||
@define-color headerbar_bg_color @bg_2;
|
||||
@define-color headerbar_fg_color @ink_1;
|
||||
@define-color headerbar_border_color @border_2;
|
||||
@define-color headerbar_backdrop_color @bg_1;
|
||||
@define-color sidebar_bg_color @bg_2;
|
||||
@define-color sidebar_fg_color @ink_1;
|
||||
@define-color sidebar_backdrop_color @bg_1;
|
||||
@define-color popover_bg_color @bg_3;
|
||||
@define-color popover_fg_color @ink_1;
|
||||
@define-color card_bg_color @bg_3;
|
||||
@define-color card_fg_color @ink_1;
|
||||
@define-color shade_color alpha(black, 0.4);
|
||||
@define-color scrollbar_outline_color alpha(@ink_3, 0.3);
|
||||
|
||||
/* ============================================================
|
||||
COMPOSANTS GTK — habillage Gruvbox seventies
|
||||
============================================================ */
|
||||
|
||||
/* Fond global */
|
||||
window {
|
||||
background-color: @window_bg_color;
|
||||
color: @window_fg_color;
|
||||
font-family: 'Inter', 'Cantarell', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* HeaderBar (barre de titre) */
|
||||
headerbar {
|
||||
background: @bg_2;
|
||||
color: @ink_1;
|
||||
border-bottom: 1px solid @border_2;
|
||||
box-shadow: inset 0 1px 0 alpha(white, 0.04);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
headerbar .title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
headerbar .subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: @ink_3;
|
||||
}
|
||||
|
||||
/* Boutons — relief 3D et accent */
|
||||
button {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.06),
|
||||
inset 0 -1px 0 alpha(black, 0.3),
|
||||
0 1px 2px alpha(black, 0.4);
|
||||
transition: all 60ms ease;
|
||||
}
|
||||
button:active {
|
||||
background: @bg_4;
|
||||
box-shadow: inset 0 2px 4px alpha(black, 0.5);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
button:disabled {
|
||||
color: @ink_4;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Bouton "suggested-action" = primary (accent orange) */
|
||||
button.suggested-action {
|
||||
background: @accent_color;
|
||||
color: @accent_fg_color;
|
||||
border-color: @accent_soft;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.2),
|
||||
0 2px 6px alpha(@accent_color, 0.35);
|
||||
}
|
||||
button.suggested-action:active {
|
||||
background: @accent_soft;
|
||||
}
|
||||
|
||||
/* Bouton "destructive-action" = danger */
|
||||
button.destructive-action {
|
||||
background: @bg_3;
|
||||
color: @error_color;
|
||||
border-color: @error_color;
|
||||
}
|
||||
|
||||
/* Bouton plat (toolbar) */
|
||||
button.flat {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.flat:hover {
|
||||
background: @bg_3;
|
||||
}
|
||||
|
||||
/* Champs de saisie */
|
||||
entry,
|
||||
text {
|
||||
background: @bg_1;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.3);
|
||||
}
|
||||
entry:focus,
|
||||
text:focus {
|
||||
border-color: @accent_color;
|
||||
outline: 2px solid alpha(@accent_color, 0.18);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Listes / treeview */
|
||||
list,
|
||||
treeview {
|
||||
background: @bg_2;
|
||||
color: @ink_1;
|
||||
}
|
||||
list > row {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid @border_1;
|
||||
}
|
||||
list > row:selected,
|
||||
treeview:selected {
|
||||
background: alpha(@accent_color, 0.12);
|
||||
color: @ink_1;
|
||||
border-left: 3px solid @accent_color;
|
||||
}
|
||||
|
||||
/* Switch (toggle) */
|
||||
switch {
|
||||
background: @bg_4;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.4);
|
||||
min-height: 22px;
|
||||
min-width: 42px;
|
||||
}
|
||||
switch:checked {
|
||||
background: @accent_color;
|
||||
border-color: @accent_soft;
|
||||
box-shadow: 0 0 10px alpha(@accent_color, 0.35);
|
||||
}
|
||||
switch slider {
|
||||
background: @ink_2;
|
||||
border-radius: 50%;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
switch:checked slider {
|
||||
background: @accent_fg_color;
|
||||
}
|
||||
|
||||
/* Scale (slider) */
|
||||
scale trough {
|
||||
background: @bg_1;
|
||||
border-radius: 4px;
|
||||
min-height: 6px;
|
||||
}
|
||||
scale highlight {
|
||||
background: @accent_color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
scale slider {
|
||||
background: @ink_1;
|
||||
border: 2px solid @accent_color;
|
||||
border-radius: 50%;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
box-shadow: 0 1px 4px alpha(black, 0.5);
|
||||
}
|
||||
|
||||
/* Progress bar (jauge horizontale type batterie) */
|
||||
progressbar trough {
|
||||
background: @bg_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.4);
|
||||
min-height: 12px;
|
||||
}
|
||||
progressbar progress {
|
||||
background: @success_color;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px alpha(@success_color, 0.45);
|
||||
}
|
||||
|
||||
/* Niveaux de progression sémantiques (à appliquer via add_css_class) */
|
||||
progressbar.warning progress { background: @warning_color; }
|
||||
progressbar.error progress { background: @error_color; }
|
||||
progressbar.info progress { background: @info_color; }
|
||||
|
||||
/* Notebook / onglets */
|
||||
notebook header {
|
||||
background: @bg_2;
|
||||
border-bottom: 1px solid @border_2;
|
||||
}
|
||||
notebook tab {
|
||||
padding: 8px 16px;
|
||||
color: @ink_3;
|
||||
border-top: 2px solid transparent;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
notebook tab:checked {
|
||||
color: @ink_1;
|
||||
border-top-color: @accent_color;
|
||||
background: @bg_3;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
popover contents {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 32px alpha(black, 0.55);
|
||||
}
|
||||
|
||||
/* Menubutton / dropdown */
|
||||
menubutton button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Status pill (badge) — à appliquer sur GtkLabel.status */
|
||||
label.status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
label.status.ok { background: alpha(@success_color, 0.18); color: @success_color; }
|
||||
label.status.warn { background: alpha(@warning_color, 0.18); color: @warning_color; }
|
||||
label.status.error { background: alpha(@error_color, 0.18); color: @error_color; }
|
||||
label.status.info { background: alpha(@info_color, 0.18); color: @info_color; }
|
||||
|
||||
/* Texte monospace / terminal */
|
||||
label.mono,
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
label.terminal,
|
||||
.terminal {
|
||||
font-family: 'Share Tech Mono', 'VT323', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Carte tuile (à appliquer via add_css_class("tile")) */
|
||||
.tile,
|
||||
.card {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.06),
|
||||
inset 0 -1px 0 alpha(black, 0.4),
|
||||
0 2px 4px alpha(black, 0.4),
|
||||
0 6px 14px alpha(black, 0.45);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
scrollbar slider {
|
||||
background: @border_2;
|
||||
border-radius: 4px;
|
||||
min-width: 6px;
|
||||
min-height: 6px;
|
||||
}
|
||||
scrollbar slider:hover {
|
||||
background: @accent_soft;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME CLAIR — à charger en alternative
|
||||
Pour appliquer le thème clair, charger ce fichier puis
|
||||
`tokens.gnome.light.css` (à dupliquer en remplaçant
|
||||
les @define-color des fonds et encres) OU appliquer
|
||||
un settings GTK light :
|
||||
g_object_set(gtk_settings, "gtk-application-prefer-dark-theme",
|
||||
FALSE, NULL);
|
||||
Et fournir un fichier dérivé avec les valeurs ci-dessous :
|
||||
============================================================ */
|
||||
/*
|
||||
bg_0: #b8b2a3
|
||||
bg_1: #d5d0c5
|
||||
bg_2: #dcd7cc
|
||||
bg_3: #e3ded3
|
||||
bg_4: #ccc6b8
|
||||
bg_5: #bdb6a7
|
||||
ink_1: #28241f
|
||||
ink_2: #3c3836
|
||||
ink_3: #5a544c
|
||||
ink_4: #8a8278
|
||||
accent_color: #af3a03
|
||||
success_color: #3c911c
|
||||
warning_color: #b57614
|
||||
error_color: #9d0006
|
||||
info_color: #427b58
|
||||
blue_color: #2d82a3
|
||||
purple_color: #8c468c
|
||||
*/
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"$schema": "design-tokens-v1",
|
||||
"name": "mon design system — gruvbox seventies",
|
||||
"version": "1.0.0",
|
||||
"description": "Design system Gruvbox seventies. Orange brûlé, fond brun délavé en sombre / gris clair usé en clair. Deux thèmes dark/light parfaitement à parité.",
|
||||
"themes": {
|
||||
"dark": {
|
||||
"bg": {
|
||||
"0": { "value": "#221c17", "description": "Niveau le plus profond, rare" },
|
||||
"1": { "value": "#2a231d", "description": "Fond application principal" },
|
||||
"2": { "value": "#322a23", "description": "Panneaux (sidebar, headerbar)" },
|
||||
"3": { "value": "#3c332a", "description": "Cartes, tuiles" },
|
||||
"4": { "value": "#4a4035", "description": "Hover, état actif" },
|
||||
"5": { "value": "#5a4f43", "description": "Press, sélection forte" }
|
||||
},
|
||||
"ink": {
|
||||
"1": { "value": "#f2e5c7", "description": "Texte principal (cream)" },
|
||||
"2": { "value": "#d5c4a1", "description": "Texte secondaire" },
|
||||
"3": { "value": "#a89984", "description": "Labels, hints" },
|
||||
"4": { "value": "#7c6f64", "description": "Désactivé" }
|
||||
},
|
||||
"accent": {
|
||||
"primary": { "value": "#fe8019", "description": "Orange Gruvbox seventies" },
|
||||
"soft": { "value": "#d65d0e", "description": "Orange foncé (hover, bordures)" },
|
||||
"glow": { "value": "rgba(254, 128, 25, 0.35)" },
|
||||
"tint": { "value": "rgba(254, 128, 25, 0.12)" }
|
||||
},
|
||||
"status": {
|
||||
"ok": { "value": "#4dbb26" },
|
||||
"warn": { "value": "#fabd2f" },
|
||||
"err": { "value": "#fb4934" },
|
||||
"info": { "value": "#83a598" }
|
||||
},
|
||||
"extra": {
|
||||
"blue": { "value": "#3db0d1" },
|
||||
"purple": { "value": "#c882c8" }
|
||||
},
|
||||
"border": {
|
||||
"1": { "value": "rgba(168, 153, 132, 0.18)" },
|
||||
"2": { "value": "rgba(168, 153, 132, 0.32)" },
|
||||
"3": { "value": "rgba(168, 153, 132, 0.55)" }
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"bg": {
|
||||
"0": { "value": "#b8b2a3", "description": "Niveau le plus profond" },
|
||||
"1": { "value": "#d5d0c5", "description": "Fond application principal" },
|
||||
"2": { "value": "#dcd7cc", "description": "Panneaux" },
|
||||
"3": { "value": "#e3ded3", "description": "Cartes, tuiles" },
|
||||
"4": { "value": "#ccc6b8", "description": "Hover" },
|
||||
"5": { "value": "#bdb6a7", "description": "Press" }
|
||||
},
|
||||
"ink": {
|
||||
"1": { "value": "#28241f", "description": "Texte principal" },
|
||||
"2": { "value": "#3c3836", "description": "Texte secondaire" },
|
||||
"3": { "value": "#5a544c", "description": "Labels, hints" },
|
||||
"4": { "value": "#8a8278", "description": "Désactivé" }
|
||||
},
|
||||
"accent": {
|
||||
"primary": { "value": "#af3a03", "description": "Orange brûlé (variante contrastée)" },
|
||||
"soft": { "value": "#d65d0e" },
|
||||
"glow": { "value": "rgba(175, 58, 3, 0.28)" },
|
||||
"tint": { "value": "rgba(175, 58, 3, 0.08)" }
|
||||
},
|
||||
"status": {
|
||||
"ok": { "value": "#3c911c" },
|
||||
"warn": { "value": "#b57614" },
|
||||
"err": { "value": "#9d0006" },
|
||||
"info": { "value": "#427b58" }
|
||||
},
|
||||
"extra": {
|
||||
"blue": { "value": "#2d82a3" },
|
||||
"purple": { "value": "#8c468c" }
|
||||
},
|
||||
"border": {
|
||||
"1": { "value": "rgba(60, 56, 54, 0.15)" },
|
||||
"2": { "value": "rgba(60, 56, 54, 0.28)" },
|
||||
"3": { "value": "rgba(60, 56, 54, 0.5)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"fonts": {
|
||||
"ui": { "family": "Inter", "weights": [400, 500, 600, 700], "fallback": ["Cantarell", "system-ui", "sans-serif"] },
|
||||
"mono": { "family": "JetBrains Mono", "weights": [400, 500, 600, 700], "fallback": ["ui-monospace", "monospace"] },
|
||||
"terminal": { "family": "Share Tech Mono", "weights": [400], "fallback": ["VT323", "Courier New", "monospace"] }
|
||||
},
|
||||
"scale": {
|
||||
"label": { "size": 11, "weight": 500, "transform": "uppercase", "tracking": "0.08em", "family": "mono" },
|
||||
"caption": { "size": 12, "weight": 400, "family": "ui" },
|
||||
"body": { "size": 14, "weight": 400, "family": "ui" },
|
||||
"body-emph": { "size": 14, "weight": 600, "family": "ui" },
|
||||
"title": { "size": 18, "weight": 700, "family": "ui" },
|
||||
"h2": { "size": 22, "weight": 700, "family": "ui" },
|
||||
"h1": { "size": 28, "weight": 700, "family": "ui" },
|
||||
"display": { "size": 44, "weight": 700, "family": "ui" },
|
||||
"kpi": { "size": 28, "weight": 700, "family": "mono" }
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"xs": 3,
|
||||
"sm": 4,
|
||||
"md": 6,
|
||||
"lg": 8,
|
||||
"xl": 10,
|
||||
"2xl": 12,
|
||||
"pill": 999
|
||||
},
|
||||
"spacing": {
|
||||
"1": 4,
|
||||
"2": 6,
|
||||
"3": 8,
|
||||
"4": 10,
|
||||
"5": 12,
|
||||
"6": 14,
|
||||
"7": 16,
|
||||
"8": 18,
|
||||
"9": 20,
|
||||
"10": 24,
|
||||
"12": 32,
|
||||
"14": 40,
|
||||
"16": 56
|
||||
},
|
||||
"shadows": {
|
||||
"1": "0 1px 2px rgba(0,0,0,0.4)",
|
||||
"2": "0 4px 12px rgba(0,0,0,0.45)",
|
||||
"3": "0 12px 32px rgba(0,0,0,0.55)",
|
||||
"press": "inset 0 2px 4px rgba(0,0,0,0.5)",
|
||||
"tile3d": "inset 0 1px 0 rgba(255,230,180,0.12), inset 0 -1px 0 rgba(0,0,0,0.45), 0 1px 0 rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 8px 18px rgba(0,0,0,0.5)"
|
||||
},
|
||||
"motion": {
|
||||
"fast": "60ms ease",
|
||||
"normal": "180ms cubic-bezier(.3,.7,.3,1.2)",
|
||||
"slow": "400ms cubic-bezier(.3,.6,.3,1)"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
# Design — Firmware esp_jardin
|
||||
|
||||
**Date :** 2026-05-23
|
||||
**Statut :** Approuvé
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Firmware embarqué pour une station de monitoring environnemental basée sur un ESP32. La station acquiert les températures de 3 sondes DS18B20 sur un bus OneWire, conserve un historique glissant de 24h en RAM et expose deux interfaces d'accès :
|
||||
|
||||
1. Une interface web locale temps réel (WebSocket + Chart.js) servie depuis LittleFS.
|
||||
2. Une API REST pour l'intégration externe (Home Assistant, scripts, MCP).
|
||||
3. Une publication MQTT par sonde avec filtre deadband.
|
||||
|
||||
Développement **sans hardware disponible** — les logs série (115200 baud) sont le principal outil de validation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack technique
|
||||
|
||||
### Bibliothèques PlatformIO
|
||||
|
||||
```ini
|
||||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
board_build.filesystem = littlefs
|
||||
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer
|
||||
esp32async/AsyncTCP
|
||||
paulstoffregen/OneWire @ ^2.3.7
|
||||
milesburton/DallasTemperature @ ^3.11.0
|
||||
knolleary/PubSubClient @ ^2.8
|
||||
|
||||
upload_flags = --auth=Jardin2026
|
||||
```
|
||||
|
||||
**Incluses dans le framework (pas de lib_deps) :** ArduinoOTA, ESPmDNS, LittleFS, WiFi.
|
||||
|
||||
### Justifications des choix
|
||||
|
||||
- **ESPAsyncWebServer (esp32async)** : fork actif depuis jan. 2025, remplace `me-no-dev` (abandonné) et `mathieucarbou` (archivé). Gère HTTP + WebSocket dans la même instance, non-bloquant par nature.
|
||||
- **DallasTemperature + OneWire** : combo standard, bien documenté, supporte l'adressage par index sur un bus multi-sondes.
|
||||
- **PubSubClient** : le plus documenté, pattern non-bloquant avec `millis()` + `client.loop()` éprouvé.
|
||||
- **LittleFS** : filesystem recommandé pour ESP32 (SPIFFS déprécié), stockage de `index.html` et assets web.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture logicielle
|
||||
|
||||
### Principe
|
||||
|
||||
Architecture **modulaire avec structures globales partagées** — idiome Arduino classique, le plus adapté au développement sans hardware et au debugging via Serial.
|
||||
|
||||
Chaque module expose deux fonctions publiques : `init()` appelé dans `setup()` et `update()` appelé dans `loop()`. Aucun `delay()` dans le code.
|
||||
|
||||
### Arborescence cible
|
||||
|
||||
```
|
||||
include/
|
||||
config.h ← structs globales, constantes depuis parametrage.md
|
||||
network.h ← WiFi hybride STA/AP, mDNS, OTA
|
||||
sensors.h ← OneWire, DallasTemperature, buffer circulaire
|
||||
web_server.h ← ESPAsyncWebServer, routes REST, WebSocket
|
||||
mqtt_manager.h ← PubSubClient, deadband, reconnexion
|
||||
src/
|
||||
main.cpp ← setup() + loop() machine à états uniquement
|
||||
network.cpp
|
||||
sensors.cpp
|
||||
web_server.cpp
|
||||
mqtt_manager.cpp
|
||||
data/
|
||||
index.html ← interface web, flashée avec `pio run -t uploadfs`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Structures de données globales
|
||||
|
||||
Déclarées dans `config.h`, définies dans `main.cpp`, accessibles via `extern` dans tous les modules.
|
||||
|
||||
```cpp
|
||||
// Configuration immuable d'une sonde (initialisée depuis parametrage.md)
|
||||
struct SondeConfig {
|
||||
const char* nom; // "T°C Ext"
|
||||
const char* topic; // "maison/jardin/ext/temperature"
|
||||
uint32_t intervalleMs; // 60 000 ms
|
||||
float deadband; // 0.2 °C
|
||||
};
|
||||
|
||||
// État runtime d'une sonde
|
||||
struct SondeEtat {
|
||||
float tempActuelle; // NAN si erreur capteur
|
||||
float dernierPublie; // dernière valeur publiée MQTT
|
||||
uint32_t dernierPubliMs; // timestamp dernière publication
|
||||
bool erreur; // true si -127.0 ou 85.0 reçu
|
||||
};
|
||||
|
||||
// Point d'historique (tampon circulaire × 288)
|
||||
struct PointHistorique {
|
||||
uint32_t timestamp; // millis() au moment de l'acquisition
|
||||
float temps[3]; // NAN si sonde en erreur à ce moment
|
||||
};
|
||||
|
||||
// État réseau global
|
||||
struct NetworkStatus {
|
||||
bool wifiConnecte;
|
||||
bool modeAP; // true = fallback AP actif
|
||||
bool mqttConnecte;
|
||||
int8_t rssi;
|
||||
uint32_t uptimeMs;
|
||||
};
|
||||
|
||||
// Instances globales
|
||||
extern SondeConfig sondesConfig[3];
|
||||
extern SondeEtat sondesEtat[3];
|
||||
extern PointHistorique historique[288];
|
||||
extern uint16_t histIdx; // tête du buffer circulaire
|
||||
extern NetworkStatus netStatus;
|
||||
```
|
||||
|
||||
**Budget mémoire historique :** 288 × (4 + 3×4) = 288 × 16 = 4 608 octets ≈ 4,5 Ko sur 520 Ko SRAM → < 1%.
|
||||
|
||||
---
|
||||
|
||||
## 5. Machine à états réseau
|
||||
|
||||
```
|
||||
BOOT
|
||||
└─ tentative WiFi STA (30 000 ms timeout)
|
||||
├─ succès → WIFI_STA_OK
|
||||
│ ├─ mDNS (esp_jardin.local)
|
||||
│ ├─ ArduinoOTA
|
||||
│ ├─ WebServer + WebSocket
|
||||
│ └─ connexion MQTT (retry 5s non-bloquant)
|
||||
│
|
||||
└─ échec → WIFI_AP_FALLBACK
|
||||
├─ AP "ESP_CHEF_JARDIN" / "Jardin2026"
|
||||
├─ WebServer actif (pas de MQTT)
|
||||
└─ retry STA toutes les 60s (non-bloquant)
|
||||
|
||||
WIFI_STA_OK
|
||||
└─ déconnexion détectée → retry STA (non-bloquant, toutes les 30s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Flux de données dans `loop()`
|
||||
|
||||
```
|
||||
loop()
|
||||
├─ network.update() → ArduinoOTA.handle(), vérification WiFi, retry
|
||||
├─ sensors.update() → toutes les 10 000 ms :
|
||||
│ ├─ requestTemperatures() (non-bloquant avec setWaitForConversion(false))
|
||||
│ ├─ validation (-127.0 et 85.0 rejetés)
|
||||
│ ├─ écriture sondesEtat[]
|
||||
│ ├─ écriture historique[histIdx] + incrémentation circulaire
|
||||
│ └─ notifyClients() → push JSON WebSocket à tous les clients connectés
|
||||
└─ mqtt.update() → client.loop(), reconnexion, publication deadband
|
||||
```
|
||||
|
||||
### Format JSON WebSocket (push temps réel)
|
||||
|
||||
```json
|
||||
{
|
||||
"sondes": [
|
||||
{ "nom": "T°C Ext", "temp": 19.3, "erreur": false },
|
||||
{ "nom": "T°C Serre", "temp": 28.7, "erreur": false },
|
||||
{ "nom": "T°C Sol", "temp": null, "erreur": true }
|
||||
],
|
||||
"uptime": 3600,
|
||||
"rssi": -62
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoints REST
|
||||
|
||||
| Méthode | Chemin | Réponse |
|
||||
|---------|--------|---------|
|
||||
| GET | `/api/status` | `{ "rssi": -62, "uptime": 3600, "ramLibre": 187432, "modeAP": false, "mqttConnecte": true }` |
|
||||
| GET | `/api/temperatures` | `{ "sonde_1": 19.3, "sonde_2": 28.7, "sonde_3": null, "unit": "C" }` |
|
||||
| GET | `/api/history` | Tableau de 288 objets `{ "ts": 12345, "t": [19.3, 28.7, null] }` |
|
||||
| POST | `/api/config` | Body : `{ "intervalleMs": 10000, "mqttBroker": "10.0.0.3", "mqttPort": 1883 }` — appliqué immédiatement en RAM |
|
||||
|
||||
---
|
||||
|
||||
## 7. Publication MQTT
|
||||
|
||||
Chaque sonde publie indépendamment selon deux conditions cumulatives :
|
||||
1. `abs(tempActuelle - dernierPublie) >= deadband` **OU** `millis() - dernierPubliMs >= intervalleMax`
|
||||
2. WiFi STA connecté ET MQTT connecté
|
||||
|
||||
Reconnexion MQTT non-bloquante : tentative toutes les 5 000 ms via `millis()`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Interface web (`data/index.html`)
|
||||
|
||||
Page unique auto-contenue, servie depuis LittleFS. Chargement initial :
|
||||
1. Fetch `GET /api/history` → initialise le graphique Chart.js
|
||||
2. Connexion WebSocket `ws://[ip]/ws` → réceptions push temps réel
|
||||
|
||||
### Layout (Gruvbox Seventies design system)
|
||||
|
||||
```
|
||||
┌─ Header : titre + statut WiFi/RSSI + bouton config ──────────────────┐
|
||||
├─────────────────────────────────────────────────┬────────────────────┤
|
||||
│ 3 cartes sondes (grid 3 colonnes) │ Statut système │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ WiFi / IP / RSSI │
|
||||
│ │ 19.3 °C │ │ 28.7 °C │ │ ERREUR │ │ MQTT / RAM / uptime│
|
||||
│ │ sparkline│ │ sparkline│ │ câblage? │ ├────────────────────┤
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │ Configuration │
|
||||
│ │ intervalle / MQTT │
|
||||
│ Graphique 24h (Chart.js, 3 courbes) │ [Appliquer] │
|
||||
│ │ │
|
||||
├─────────────────────────────────────────────────┴────────────────────┤
|
||||
│ Status bar : MODE | ● MQTT | n sondes | esp_jardin.local | HH:MM:SS │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Règles design system :**
|
||||
- Variables CSS uniquement (`var(--accent)`, jamais de hex en dur)
|
||||
- `data-theme="dark"` sur `<html>`
|
||||
- Polices : Inter (UI), JetBrains Mono (valeurs numériques), Share Tech Mono (status bar, logs)
|
||||
- Tuiles : `border-radius: 12px`, classe `glass`
|
||||
- Sonde en erreur : bordure `var(--err)` + LED pulsante rouge
|
||||
|
||||
---
|
||||
|
||||
## 9. Gestion des erreurs
|
||||
|
||||
| Cas | Comportement firmware | Rendu UI |
|
||||
|---|---|---|
|
||||
| Sonde −127°C ou 85°C | `erreur = true`, valeur non écrite en RAM ni publiée MQTT | Carte rouge + "Sonde déconnectée" |
|
||||
| WiFi STA perdu | Retry non-bloquant toutes les 30s | LED header warn, barre "STA reconnexion…" |
|
||||
| MQTT injoignable | Retry non-bloquant toutes les 5s | Indicateur MQTT rouge |
|
||||
| AP fallback actif | WebServer opérationnel, MQTT désactivé | Barre de statut mode "AP" en orange |
|
||||
| WebSocket client déco | Cleanup automatique ESPAsyncWebServer | — |
|
||||
|
||||
**Logs série** : chaque événement logué avec `millis()` — acquisition, erreur sonde, changement WiFi, pub MQTT, connexion WS.
|
||||
|
||||
---
|
||||
|
||||
## 10. OTA
|
||||
|
||||
- Mot de passe firmware : `Jardin2026` (défini via `ArduinoOTA.setPassword("Jardin2026")`)
|
||||
- Mot de passe `platformio.ini` : `upload_flags = --auth=Jardin2026`
|
||||
- Upload firmware : `pio run -t upload --upload-port [ip]`
|
||||
- Upload filesystem : `pio run -t uploadfs --upload-port [ip]`
|
||||
|
||||
---
|
||||
|
||||
## 11. Hors scope V1
|
||||
|
||||
- Deep sleep / économie d'énergie (noté dans `amelioration.md`)
|
||||
- NTP (timestamps relatifs `millis()` en V1, epoch en V2)
|
||||
- Capteurs I2C (BH1750, SHT31), ADC sol, relais, pluviomètre
|
||||
- Authentification interface web
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# feature.md — esp_jardin v1.0
|
||||
|
||||
## Fonctionnalités implémentées
|
||||
|
||||
### Acquisition des températures
|
||||
- 3 sondes DS18B20 sur bus OneWire (GPIO 4) — adressage par index
|
||||
- Acquisition non-bloquante : demande de conversion + attente 750 ms via `millis()`
|
||||
- Fréquence : toutes les 10 secondes (configurable)
|
||||
- Validation métrologique : valeurs −127.0°C (CRC fail) et 85.0°C (power-on) rejetées
|
||||
- Précision affichée : 1 décimale (%.1f)
|
||||
|
||||
### Historique 24h en RAM
|
||||
- Tampon circulaire de 288 points (structure `PointHistorique`)
|
||||
- Empreinte mémoire : ~4.5 Ko sur 520 Ko SRAM (<1%)
|
||||
- Les valeurs d'erreur sont stockées comme `NAN` pour discontinuer les courbes
|
||||
|
||||
### Connectivité réseau hybride
|
||||
- **Mode STA** : connexion au réseau WiFi configuré, timeout 30s
|
||||
- **Mode AP de secours** : point d'accès `ESP_CHEF_JARDIN` si STA échoue
|
||||
- Reconnexion automatique STA non-bloquante (retry toutes les 30s / 60s en mode AP)
|
||||
- **mDNS** : accessible via `http://esp_jardin.local`
|
||||
- **OTA** : mise à jour firmware à distance, protégée par mot de passe
|
||||
|
||||
### Interface web temps réel
|
||||
- Page unique servie depuis LittleFS (`data/index.html`)
|
||||
- **WebSocket** : push JSON immédiat à chaque nouvelle acquisition
|
||||
- **Graphique 24h** (Chart.js) : initialisé depuis `/api/history`, mis à jour en temps réel
|
||||
- 3 cartes sondes avec valeur live, LED d'état, message d'erreur si sonde déconnectée
|
||||
- Panneau statut système : WiFi / IP / RSSI / MQTT / RAM libre / uptime
|
||||
- Console de configuration : intervalle + broker MQTT (appliqué en RAM)
|
||||
- Bascule thème dark/light
|
||||
- Responsive (grille adaptative sur mobile)
|
||||
|
||||
### API REST
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /api/status` | RSSI, uptime, RAM libre, mode AP, état MQTT |
|
||||
| `GET /api/temperatures` | Températures instantanées des 3 sondes |
|
||||
| `GET /api/history` | Historique complet 24h (288 points) |
|
||||
| `POST /api/config` | Mise à jour intervalle + broker MQTT |
|
||||
|
||||
### Publication MQTT
|
||||
- Client PubSubClient, reconnexion non-bloquante (retry toutes les 5s)
|
||||
- Publication indépendante par sonde
|
||||
- **Filtre deadband** : publish si `|ΔT| ≥ deadband` OU si `intervalle_max` dépassé
|
||||
- Messages retain=true
|
||||
- Valeurs d'erreur jamais publiées
|
||||
|
||||
## Hors scope v1
|
||||
- Deep sleep / économie d'énergie
|
||||
- NTP (timestamps relatifs `millis()`)
|
||||
- Capteurs I2C (BH1750, SHT31), sol (ADC), relais, pluviomètre
|
||||
- Authentification interface web
|
||||
- Persistance de la configuration (redémarrage → retour aux valeurs par défaut)
|
||||
@@ -0,0 +1,77 @@
|
||||
# plan.md — esp_jardin v1.0
|
||||
|
||||
Résumé du plan d'implémentation. Plan détaillé avec code complet :
|
||||
`docs/superpowers/plans/2026-05-23-esp-jardin-firmware.md`
|
||||
|
||||
## Stack technique retenu
|
||||
|
||||
| Besoin | Bibliothèque | Justification |
|
||||
|---|---|---|
|
||||
| HTTP + WebSocket async | `esp32async/ESPAsyncWebServer` | Fork actif (jan. 2025), non-bloquant |
|
||||
| TCP async | `esp32async/AsyncTCP` | Dépendance d'ESPAsyncWebServer |
|
||||
| Sondes DS18B20 | `paulstoffregen/OneWire` + `milesburton/DallasTemperature` | Standard, bien documenté |
|
||||
| MQTT | `knolleary/PubSubClient` | Plus documenté, non-bloquant avec millis() |
|
||||
| JSON | `bblanchon/ArduinoJson ^7` | Sérialization REST + WebSocket |
|
||||
| Filesystem | LittleFS (natif framework) | SPIFFS déprécié |
|
||||
| OTA + mDNS | ArduinoOTA + ESPmDNS (natifs) | Inclus dans Espressif32 |
|
||||
|
||||
## Architecture : structures globales partagées
|
||||
|
||||
Modules indépendants (.h / .cpp) communiquant via structs globales dans `config.h`.
|
||||
`main.cpp` = `setup()` + `loop()` uniquement.
|
||||
|
||||
```
|
||||
network_init() → setup()
|
||||
sensors_init() → setup()
|
||||
web_server_init()→ setup()
|
||||
mqtt_init() → setup()
|
||||
|
||||
network_update() → loop() — à chaque itération
|
||||
sensors_update() → bool → loop() — toutes les 10s, retourne true si nouvelle mesure
|
||||
└─ si true → web_server_notify_clients()
|
||||
mqtt_update() → loop() — à chaque itération
|
||||
```
|
||||
|
||||
## Séquence d'implémentation
|
||||
|
||||
| Tâche | Fichiers | Livrable |
|
||||
|---|---|---|
|
||||
| 1 | `platformio.ini`, `parametrage.md` | Config PlatformIO complète |
|
||||
| 2 | `include/config.h`, `src/main.cpp` | Structs globales, compilation OK |
|
||||
| 3 | `include/network.h`, `src/network.cpp` | WiFi STA/AP, mDNS, OTA |
|
||||
| 4 | `include/sensors.h`, `src/sensors.cpp` | DS18B20 non-bloquant, buffer circulaire |
|
||||
| 5 | `include/web_server.h`, `src/web_server.cpp` | HTTP REST + WebSocket |
|
||||
| 6 | `include/mqtt_manager.h`, `src/mqtt_manager.cpp` | MQTT + deadband |
|
||||
| 7 | `data/index.html` | Interface web complète |
|
||||
| 8 | Flash + validation série | Protocole de test robustesse |
|
||||
|
||||
## Principe non-bloquant (DS18B20)
|
||||
|
||||
```
|
||||
loop() itération N → sensors.requestTemperatures() (retour immédiat)
|
||||
loop() itération N+75 → getTempCByIndex() après 750 ms (conversion terminée)
|
||||
```
|
||||
|
||||
## Format JSON WebSocket (push)
|
||||
|
||||
```json
|
||||
{
|
||||
"sondes": [
|
||||
{ "nom": "T°C Ext", "temp": "19.3", "erreur": false },
|
||||
{ "nom": "T°C Serre", "temp": "28.7", "erreur": false },
|
||||
{ "nom": "T°C Sol", "temp": null, "erreur": true }
|
||||
],
|
||||
"uptime": 3600,
|
||||
"rssi": -62
|
||||
}
|
||||
```
|
||||
|
||||
## Commandes essentielles
|
||||
|
||||
```bash
|
||||
pio run # Compilation
|
||||
pio run -t upload # Flash firmware via USB
|
||||
pio run -t uploadfs # Flash filesystem LittleFS (index.html)
|
||||
pio device monitor # Moniteur série 115200 baud
|
||||
pio run -t upload --upload-port 10.0.0.42 # OTA après déploiement
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
; PlatformIO Project Configuration File
|
||||
;
|
||||
; Build options: build flags, source filter
|
||||
; Upload options: custom upload port, speed and extra flags
|
||||
; Library options: dependencies, extra library storages
|
||||
; Advanced options: extra scripting
|
||||
;
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
Reference in New Issue
Block a user