chore: ajout .gitignore, CLAUDE.md, design system et docs Phase 2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.superpowers/
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
screenshot.png
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project: HomeHub
|
||||||
|
|
||||||
|
HomeHub is a personal organization self-hosted PWA deployed via Docker Compose on Proxmox 9 (Debian 13). It targets heavy mobile usage with offline support.
|
||||||
|
|
||||||
|
## Architecture décidée
|
||||||
|
|
||||||
|
Two competing architectures are documented. The **retained one** (from `Consignes de Développement App Self-hosted.md`) is:
|
||||||
|
|
||||||
|
| Service | Stack |
|
||||||
|
|---------|-------|
|
||||||
|
| Frontend | React + Vite + TypeScript + Tailwind CSS (PWA) |
|
||||||
|
| Backend | FastAPI (Python) — async, SQLAlchemy 2.0 |
|
||||||
|
| Database | PostgreSQL 16 — multiple schemas (not multiple DBs) |
|
||||||
|
| Queue/Cache | Redis |
|
||||||
|
| Proxy | Traefik / OPNsense in front |
|
||||||
|
|
||||||
|
The alternative modular approach (Vikunja + PocketBase + Keeper.sh + SvelteKit in the brainstorming doc) was **not retained**.
|
||||||
|
|
||||||
|
### Backend structure (FastAPI)
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/ # Endpoints split by domain: auth, todos, shopping, notes, calendar, mcp
|
||||||
|
│ ├── core/ # Config, security, DB (SQLAlchemy async)
|
||||||
|
│ ├── models/ # SQLAlchemy models using PostgreSQL schemas
|
||||||
|
│ ├── schemas/ # Pydantic validation schemas
|
||||||
|
│ ├── services/ # Business logic (calendar sync, Hermes Vision integration)
|
||||||
|
│ └── main.py
|
||||||
|
├── Dockerfile
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL schemas
|
||||||
|
One PostgreSQL instance, logical separation via schemas: `auth`, `todos`, `shopping`, `notes`, `kanban`. This allows cross-schema joins while simplifying backups (`pg_dump` once).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- JWT authentication
|
||||||
|
- CORS allowing local network `10.0.0.0/22` only
|
||||||
|
|
||||||
|
## Design System — Gruvbox seventies
|
||||||
|
|
||||||
|
**All UI must use the design system files in `design_system/`.** Read `design_system/consigne_design_system.md` before writing any UI code.
|
||||||
|
|
||||||
|
### Absolute rules
|
||||||
|
1. **Always use CSS variables** — never hardcode hex colors. `color: var(--accent)` ✅, `color: #fe8019` ❌
|
||||||
|
2. **Always declare `data-theme="dark|light"`** on a parent element (`<html>` or a wrapper)
|
||||||
|
3. **Never reinvent existing components** — check `design_system/components/ui-kit.jsx` first (14 components: Button, IconButton, Toggle, Tooltip, StatusLed, BatteryGauge, RadialGauge, BigRadialGauge, Popup, TreeNav, Sparkline, LineChart, Icon…)
|
||||||
|
4. **Never use emoji or custom SVG** for icons — use `<Icon name="…">` with the mapped names
|
||||||
|
5. **No hover effects** on buttons/tiles (only 3D press on click via `.interactive`)
|
||||||
|
6. **Always use tooltips** on standalone icon buttons (`<IconButton>` requires `label`)
|
||||||
|
7. **Three fonts only**: `var(--font-ui)` (Inter, UI text), `var(--font-mono)` (JetBrains Mono, data/numbers), `var(--font-terminal)` (Share Tech Mono, logs/retro)
|
||||||
|
8. **Border radius**: tiles 10-12px, buttons 8px, pills 999px — never 24px+
|
||||||
|
|
||||||
|
### Key CSS tokens
|
||||||
|
- Backgrounds: `--bg-1` (app) → `--bg-2` (panels) → `--bg-3` (cards/tiles) → `--bg-4` (hover) → `--bg-5` (active)
|
||||||
|
- Text: `--ink-1` (primary) → `--ink-2` → `--ink-3` (labels) → `--ink-4` (disabled)
|
||||||
|
- Status: `--ok`, `--warn`, `--err`, `--info`
|
||||||
|
- `className="glass"` or `"glass-strong"` handles card styling (backdrop-filter + shadow)
|
||||||
|
|
||||||
|
### Design system files
|
||||||
|
- `design_system/tokens/tokens.css` — web CSS variables (dark + light)
|
||||||
|
- `design_system/tokens/tokens.gnome.css` — GTK 4 / libadwaita overrides
|
||||||
|
- `design_system/tokens/tokens.json` — generic format for Tailwind/Figma
|
||||||
|
- `design_system/components/ui-kit.jsx` — all 14 React components
|
||||||
|
- `design_system/examples/exemple-minimal.html` — reference demo
|
||||||
|
|
||||||
|
## Frontend PWA requirements
|
||||||
|
- Configure `@vite-pwa/plugin` with aggressive asset caching for offline use
|
||||||
|
- `manifest.json` with icons for iOS and Android
|
||||||
|
- Show a clean "Offline" state in UI
|
||||||
|
- Touch targets minimum 48px height (mobile-first)
|
||||||
|
- Swipe gestures on todo/shopping lists (left to postpone, right to complete)
|
||||||
|
- Wake Lock API on the shopping list active view (screen stays on in-store)
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
- **Gitea**: `gitea.maison43.duckdns.org` — webhook endpoint `/api/webhooks/gitea` for issue/PR sync to Kanban
|
||||||
|
- **Home Assistant**: `10.0.0.2:8123` — expose delayed task count as sensor via REST/MQTT
|
||||||
|
- **Agent Hermes / Vision LLM**: `POST /api/shopping/analyze-fridge` — sends fridge photo to LLM for missing ingredient suggestions
|
||||||
|
- **MCP server**: embedded in FastAPI — exposes `get_todos()`, `add_todo()`, `add_shopping_item()`, `search_notes()` tools
|
||||||
|
- **CalDAV**: `/api/caldav/` for native iOS subscription
|
||||||
|
- **Google Calendar**: OAuth2 per user + async background worker
|
||||||
|
|
||||||
|
## Development phases (reference)
|
||||||
|
1. Docker setup + PostgreSQL schemas + FastAPI auth + React PWA scaffold
|
||||||
|
2. Todos CRUD + Notes CRUD + mobile UI + full-text search (PostgreSQL FTS in French)
|
||||||
|
3. Shopping list (store mode + Wake Lock + frequency-based auto-fill + Hermes vision)
|
||||||
|
4. Calendar (CalDAV + Google OAuth2 sync worker)
|
||||||
|
5. MCP server + Gitea webhooks
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# HomeHub
|
||||||
|
|
||||||
|
Application d'organisation personnelle auto-hébergée — PWA mobile-first déployée sur Proxmox 9.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Todos** — tâches classées par domaine (informatique, DIY, jardinage, cuisine…)
|
||||||
|
- **Liste de courses** — générée depuis les habitudes d'achat, mode magasin avec Wake Lock, suivi des prix, OCR étiquettes et tickets
|
||||||
|
- **Notes** — saisie rapide avec photo, audio, GPS et métadonnées libres
|
||||||
|
- **MCP Server** — expose les données à des agents IA (Hermes, Claude, etc.)
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Composant | Technologie |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Frontend | React 18 + Vite + TypeScript + Tailwind CSS |
|
||||||
|
| Backend | Python 3.12 + FastAPI (async) |
|
||||||
|
| Base de données | PostgreSQL 16 (schémas multiples) |
|
||||||
|
| Migrations | Alembic |
|
||||||
|
| OCR | Tesseract 5 (service Docker dédié, partagé entre modules) |
|
||||||
|
| Scan code-barres | zxing-js (frontend, iOS + Android) |
|
||||||
|
| Catalogue produits | OpenFoodFacts API (~3M produits alimentaires) |
|
||||||
|
| Recherche image | SearXNG (auto-hébergé, fallback image produits) |
|
||||||
|
| Déploiement | Docker Compose · Nginx Proxy Manager |
|
||||||
|
| Design system | Gruvbox seventies (`design_system/`) |
|
||||||
|
|
||||||
|
## Démarrage rapide (développement local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copier la configuration
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Lancer l'environnement de développement
|
||||||
|
./dev.sh
|
||||||
|
|
||||||
|
# Backend disponible sur http://localhost:8000
|
||||||
|
# Frontend disponible sur http://localhost:3000
|
||||||
|
# Documentation API : http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
home_hub/
|
||||||
|
├── ocr/
|
||||||
|
│ ├── app.py # Service FastAPI OCR (Tesseract + Pillow)
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── product-search/
|
||||||
|
│ ├── app.py # Client OpenFoodFacts + proxy SearXNG images
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # Endpoints par domaine (todos, shopping, notes, media, mcp)
|
||||||
|
│ │ ├── core/ # Config, base de données, middleware
|
||||||
|
│ │ ├── models/ # Modèles SQLAlchemy
|
||||||
|
│ │ ├── schemas/ # Schémas Pydantic
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── media.py # Upload, compression, génération miniatures (Pillow)
|
||||||
|
│ │ ├── ocr.py # Client vers service ocr:8001
|
||||||
|
│ │ └── ... # Suggestions shopping, sync calendrier (futur)
|
||||||
|
│ ├── alembic/ # Migrations de base de données
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Composants React
|
||||||
|
│ │ ├── pages/ # Pages par module
|
||||||
|
│ │ ├── hooks/ # Hooks personnalisés (camera, geolocation, wake-lock…)
|
||||||
|
│ │ └── api/ # Client API typé
|
||||||
|
│ ├── public/
|
||||||
|
│ │ └── manifest.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── design_system/ # Design system Gruvbox seventies (tokens + composants)
|
||||||
|
├── docs/
|
||||||
|
│ ├── spec.md # Spécification fonctionnelle complète
|
||||||
|
│ └── plan.md # Plan de développement par phases
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docker-compose.dev.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Spécification fonctionnelle](docs/spec.md) — features détaillées, schéma DB, interfaces
|
||||||
|
- [Plan de développement](docs/plan.md) — phases et tâches
|
||||||
|
- [Design system](design_system/README.md) — composants et règles visuelles
|
||||||
|
- API REST : `http://localhost:8000/docs` (Swagger auto-généré par FastAPI)
|
||||||
|
|
||||||
|
## Déploiement (Proxmox)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Nginx Proxy Manager pointe vers :
|
||||||
|
# homehub.local → frontend:3000
|
||||||
|
# homehub.local/api → backend:8000
|
||||||
|
# homehub.local/mcp → backend:8000/mcp (pour les agents IA)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Évolutions prévues
|
||||||
|
|
||||||
|
- Authentification multi-utilisateurs (JWT)
|
||||||
|
- Sync Google Calendar + CalDAV iOS
|
||||||
|
- Intégration Home Assistant
|
||||||
|
- Webhooks Gitea → Kanban
|
||||||
|
- Analyse frigo par Vision LLM (Hermes/Ollama)
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
# HomeHub — Plan de développement
|
||||||
|
|
||||||
|
> Approche itérative : chaque phase livre quelque chose d'utilisable.
|
||||||
|
> Stack : FastAPI · React/Vite/TS · PostgreSQL 16 · Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Socle technique
|
||||||
|
|
||||||
|
**Objectif** : environnement opérationnel, rien de métier, mais tout tourne.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] Structure projet FastAPI (`app/api/`, `app/core/`, `app/models/`, `app/schemas/`, `app/services/`)
|
||||||
|
- [ ] Configuration SQLAlchemy 2.0 async + pool de connexions
|
||||||
|
- [ ] Création des schémas PostgreSQL (`todos`, `shopping`, `notes`) via Alembic
|
||||||
|
- [ ] Migration initiale : toutes les tables (voir spec section 3)
|
||||||
|
- [ ] Endpoint santé : `GET /api/health`
|
||||||
|
- [ ] Module media (`app/services/media.py`) :
|
||||||
|
- [ ] Validation des formats acceptés (JPG, PNG, SVG, WebP, WebM, M4A)
|
||||||
|
- [ ] Génération miniature Pillow : 150×150 (shopping), 300×300 (notes), 400×300 (inline)
|
||||||
|
- [ ] Sauvegarde `originals/` + `thumbnails/` sur le volume
|
||||||
|
- [ ] Endpoint `POST /api/media/upload` → retourne `{ file_path, thumbnail_path }`
|
||||||
|
- [ ] Endpoint `DELETE /api/media/{uuid}` → supprime original + miniature
|
||||||
|
- [ ] Middleware CORS pour réseau local `10.0.0.0/22`
|
||||||
|
- [ ] Configuration via variables d'environnement (`.env`)
|
||||||
|
- [ ] Dockerfile backend (Python 3.12 slim)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] Scaffold Vite + React 18 + TypeScript
|
||||||
|
- [ ] Tailwind CSS configuré avec les tokens Gruvbox (via `tokens.json`)
|
||||||
|
- [ ] Intégration `design_system/components/ui-kit.jsx`
|
||||||
|
- [ ] `@vite-pwa/plugin` configuré (Service Worker + manifest)
|
||||||
|
- [ ] `manifest.json` avec icônes iOS + Android
|
||||||
|
- [ ] Routage React Router v6
|
||||||
|
- [ ] Layout de base : navigation mobile (bottom bar) + navigation laptop (sidebar)
|
||||||
|
- [ ] Dockerfile frontend (Nginx)
|
||||||
|
|
||||||
|
### Infra
|
||||||
|
- [ ] `docker-compose.yml` avec 3 services (frontend, backend, db)
|
||||||
|
- [ ] Volume PostgreSQL persistant
|
||||||
|
- [ ] Volume uploads persistant (`/uploads/`)
|
||||||
|
- [ ] `docker-compose.dev.yml` avec hot reload (volumes montés)
|
||||||
|
- [ ] Fichier `.env.example`
|
||||||
|
- [ ] Script `dev.sh` pour démarrage local rapide
|
||||||
|
- [ ] Script de seed (`backend/app/data/seed.py`) : charge `seed_products.json` (113 produits) + `seed_stores.json` (9 magasins) en base au premier démarrage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Module Todos
|
||||||
|
|
||||||
|
**Objectif** : créer, lister, modifier, terminer et reporter des tâches depuis mobile et laptop.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `GET /api/todos` — liste avec filtres (domaine, statut, priorité, tags)
|
||||||
|
- [ ] `POST /api/todos` — création
|
||||||
|
- [ ] `PATCH /api/todos/{id}` — mise à jour partielle
|
||||||
|
- [ ] `DELETE /api/todos/{id}` — suppression
|
||||||
|
- [ ] `POST /api/todos/{id}/postpone` — incrémente postponed_count + décale due_date
|
||||||
|
- [ ] Schémas Pydantic : TodoCreate, TodoUpdate, TodoResponse
|
||||||
|
|
||||||
|
### Frontend Mobile
|
||||||
|
- [ ] Page Todos : liste des tâches en cours, groupées par domaine
|
||||||
|
- [ ] Bouton "+ Tâche" flottant → formulaire rapide (titre + domaine + date optionnelle)
|
||||||
|
- [ ] Swipe droite → marquer done
|
||||||
|
- [ ] Swipe gauche → actions (reporter 1j / reporter 1 semaine / supprimer)
|
||||||
|
- [ ] Badge compteur par domaine
|
||||||
|
|
||||||
|
### Frontend Laptop
|
||||||
|
- [ ] Vue tableau avec filtres : domaine, statut, priorité, tags, période
|
||||||
|
- [ ] Formulaire complet (tous les champs)
|
||||||
|
- [ ] Tri multi-colonnes
|
||||||
|
- [ ] Sélection multiple + actions groupées
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Module Notes / Listes diverses
|
||||||
|
|
||||||
|
**Objectif** : saisie rapide de notes avec photo, audio et GPS depuis smartphone.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `GET /api/notes` — liste avec search full-text + filtres tags/catégorie
|
||||||
|
- [ ] `POST /api/notes` — création
|
||||||
|
- [ ] `PATCH /api/notes/{id}` — mise à jour
|
||||||
|
- [ ] `DELETE /api/notes/{id}` — suppression
|
||||||
|
- [ ] `POST /api/notes/{id}/attachments` — upload fichier (image/audio)
|
||||||
|
- [ ] `DELETE /api/notes/{id}/attachments/{att_id}` — suppression pièce jointe
|
||||||
|
- [ ] Compression image Pillow → WebP (fallback serveur)
|
||||||
|
- [ ] Recherche FTS PostgreSQL français
|
||||||
|
|
||||||
|
### Frontend Mobile
|
||||||
|
- [ ] Page Notes : liste chronologique avec aperçu
|
||||||
|
- [ ] Bouton "+ Note" → formulaire rapide (contenu + tags)
|
||||||
|
- [ ] Bouton 📷 → Camera API (capture directe ou import galerie)
|
||||||
|
- [ ] Bouton 🎤 → MediaRecorder (enregistrement audio avec waveform visuel)
|
||||||
|
- [ ] Bouton 📍 → Geolocation API → affichage coordonnées
|
||||||
|
- [ ] Compression WebP côté client (Canvas API) avant upload
|
||||||
|
- [ ] Visionneuse photo inline
|
||||||
|
- [ ] Lecteur audio inline
|
||||||
|
- [ ] Bouton 📄 → OCR sur photo → texte extrait inséré dans le contenu de la note
|
||||||
|
|
||||||
|
### Frontend Laptop
|
||||||
|
- [ ] Grille notes avec vignettes photo
|
||||||
|
- [ ] Recherche full-text avec surlignage
|
||||||
|
- [ ] Filtres : catégorie, tags, avec photo, avec audio, avec GPS
|
||||||
|
- [ ] Formulaire étendu avec champs métadonnées libres (clé/valeur)
|
||||||
|
- [ ] Vue carte pour les notes avec GPS (Leaflet.js)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Module Shopping (base)
|
||||||
|
|
||||||
|
**Objectif** : liste de courses fonctionnelle en magasin depuis smartphone.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `GET /api/shopping/products` — catalogue avec recherche
|
||||||
|
- [ ] `POST /api/shopping/products` — ajout produit catalogue
|
||||||
|
- [ ] `PATCH /api/shopping/products/{id}` — modification produit
|
||||||
|
- [ ] `GET /api/shopping/stores` — liste magasins
|
||||||
|
- [ ] `POST /api/shopping/stores` — ajout magasin
|
||||||
|
- [ ] `GET /api/shopping/lists` — listes de courses
|
||||||
|
- [ ] `POST /api/shopping/lists` — création liste (avec génération auto depuis frequency_score)
|
||||||
|
- [ ] `GET /api/shopping/lists/{id}/items` — articles de la liste
|
||||||
|
- [ ] `POST /api/shopping/lists/{id}/items` — ajout article
|
||||||
|
- [ ] `PATCH /api/shopping/list_items/{id}` — cocher / modifier prix relevé
|
||||||
|
- [ ] `POST /api/shopping/lists/{id}/finish` — terminer les courses (report automatique articles non cochés → nouvelle liste)
|
||||||
|
- [ ] Logique de report : créer liste semaine+1 avec `carried_over = true` sur articles non cochés
|
||||||
|
|
||||||
|
### Frontend Mobile — Création liste
|
||||||
|
- [ ] Page accueil shopping : liste des semaines
|
||||||
|
- [ ] "Nouvelle liste" → picker semaine + suggestions top produits (frequency_score)
|
||||||
|
- [ ] Recherche produit dans catalogue + ajout 1 tap
|
||||||
|
- [ ] Ajout rapide produit hors catalogue → création à la volée dans catalogue
|
||||||
|
|
||||||
|
### Frontend Mobile — Mode courses
|
||||||
|
- [ ] Vue liste triée par catégorie/rayon
|
||||||
|
- [ ] Wake Lock API activée automatiquement à l'ouverture
|
||||||
|
- [ ] Grand bouton checkbox par article (48px+)
|
||||||
|
- [ ] Tap sur prix → champ numérique rapide
|
||||||
|
- [ ] Swipe gauche → retirer de la liste
|
||||||
|
- [ ] Bouton "Terminer les courses" avec confirmation
|
||||||
|
|
||||||
|
### Frontend Laptop — Gestion catalogue
|
||||||
|
- [ ] Tableau produits avec photo, marque, catégorie, frequency_score
|
||||||
|
- [ ] CRUD complet produits
|
||||||
|
- [ ] Gestion magasins
|
||||||
|
- [ ] Import/export catalogue CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4b — Module scan + enrichissement catalogue produits
|
||||||
|
|
||||||
|
**Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts, recherche image via SearXNG sur laptop.
|
||||||
|
|
||||||
|
### Services Docker
|
||||||
|
- [ ] Dockerfile `product-search/` : Python slim + `requests` + client OpenFoodFacts
|
||||||
|
- [ ] `GET /lookup?barcode={code}` → OpenFoodFacts barcode API
|
||||||
|
- [ ] `GET /search?q={nom}` → OpenFoodFacts text search
|
||||||
|
- [ ] `GET /image-search?q={nom}` → délègue à SearXNG (images uniquement)
|
||||||
|
- [ ] Ajout service `product-search` dans `docker-compose.yml` (port 8002)
|
||||||
|
- [ ] Ajout service `searxng` dans `docker-compose.yml` (image officielle, port 8080 interne)
|
||||||
|
- [ ] Configuration SearXNG : désactiver toutes les catégories sauf `images`
|
||||||
|
|
||||||
|
### Backend (endpoints proxy)
|
||||||
|
- [ ] `GET /api/products/lookup?barcode=` → proxifie vers product-search
|
||||||
|
- [ ] `GET /api/products/search?q=` → proxifie vers product-search
|
||||||
|
- [ ] `GET /api/products/image-search?q=` → proxifie vers product-search (laptop uniquement)
|
||||||
|
- [ ] `POST /api/products/import` → crée un produit dans le catalogue depuis les données OpenFoodFacts retournées
|
||||||
|
|
||||||
|
### Frontend Mobile — scan
|
||||||
|
- [ ] Intégration `zxing-js` (BarcodeReader via flux caméra)
|
||||||
|
- [ ] Bouton "Scanner" dans le formulaire d'ajout de produit
|
||||||
|
- [ ] Sur scan réussi → appel `/api/products/lookup` → auto-remplissage formulaire
|
||||||
|
- [ ] Si barcode inconnu → formulaire vide (saisie manuelle)
|
||||||
|
|
||||||
|
### Frontend Laptop — enrichissement catalogue
|
||||||
|
- [ ] Champ de recherche texte → `/api/products/search` → liste de résultats OpenFoodFacts
|
||||||
|
- [ ] Bouton "Chercher une image" → `/api/products/image-search` → grille de miniatures SearXNG
|
||||||
|
- [ ] Clic sur une image → téléchargement + génération miniature via module media
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Service OCR (conteneur dédié, partagé)
|
||||||
|
|
||||||
|
**Objectif** : service OCR partagé, prérequis pour le shopping avancé et les notes avancées.
|
||||||
|
|
||||||
|
- [ ] Dockerfile `ocr/` : image Python slim + Tesseract 5 + langues (fra, eng) + Pillow
|
||||||
|
- [ ] `POST /extract` : reçoit une image (multipart), retourne `{ text, confidence }`
|
||||||
|
- [ ] Pré-processing Pillow : redimensionnement, niveaux de gris, amélioration contraste
|
||||||
|
- [ ] Endpoint backend unifié `POST /api/ocr/extract` : proxifie vers `ocr:8001`
|
||||||
|
- [ ] Gestion du service inactif : retour d'erreur propre sans bloquer le module appelant
|
||||||
|
- [ ] Ajout du service `ocr` dans `docker-compose.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Module Shopping avancé (OCR + suivi prix)
|
||||||
|
|
||||||
|
**Objectif** : OCR étiquettes et tickets, graphiques d'évolution des prix.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix via service OCR
|
||||||
|
- [ ] `POST /api/shopping/ocr/receipt` — photo ticket → reconciliation avec liste
|
||||||
|
- [ ] `GET /api/shopping/products/{id}/price-history` — historique prix par produit + magasin
|
||||||
|
|
||||||
|
### Frontend Mobile
|
||||||
|
- [ ] Bouton 📷 sur chaque article → OCR étiquette → pré-remplissage prix
|
||||||
|
- [ ] Bouton "Scanner ticket" en fin de courses → OCR receipt → confirmation reconciliation
|
||||||
|
|
||||||
|
### Frontend Laptop
|
||||||
|
- [ ] Graphique courbe d'évolution du prix par produit (Recharts ou Chart.js)
|
||||||
|
- [ ] Comparaison prix par magasin
|
||||||
|
- [ ] Export CSV historique des prix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — MCP Server
|
||||||
|
|
||||||
|
**Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.).
|
||||||
|
|
||||||
|
- [ ] Intégration SDK MCP Python (`mcp` package)
|
||||||
|
- [ ] Endpoint SSE `/mcp`
|
||||||
|
- [ ] Implémentation des 6 outils (voir spec section 2.4)
|
||||||
|
- [ ] Tests avec Claude Code (MCP client)
|
||||||
|
- [ ] Documentation OpenAPI des outils MCP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases futures (hors scope initial)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases futures (hors scope initial)
|
||||||
|
|
||||||
|
### Phase 8 — Authentification multi-utilisateurs
|
||||||
|
- Auth JWT (login / refresh token)
|
||||||
|
- Activation de `owner_id` dans toutes les tables
|
||||||
|
- Middleware d'authentification FastAPI
|
||||||
|
- Page connexion React
|
||||||
|
|
||||||
|
### Phase 9 — Calendrier + intégrations
|
||||||
|
- Sync Google Calendar (OAuth2 + worker)
|
||||||
|
- Endpoint CalDAV pour iOS
|
||||||
|
- Redis pour la queue de synchronisation
|
||||||
|
- Webhooks Gitea → Kanban
|
||||||
|
- Home Assistant : capteur "tâches en retard"
|
||||||
|
|
||||||
|
### Phase 10 — Vision IA
|
||||||
|
- Endpoint analyse frigo (photo → suggestions liste de courses)
|
||||||
|
- Amélioration OCR via modèle Vision local (Ollama)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre de développement recommandé
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 → Phase 2 → Phase 4 (shopping base) → Phase 4b (scan + OpenFoodFacts) → Phase 3 (notes) → Phase 5 (OCR) → Phase 6 (shopping avancé) → Phase 7 (MCP)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale : les todos sont les plus simples (validation du socle), le shopping mobile de base est prioritaire pour usage réel, les notes viennent ensuite, le service OCR est posé avant d'en avoir besoin, puis les features avancées.
|
||||||
+388
@@ -0,0 +1,388 @@
|
|||||||
|
# HomeHub — Spécification Fonctionnelle
|
||||||
|
|
||||||
|
> Version 0.1 — mai 2026
|
||||||
|
> Application d'organisation personnelle auto-hébergée (PWA)
|
||||||
|
> Stack : FastAPI · React/Vite/TypeScript · PostgreSQL 16 · Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte et objectifs
|
||||||
|
|
||||||
|
HomeHub est une PWA auto-hébergée sur Proxmox 9, conçue pour un usage intensif mobile (smartphone en magasin, sur le terrain) et une consultation plus complète sur laptop. L'application est **mono-utilisateur** au démarrage, avec une architecture prévue pour évoluer vers le multi-utilisateur sans refactoring majeur (colonne `owner_id` nullable dans toutes les tables).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture technique
|
||||||
|
|
||||||
|
### 2.1 Services Docker Compose (6 services)
|
||||||
|
|
||||||
|
| Service | Image | Port interne | Rôle |
|
||||||
|
|---------|-------|-------------|------|
|
||||||
|
| `frontend` | Nginx léger | 3000 | Sert le build React/Vite (PWA statique) |
|
||||||
|
| `backend` | Python 3.12 / FastAPI | 8000 | API REST + MCP server |
|
||||||
|
| `db` | PostgreSQL 16 | 5432 | Base de données multi-schémas |
|
||||||
|
| `ocr` | Tesseract + Pillow | 8001 | Service OCR partagé (toutes les photos de l'app) |
|
||||||
|
| `product-search` | Python slim | 8002 | Lookup OpenFoodFacts + fallback SearXNG |
|
||||||
|
| `searxng` | SearXNG officiel | 8080 | Métamoteur de recherche — fallback image produits |
|
||||||
|
|
||||||
|
**Services isolés et arrêtables indépendamment :**
|
||||||
|
- `ocr` (4) — arrêtable sans impact sur la liste de courses. Seule la saisie OCR de prix devient indisponible
|
||||||
|
- `product-search` (5) — arrêtable sans impact. Seule la recherche OpenFoodFacts devient indisponible
|
||||||
|
- `searxng` (6) — arrêtable sans impact. Seul le fallback image produit devient indisponible
|
||||||
|
|
||||||
|
Les services 1/2/3 (`frontend`, `backend`, `db`) sont les seuls **obligatoires** pour le fonctionnement de base. Un `docker compose stop ocr product-search searxng` laisse l'app 100% fonctionnelle pour la liste de courses.
|
||||||
|
|
||||||
|
**Nginx Proxy Manager** (déjà en place sur le homelab) gère l'entrée HTTPS et le routage vers ces services.
|
||||||
|
|
||||||
|
### 2.2 Évolutions prévues (non bloquantes)
|
||||||
|
|
||||||
|
- Redis → ajouté avec la sync Google Calendar (Phase 6)
|
||||||
|
- Auth JWT → activation de `owner_id` + écran de connexion (Phase 7)
|
||||||
|
- Agent Hermes (Vision LLM) → analyse photo frigo
|
||||||
|
- Webhooks Gitea → intégration Kanban
|
||||||
|
- Home Assistant → capteurs tâches en retard
|
||||||
|
|
||||||
|
### 2.3 Stockage fichiers et module media
|
||||||
|
|
||||||
|
Volume Docker `/uploads/` servi par le backend. Toutes les images passent par le module `media` du backend à la réception.
|
||||||
|
|
||||||
|
**Pipeline de traitement à l'upload :**
|
||||||
|
1. Compression côté client (Canvas API → WebP) avant envoi — réduit la bande passante
|
||||||
|
2. Validation format serveur (JPG, PNG, SVG, WebP acceptés)
|
||||||
|
3. Génération automatique d'une miniature (Pillow) — stockée séparément
|
||||||
|
4. Les deux fichiers sont enregistrés sur le volume
|
||||||
|
|
||||||
|
**Structure du volume :**
|
||||||
|
```
|
||||||
|
/uploads/
|
||||||
|
images/
|
||||||
|
originals/{uuid}.webp ← image compressée (pleine résolution utile)
|
||||||
|
thumbnails/{uuid}_thumb.webp ← miniature générée par Pillow
|
||||||
|
audio/
|
||||||
|
{uuid}.webm ← enregistrements audio
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tailles de miniatures par contexte :**
|
||||||
|
|
||||||
|
| Contexte | Taille thumbnail |
|
||||||
|
|----------|-----------------|
|
||||||
|
| Produit catalogue shopping | 150 × 150 px (carré, centré) |
|
||||||
|
| Note (vignette liste) | 300 × 300 px (carré, centré) |
|
||||||
|
| Pièce jointe (aperçu inline) | 400 × 300 px (paysage, centré) |
|
||||||
|
|
||||||
|
Les miniatures sont toujours servies pour les vues liste/grille. L'original n'est chargé qu'à la demande (plein écran, zoom). Formats audio acceptés : WebM, M4A.
|
||||||
|
|
||||||
|
### 2.4 MCP Server
|
||||||
|
|
||||||
|
Intégré dans FastAPI sur `/mcp` (protocole SSE). Expose les outils suivants aux agents IA (Hermes, Claude, etc.) :
|
||||||
|
|
||||||
|
| Outil | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `get_todos()` | Retourne les tâches urgentes / en cours |
|
||||||
|
| `add_todo(title, due_date)` | Crée une tâche |
|
||||||
|
| `get_shopping_list()` | Retourne la liste de courses active |
|
||||||
|
| `add_shopping_item(name, category)` | Ajoute un article à la liste active |
|
||||||
|
| `search_notes(query)` | Recherche full-text dans les notes |
|
||||||
|
| `add_note(title, content, tags)` | Crée une note |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schéma de base de données
|
||||||
|
|
||||||
|
### 3.1 Schema `shopping`
|
||||||
|
|
||||||
|
#### `shopping.products` — Catalogue global
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| name | VARCHAR(150) | Nom produit |
|
||||||
|
| brand | VARCHAR(100) | Marque |
|
||||||
|
| category | VARCHAR(50) | Rayon magasin (tri in-store) |
|
||||||
|
| image_path | VARCHAR(255) | Chemin original `/uploads/images/originals/` |
|
||||||
|
| thumbnail_path | VARCHAR(255) | Chemin miniature `/uploads/images/thumbnails/` |
|
||||||
|
| default_unit | VARCHAR(20) | kg / L / unité |
|
||||||
|
| barcode | VARCHAR(50) | Code-barres (scan futur) |
|
||||||
|
| frequency_score | INT DEFAULT 0 | Score d'habitude (suggestions auto) |
|
||||||
|
| owner_id | UUID NULL | Prévu multi-user |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
**Catégories produits (tri rayon en magasin) :**
|
||||||
|
`Fruits` · `Légumes` · `Viandes` · `Charcuterie` · `Poissons` · `Produits laitiers` · `Boulangerie` · `Épicerie salée` · `Épicerie sucrée` · `Condiments` · `Boissons` · `Entretien` · `Pharmacie` · `Animaux` · `Carburant` · `Électronique` · `Divers`
|
||||||
|
|
||||||
|
**Magasins pré-configurés (seed) :** Lidl · Intermarché · Super U · Gamm Vert · Weldom · Cosi · Bricocash · Tinel · Marie Blachère
|
||||||
|
|
||||||
|
**Données de démarrage :** 113 produits dans `backend/app/data/seed_products.json`, 9 magasins dans `seed_stores.json` — chargés automatiquement au premier démarrage.
|
||||||
|
|
||||||
|
#### `shopping.stores` — Magasins
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| name | VARCHAR(100) | Ex: Lidl, Carrefour |
|
||||||
|
| location | TEXT | Adresse ou coordonnées GPS |
|
||||||
|
| owner_id | UUID NULL | |
|
||||||
|
|
||||||
|
#### `shopping.price_history` — Historique des prix
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| product_id | UUID → products | |
|
||||||
|
| store_id | UUID → stores NULL | |
|
||||||
|
| price | NUMERIC(8,2) | Prix relevé |
|
||||||
|
| unit | VARCHAR(20) | kg / L / unité |
|
||||||
|
| quantity | NUMERIC(8,3) | Quantité associée |
|
||||||
|
| source | VARCHAR(20) | `manual` / `ocr_tag` / `ocr_receipt` |
|
||||||
|
| recorded_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
#### `shopping.lists` — Listes de courses
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| name | VARCHAR(100) | Ex: "Semaine du 26 mai" |
|
||||||
|
| store_id | UUID NULL | Magasin cible |
|
||||||
|
| week_date | DATE | Date de la semaine cible |
|
||||||
|
| status | VARCHAR(20) | `draft` / `active` / `done` |
|
||||||
|
| owner_id | UUID NULL | |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
#### `shopping.list_items` — Articles dans une liste
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| list_id | UUID → lists | |
|
||||||
|
| product_id | UUID → products NULL | Lié au catalogue (optionnel) |
|
||||||
|
| custom_name | VARCHAR(150) | Si article hors catalogue |
|
||||||
|
| quantity | NUMERIC(8,3) | |
|
||||||
|
| unit | VARCHAR(20) | |
|
||||||
|
| is_checked | BOOLEAN DEFAULT FALSE | Coché en magasin |
|
||||||
|
| price_recorded | NUMERIC(8,2) | Prix relevé pendant les courses |
|
||||||
|
| carried_over | BOOLEAN DEFAULT FALSE | Reporté depuis la semaine précédente |
|
||||||
|
| sort_order | INT | Tri par rayon/catégorie |
|
||||||
|
|
||||||
|
### 3.2 Schema `todos`
|
||||||
|
|
||||||
|
#### `todos.items`
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| title | VARCHAR(255) | |
|
||||||
|
| body | TEXT | Texte libre |
|
||||||
|
| url | TEXT | Lien optionnel |
|
||||||
|
| domain | VARCHAR(50) | Voir liste des domaines |
|
||||||
|
| category | VARCHAR(50) | Sous-catégorie libre |
|
||||||
|
| tags | VARCHAR(50)[] | Array · index GIN |
|
||||||
|
| status | VARCHAR(20) | `pending` / `done` / `cancelled` |
|
||||||
|
| priority | VARCHAR(10) | `low` / `medium` / `high` |
|
||||||
|
| due_date | TIMESTAMPTZ | Date objectif |
|
||||||
|
| postponed_count | INT DEFAULT 0 | Nombre de reports |
|
||||||
|
| created_at | TIMESTAMPTZ DEFAULT NOW() | Auto |
|
||||||
|
| updated_at | TIMESTAMPTZ | |
|
||||||
|
| owner_id | UUID NULL | |
|
||||||
|
|
||||||
|
**Domaines disponibles** : `informatique` · `diy` · `electronique` · `domotique` · `bricolage` · `jardin` · `cuisine` · `voyage` · `animaux`
|
||||||
|
|
||||||
|
### 3.3 Schema `notes`
|
||||||
|
|
||||||
|
#### `notes.items`
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| title | VARCHAR(255) | Optionnel |
|
||||||
|
| content | TEXT NOT NULL | Texte de la note |
|
||||||
|
| category | VARCHAR(50) | |
|
||||||
|
| tags | VARCHAR(50)[] | Index GIN |
|
||||||
|
| gps_lat | NUMERIC(10,7) | Latitude GPS |
|
||||||
|
| gps_lon | NUMERIC(10,7) | Longitude GPS |
|
||||||
|
| metadata | JSONB | Paires clé/valeur libres (référence, magasin…) |
|
||||||
|
| created_at | TIMESTAMPTZ DEFAULT NOW() | |
|
||||||
|
| owner_id | UUID NULL | |
|
||||||
|
|
||||||
|
Index : `GIN(tags)` + `GIN(to_tsvector('french', title || ' ' || content))` (recherche full-text)
|
||||||
|
|
||||||
|
#### `notes.attachments`
|
||||||
|
| Colonne | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| note_id | UUID → items | |
|
||||||
|
| file_path | VARCHAR(255) | Chemin original `/uploads/images/originals/` |
|
||||||
|
| thumbnail_path | VARCHAR(255) | Chemin miniature (NULL si audio) |
|
||||||
|
| file_type | VARCHAR(20) | `image` / `audio` |
|
||||||
|
| original_name | VARCHAR(255) | |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modules fonctionnels
|
||||||
|
|
||||||
|
### 4.1 Module Shopping — Liste de courses
|
||||||
|
|
||||||
|
#### Principe fondamental — ne jamais bloquer
|
||||||
|
|
||||||
|
> **L'objectif principal est simple : créer une liste en début de semaine, cocher les articles pendant les courses.**
|
||||||
|
> Toutes les autres fonctionnalités (prix, OCR, stats, suggestions automatiques) sont **optionnelles** et n'apparaissent jamais dans le chemin principal. Un utilisateur qui ignore tout ça doit pouvoir utiliser l'app sans friction.
|
||||||
|
|
||||||
|
#### Concept général
|
||||||
|
La liste de courses est générée **à partir du catalogue global** de produits. Le `frequency_score` de chaque produit augmente à chaque achat — ce score alimente les suggestions automatiques de la semaine. L'utilisateur valide/modifie/complète la liste avant de partir en courses.
|
||||||
|
|
||||||
|
Les articles **non cochés** à la fin des courses sont automatiquement reportés (`carried_over = true`) dans la liste de la semaine suivante.
|
||||||
|
|
||||||
|
La saisie de prix est **toujours optionnelle** — le champ prix est discret, accessible d'un tap long ou d'un bouton secondaire, jamais dans le chemin de cocher un article.
|
||||||
|
|
||||||
|
#### Interface mobile (priorité)
|
||||||
|
- **Création de liste** : interface ultra-simple — picker de la semaine, suggestion automatique des produits habituels (top frequency_score), ajout rapide d'un produit (recherche dans le catalogue ou création à la volée)
|
||||||
|
- **Mode courses** (liste active) :
|
||||||
|
- Grands boutons tactiles (min 48px)
|
||||||
|
- Articles triés par rayon/catégorie
|
||||||
|
- Tap pour cocher un article
|
||||||
|
- Swipe gauche → supprimer de la liste
|
||||||
|
- Champ prix rapide au tap (clavier numérique)
|
||||||
|
- **Wake Lock API** : l'écran ne se verrouille pas pendant les courses
|
||||||
|
- Bouton "Terminer les courses" → génère la liste suivante avec les articles non cochés
|
||||||
|
|
||||||
|
#### Interface laptop (complète)
|
||||||
|
- Vue tableau avec filtre par magasin, catégorie, statut
|
||||||
|
- Gestion du catalogue global de produits (CRUD complet)
|
||||||
|
- Gestion des magasins
|
||||||
|
- Graphiques d'évolution des prix par produit
|
||||||
|
- Export CSV de l'historique des prix
|
||||||
|
|
||||||
|
#### Module OCR (service Docker isolé, partagé entre tous les modules)
|
||||||
|
|
||||||
|
Le service OCR tourne dans son propre conteneur Docker (`ocr:8001`). Il est utilisable par tous les modules de l'application via l'endpoint unifié `POST /api/ocr/extract` du backend.
|
||||||
|
|
||||||
|
| Module | Usage OCR |
|
||||||
|
|--------|-----------|
|
||||||
|
| Shopping | Lecture étiquette prix en rayon → pré-remplissage prix |
|
||||||
|
| Shopping | Lecture ticket de caisse → réconciliation finale liste |
|
||||||
|
| Notes | Extraction texte depuis une photo (photo de document, panneau, référence) |
|
||||||
|
| Futur | Tout import photo dans l'application |
|
||||||
|
|
||||||
|
Le module OCR est **toujours optionnel** : si le service est arrêté, les autres fonctionnalités de l'app ne sont pas impactées. Backend : Tesseract (local). Fallback possible : Ollama Vision (Hermes) en remplaçant simplement le service `ocr` dans le Docker Compose.
|
||||||
|
|
||||||
|
#### Suivi des prix
|
||||||
|
Chaque prix saisi (manuel, OCR étiquette ou OCR ticket) alimente `price_history`. Sur laptop, un graphique en courbe par produit montre l'évolution du prix dans le temps, par magasin.
|
||||||
|
|
||||||
|
### 4.2 Module Notes / Listes diverses
|
||||||
|
|
||||||
|
Saisie rapide de notes avec support multimédia et géolocalisation.
|
||||||
|
|
||||||
|
**Champs** :
|
||||||
|
- Titre (optionnel)
|
||||||
|
- Contenu texte libre (saisie rapide)
|
||||||
|
- Date (auto à la création, modifiable)
|
||||||
|
- Catégorie
|
||||||
|
- Tags (multi-valeurs, autocomplétion)
|
||||||
|
- Photo(s) : capture directe via Camera API ou import depuis galerie. Compression WebP avant upload
|
||||||
|
- Audio : enregistrement via MediaRecorder API. Format WebM/M4A
|
||||||
|
- GPS : bouton "Localiser" → Geolocation API → stocke lat/lon, affiche adresse via reverse geocoding (optionnel)
|
||||||
|
- Métadonnées libres : paires clé/valeur (ex: "Référence: X12-34", "Magasin: Brico Dépôt")
|
||||||
|
|
||||||
|
**Recherche** : full-text PostgreSQL en français sur titre + contenu + métadonnées. Filtres par catégorie, tags, présence de photo/audio/GPS.
|
||||||
|
|
||||||
|
### 4.3 Module Todos
|
||||||
|
|
||||||
|
Gestion de tâches classées par domaine.
|
||||||
|
|
||||||
|
**Champs** :
|
||||||
|
- Titre
|
||||||
|
- Texte libre (description)
|
||||||
|
- URL (lien externe optionnel)
|
||||||
|
- Domaine (liste fermée : voir section 3.2)
|
||||||
|
- Catégorie (libre)
|
||||||
|
- Tags (multi-valeurs)
|
||||||
|
- Statut : `pending` / `done` / `cancelled`
|
||||||
|
- Priorité : `low` / `medium` / `high`
|
||||||
|
- Date de création (auto)
|
||||||
|
- Date objectif (due_date)
|
||||||
|
- Compteur de reports (`postponed_count`)
|
||||||
|
|
||||||
|
**Interface mobile** : ajout rapide en 1 clic, vue liste épurée, actions swipe (reporter / terminer). Boutons "Reporter d'1 jour", "Reporter à la semaine prochaine".
|
||||||
|
|
||||||
|
**Interface laptop** : vue complète avec filtres multi-critères (domaine, statut, priorité, tags), tri, recherche textuelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Module scan code-barres / QR code
|
||||||
|
|
||||||
|
### Fonctionnement
|
||||||
|
- Librairie JS `zxing-js` (open-source, cross-platform) intégrée dans le frontend
|
||||||
|
- Accès via le flux Camera API (pas de capture photo — lecture en temps réel)
|
||||||
|
- Fonctionne sur iOS Safari et Android Chrome
|
||||||
|
- Déclenché par un bouton 📷 "Scanner" dans le formulaire d'ajout de produit
|
||||||
|
|
||||||
|
### Flux de résolution produit
|
||||||
|
```
|
||||||
|
Scan code-barres (EAN-13 / QR)
|
||||||
|
→ service product-search : GET /lookup?barcode={code}
|
||||||
|
→ 1. OpenFoodFacts API (base ~3M produits alimentaires)
|
||||||
|
→ trouvé : nom, marque, catégorie, image → auto-remplit le formulaire
|
||||||
|
→ 2. Si pas trouvé : retourne vide → saisie manuelle
|
||||||
|
→ enrichissement optionnel via recherche texte + SearXNG (laptop uniquement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service `product-search` (Docker)
|
||||||
|
API Python légère qui centralise :
|
||||||
|
- `GET /lookup?barcode={code}` → OpenFoodFacts par code-barres
|
||||||
|
- `GET /search?q={nom}` → OpenFoodFacts par nom de produit
|
||||||
|
- `GET /image-search?q={nom}` → SearXNG image search (fallback, laptop uniquement)
|
||||||
|
|
||||||
|
Le backend FastAPI expose ces fonctionnalités via `/api/products/lookup`, `/api/products/search`, `/api/products/image-search`.
|
||||||
|
|
||||||
|
### Service `searxng` (Docker)
|
||||||
|
Instance SearXNG auto-hébergée, utilisée exclusivement pour la recherche d'images de produits non trouvés dans OpenFoodFacts. Accessible uniquement depuis l'interface laptop lors de l'enrichissement du catalogue. Non exposé publiquement via NPM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Capacités natives smartphone (PWA)
|
||||||
|
|
||||||
|
| API navigateur | Usage |
|
||||||
|
|---------------|-------|
|
||||||
|
| Camera API | Capture photo directe (notes, OCR, produits) |
|
||||||
|
| MediaRecorder API | Enregistrement audio (notes vocales) |
|
||||||
|
| Geolocation API | Localisation GPS sur les notes |
|
||||||
|
| Wake Lock API | Écran actif pendant les courses |
|
||||||
|
| Web Share API | Partage de notes/listes vers autres apps |
|
||||||
|
| Liens `webcal://` | Abonnement calendrier natif iOS/Android (futur) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design system
|
||||||
|
|
||||||
|
Le design system **Gruvbox seventies** est intégré dès le départ.
|
||||||
|
|
||||||
|
- Fichiers : `design_system/tokens/tokens.css` · `design_system/components/ui-kit.jsx`
|
||||||
|
- Thème : dark par défaut, light disponible via `data-theme`
|
||||||
|
- Palette : orange brûlé `#fe8019` (accent), fond brun `#2a231d`, texte `#f2e5c7`
|
||||||
|
- 14 composants React disponibles (Button, IconButton, Toggle, StatusLed, BatteryGauge, RadialGauge, Popup, TreeNav, Sparkline, LineChart, Icon…)
|
||||||
|
- Fonts : Inter (UI) · JetBrains Mono (données) · Share Tech Mono (terminal/logs)
|
||||||
|
- Règles absolues : jamais de hex en dur, toujours `var(--token)`, `data-theme` obligatoire sur un parent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Interface utilisateur — principes
|
||||||
|
|
||||||
|
### Mobile-first
|
||||||
|
- Touch target minimum : 48px
|
||||||
|
- Composants d'action accessibles en 1 main
|
||||||
|
- Swipe sur les listes (reporter, terminer, supprimer)
|
||||||
|
- Chargement hors-ligne via Service Worker (cache assets + données critiques)
|
||||||
|
- Feedback haptique si disponible
|
||||||
|
|
||||||
|
### Distinction mobile / laptop
|
||||||
|
Certains modules ont deux pages dédiées selon la taille d'écran :
|
||||||
|
|
||||||
|
| Module | Mobile | Laptop |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Shopping | Création liste simple + mode courses | Catalogue produits + gestion magasins + graphiques prix |
|
||||||
|
| Todos | Ajout rapide + liste swipeable | Tableau filtrable multi-critères |
|
||||||
|
| Notes | Saisie rapide + media capture | Liste complète + recherche avancée |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Hors scope initial (Phase 1-5)
|
||||||
|
|
||||||
|
- Authentification multi-utilisateurs (Phase 7)
|
||||||
|
- Google Calendar / CalDAV (Phase 6)
|
||||||
|
- Kanban / Gitea webhooks (Phase 8)
|
||||||
|
- Home Assistant (Phase 8)
|
||||||
|
- Analyse frigo par Vision LLM / Hermes (Phase 9)
|
||||||
|
- Scan code-barres produits
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user