Compare commits

...

23 Commits

Author SHA1 Message Date
gilles 4518ed8311 chore(design): ajout du package design system smartphone
Contient les tokens, composants et exemples adaptés au mobile,
à utiliser comme référence lors du développement des vues smartphone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:53:36 +02:00
gilles 4c616fa5d3 fix(ui): bouton + laptop, overflow mobile, clôture semaine, backup complet
- TodosPage/ShoppingPage : bouton « + » visible en laptop (hidden lg:flex)
- ShoppingPage : renomme « Terminer » en « Clôturer la semaine », badge ⚠
  si semaine ISO dépassée, confirmation modale avec décompte non-cochés
- NotesPage : overflowWrap:anywhere sur titre/contenu/markdown (URLs longues
  qui débordaient hors de la tuile sur smartphone)
- index.css : overflow-x:hidden + max-width:100vw sur html/body (garde-fou global)
- admin.py : backup remplacé par archive .tar.gz (pg_dump + uploads/) streamée
  au navigateur ; restore via multipart upload avec extraction sécurisée
- admin.ts : downloadBackup() (blob trigger) + uploadAndRestore() avec progression XHR
- ConfigPage : refonte section backup avec boutons Télécharger/Restaurer
  et barre de progression upload

v0.5.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:52:37 +02:00
gilles 69c2042995 fix(mcp): nginx Host=localhost pour passer la protection DNS rebinding FastMCP 2026-05-28 10:42:57 +02:00
gilles 20483dc5f9 fix(mcp): désactiver DNS rebinding protection (auth Bearer suffisant) 2026-05-28 10:40:44 +02:00
gilles 727ebc6484 fix(mcp): démarrer session_manager dans le lifespan FastAPI 2026-05-28 10:31:34 +02:00
gilles 39939b9621 fix(mcp): streamable_http_path=/ pour mount FastAPI à /mcp 2026-05-28 10:05:52 +02:00
gilles d50d659daf fix: autoriser 10.0.1.45 dans CORS_ORIGINS 2026-05-28 06:58:00 +02:00
gilles 0a798d2791 chore: bump version — MCP server v0.5.10 2026-05-28 06:54:46 +02:00
gilles 828efb9dd8 fix(mcp): MCP_API_KEY via variable d'environnement (pas en clair dans docker-compose) 2026-05-28 06:53:36 +02:00
gilles 8ebdccb543 feat(mcp): câblage FastAPI + nginx proxy + docker-compose MCP_API_KEY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:49:50 +02:00
gilles 5d7dbec67c fix(mcp): status active + search_products guard + item.product + cleanup auto-name 2026-05-28 06:41:04 +02:00
gilles 87efbcb03d feat(mcp): 6 outils shopping + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:37:38 +02:00
gilles c72ffd0ad6 fix(mcp): FTS colonnes non qualifiées + test positif search_notes 2026-05-28 06:29:36 +02:00
gilles e902452781 feat(mcp): 5 outils notes + tests
Ajoute search_notes, get_note, create_note, update_note, delete_note au serveur MCP.
Tests: 6 nouveaux tests notes (13 tests MCP au total, tous passent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:11:15 +02:00
gilles 6cd866c77a fix(mcp): scope fixture NullPool + suppression imports inutiles + validation enums + cleanup tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:03:43 +02:00
gilles 05db49f27a feat(mcp): 5 outils todos + tests
Ajoute mcp_server.py avec get_todos, create_todo, update_todo, postpone_todo, delete_todo.
Ajoute test_mcp.py (7 tests). Corrige conftest pour injecter NullPool dans AsyncSessionLocal des outils MCP (évite les conflits d'event loop entre tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:58:20 +02:00
gilles 24598c836b fix(mcp): comparaison constante hmac + rejet si clé vide + WWW-Authenticate
Fixes deux vulnérabilités critiques en sécurité:

1. **Timing attack** — remplace la comparaison naïve `!=` par
   `hmac.compare_digest()` pour éviter les attaques temporelles
   (constant-time comparison).

2. **Clé vide acceptée** — ajoute le check `not settings.mcp_api_key`
   pour rejeter (401) TOUS les requêtes `/mcp` si MCP_API_KEY n'est
   pas configurée, empêchant l'accès unauthenticated silencieux.

Bonus: ajoute l'en-tête `www-authenticate: Bearer` (RFC 9110).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:49:45 +02:00
gilles cc8fc5ba3f feat(mcp): middleware ASGI Bearer token pour /mcp* 2026-05-25 22:47:24 +02:00
gilles 6ff7c2f74e fix(mcp): contrainte version mcp<2.0 + MCP_API_KEY dans .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:46:36 +02:00
gilles 48e1b5343e feat(mcp): dépendance mcp>=1.9 + champ mcp_api_key dans Settings 2026-05-25 22:44:57 +02:00
gilles bbf264fb61 docs: plan d'implémentation MCP server (16 outils + tutoriel Hermes) 2026-05-25 22:40:41 +02:00
gilles b8d89acafa docs: ajout clé MCP_API_KEY générée dans la spec 2026-05-25 22:29:54 +02:00
gilles fefde4eb31 docs: spec serveur MCP HomeHub (16 outils, Streamable HTTP, Hermes) 2026-05-25 22:28:28 +02:00
45 changed files with 12786 additions and 145 deletions
+1
View File
@@ -1,3 +1,4 @@
DATABASE_URL=postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR=/uploads
CORS_ORIGINS=http://localhost:3001,http://localhost:3000
MCP_API_KEY=
+95 -48
View File
@@ -1,10 +1,15 @@
import asyncio
import os
import shutil
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -50,22 +55,14 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
}
def _pg_env() -> dict:
def _pg_env() -> tuple[dict, object]:
url = urlparse(settings.database_url.replace("+asyncpg", ""))
env = os.environ.copy()
env["PGPASSWORD"] = url.password or ""
return env, url
@router.post("/backup")
async def create_backup():
backup_dir = settings.backup_path
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
filename = f"homehub_{timestamp}.dump"
filepath = backup_dir / filename
async def _pg_dump_to(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_dump", "-Fc",
@@ -73,48 +70,16 @@ async def create_backup():
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
"-f", str(filepath),
"-f", str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(500, f"Échec du backup : {stderr.decode()}")
stat = filepath.stat()
return {
"filename": filename,
"size": stat.st_size,
"created_at": datetime.now().isoformat(),
}
raise HTTPException(500, f"Échec du pg_dump : {stderr.decode()}")
@router.get("/backups")
async def list_backups():
backup_dir = settings.backup_path
if not backup_dir.exists():
return []
files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True)
return [
{
"filename": f.name,
"size": f.stat().st_size,
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in files
]
@router.post("/restore/{filename}")
async def restore_backup(filename: str):
# Sécurité : interdit les chemins relatifs
if "/" in filename or ".." in filename or not filename.endswith(".dump"):
raise HTTPException(400, "Nom de fichier invalide")
filepath = settings.backup_path / filename
if not filepath.exists():
raise HTTPException(404, "Fichier introuvable")
async def _pg_restore_from(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges",
@@ -122,7 +87,7 @@ async def restore_backup(filename: str):
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
str(filepath),
str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
@@ -131,4 +96,86 @@ async def restore_backup(filename: str):
if proc.returncode not in (0, 1):
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
return {"message": "Restauration réussie"}
@router.post("/backup")
async def download_backup():
"""Génère une archive .tar.gz contenant DB + médias, streamée au navigateur."""
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
archive_name = f"homehub_{timestamp}.tar.gz"
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_backup_"))
dump_path = tmpdir / "db.dump"
archive_path = tmpdir / archive_name
await _pg_dump_to(dump_path)
def _build_archive() -> None:
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(dump_path, arcname="db.dump")
uploads = settings.upload_path
if uploads.exists():
tar.add(uploads, arcname="uploads")
await asyncio.to_thread(_build_archive)
def _cleanup() -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
return FileResponse(
archive_path,
media_type="application/gzip",
filename=archive_name,
background=BackgroundTask(_cleanup),
)
@router.post("/restore")
async def upload_and_restore(file: UploadFile = File(...)):
"""Restaure depuis une archive .tar.gz uploadée (DB + médias)."""
if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(400, "Format attendu : .tar.gz")
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_restore_"))
try:
archive_path = tmpdir / "upload.tar.gz"
with archive_path.open("wb") as f:
while chunk := await file.read(1024 * 1024):
f.write(chunk)
extract_dir = tmpdir / "extract"
extract_dir.mkdir()
def _extract() -> None:
with tarfile.open(archive_path, "r:gz") as tar:
# Sécurité : refuser les chemins absolus ou contenant ..
for member in tar.getmembers():
if member.name.startswith("/") or ".." in Path(member.name).parts:
raise HTTPException(400, f"Chemin invalide dans l'archive : {member.name}")
tar.extractall(extract_dir, filter="data")
await asyncio.to_thread(_extract)
dump_path = extract_dir / "db.dump"
if not dump_path.exists():
raise HTTPException(400, "Archive invalide : db.dump introuvable")
await _pg_restore_from(dump_path)
uploads_src = extract_dir / "uploads"
if uploads_src.exists():
uploads_dst = settings.upload_path
uploads_dst.mkdir(parents=True, exist_ok=True)
def _sync_media() -> None:
for item in uploads_src.rglob("*"):
if not item.is_file():
continue
rel = item.relative_to(uploads_src)
dest = uploads_dst / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)
await asyncio.to_thread(_sync_media)
return {"message": "Restauration réussie"}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+509
View File
@@ -0,0 +1,509 @@
import json
import uuid
from datetime import datetime, timedelta, timezone, date as date_type
from decimal import Decimal
from mcp.server.fastmcp import FastMCP
from sqlalchemy import select, and_, text, or_
from sqlalchemy.orm import selectinload
from app.core.database import AsyncSessionLocal
from app.models.todos import TodoItem
from app.models.notes import NoteItem
from app.models.shopping import ShoppingList, ListItem, Product
_VALID_STATUSES = {"pending", "done", "cancelled"}
_VALID_PRIORITIES = {"low", "medium", "high"}
mcp = FastMCP("HomeHub", stateless_http=True, streamable_http_path="/")
def _serialize(obj):
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date_type):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
raise TypeError(f"Type non sérialisable : {type(obj)}")
def _dumps(data) -> str:
return json.dumps(data, default=_serialize)
def _iso_week_label() -> str:
now = datetime.now(tz=timezone.utc)
iso = now.isocalendar()
return f"S{iso[1]} {iso[0]}"
# ── TODOS ──────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_todos(
status: str = "pending",
domain: str | None = None,
priority: str | None = None,
) -> str:
"""Liste filtrée des tâches. status: pending/done/cancelled. priority: low/medium/high."""
async with AsyncSessionLocal() as session:
conditions = [TodoItem.status == status]
if domain:
conditions.append(TodoItem.domains.contains([domain]))
if priority:
conditions.append(TodoItem.priority == priority)
stmt = (
select(TodoItem)
.where(and_(*conditions))
.order_by(TodoItem.due_date.asc().nulls_last(), TodoItem.created_at.desc())
.limit(100)
)
result = await session.execute(stmt)
items = result.scalars().all()
return _dumps([{
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
"domains": item.domains,
"tags": item.tags,
"due_date": item.due_date,
"postponed_count": item.postponed_count,
} for item in items])
@mcp.tool()
async def create_todo(
title: str,
due_date: str | None = None,
priority: str | None = None,
domain: str | None = None,
) -> str:
"""Crée une tâche. due_date en ISO 8601 (ex: 2025-06-01T10:00:00Z). priority: low/medium/high."""
if priority and priority not in _VALID_PRIORITIES:
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: low/medium/high"})
async with AsyncSessionLocal() as session:
parsed_due = None
if due_date:
try:
parsed_due = datetime.fromisoformat(due_date.replace("Z", "+00:00"))
except ValueError:
return _dumps({"error": f"Format de date invalide : {due_date}"})
item = TodoItem(
title=title,
due_date=parsed_due,
priority=priority or "medium",
domains=[domain] if domain else [],
)
session.add(item)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
"domains": item.domains,
"due_date": item.due_date,
})
@mcp.tool()
async def update_todo(
id: str,
title: str | None = None,
status: str | None = None,
priority: str | None = None,
) -> str:
"""Modifie une tâche. status: pending/done/cancelled. priority: low/medium/high."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
if status is not None and status not in _VALID_STATUSES:
return _dumps({"error": f"Statut invalide : {status}. Valeurs: pending/done/cancelled"})
if priority is not None and priority not in _VALID_PRIORITIES:
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: low/medium/high"})
if title is not None:
item.title = title
if status is not None:
item.status = status
if priority is not None:
item.priority = priority
item.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
})
@mcp.tool()
async def postpone_todo(id: str, days: int) -> str:
"""Reporte une tâche de N jours à partir de sa date d'échéance (ou maintenant si nulle)."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
now = datetime.now(timezone.utc)
base = item.due_date if item.due_date else now
item.due_date = base + timedelta(days=days)
item.postponed_count += 1
item.updated_at = now
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"due_date": item.due_date,
"postponed_count": item.postponed_count,
})
@mcp.tool()
async def delete_todo(id: str) -> str:
"""Supprime une tâche définitivement."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
await session.delete(item)
await session.commit()
return _dumps({"deleted": id})
# ── NOTES ──────────────────────────────────────────────────────────────────────
@mcp.tool()
async def search_notes(
query: str | None = None,
category: str | None = None,
tag: str | None = None,
) -> str:
"""Recherche de notes via FTS PostgreSQL (français). query cherche dans titre+contenu."""
async with AsyncSessionLocal() as session:
conditions = []
if category:
conditions.append(NoteItem.category == category)
if tag:
conditions.append(NoteItem.tags.contains([tag]))
if query:
conditions.append(
text(
"to_tsvector('french', coalesce(title,'') || ' ' || content)"
" @@ plainto_tsquery('french', :q)"
).bindparams(q=query)
)
stmt = (
select(NoteItem)
.options(selectinload(NoteItem.attachments))
.order_by(NoteItem.created_at.desc())
.limit(20)
)
if conditions:
stmt = stmt.where(and_(*conditions))
result = await session.execute(stmt)
notes = result.scalars().all()
return _dumps([{
"id": n.id,
"title": n.title,
"category": n.category,
"tags": n.tags,
"content_preview": n.content[:200] if n.content else "",
"created_at": n.created_at,
"attachment_count": len(n.attachments),
} for n in notes])
@mcp.tool()
async def get_note(id: str) -> str:
"""Retourne une note complète avec ses pièces jointes."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
stmt = (
select(NoteItem)
.where(NoteItem.id == note_id)
.options(selectinload(NoteItem.attachments))
)
result = await session.execute(stmt)
note = result.scalar_one_or_none()
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
return _dumps({
"id": note.id,
"title": note.title,
"content": note.content,
"category": note.category,
"tags": note.tags,
"created_at": note.created_at,
"attachments": [{
"id": a.id,
"file_type": a.file_type,
"original_name": a.original_name,
} for a in note.attachments],
})
@mcp.tool()
async def create_note(
title: str,
content: str,
category: str | None = None,
tags: list[str] | None = None,
) -> str:
"""Crée une note avec titre, contenu markdown, catégorie optionnelle et tags."""
async with AsyncSessionLocal() as session:
note = NoteItem(
title=title,
content=content,
category=category,
tags=tags or [],
)
session.add(note)
await session.commit()
await session.refresh(note)
return _dumps({
"id": note.id,
"title": note.title,
"category": note.category,
"tags": note.tags,
"created_at": note.created_at,
})
@mcp.tool()
async def update_note(
id: str,
title: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> str:
"""Modifie le titre, le contenu ou les tags d'une note."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
note = await session.get(NoteItem, note_id)
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
if title is not None:
note.title = title
if content is not None:
note.content = content
if tags is not None:
note.tags = tags
await session.commit()
await session.refresh(note)
return _dumps({"id": note.id, "title": note.title, "tags": note.tags})
@mcp.tool()
async def delete_note(id: str) -> str:
"""Supprime une note et toutes ses pièces jointes (cascade)."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
note = await session.get(NoteItem, note_id)
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
await session.delete(note)
await session.commit()
return _dumps({"deleted": id})
# ── SHOPPING ────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_shopping_lists() -> str:
"""Retourne toutes les listes de courses avec compteurs d'articles cochés/total."""
async with AsyncSessionLocal() as session:
stmt = (
select(ShoppingList)
.options(selectinload(ShoppingList.items))
.order_by(ShoppingList.created_at.desc())
)
result = await session.execute(stmt)
lists = result.scalars().all()
return _dumps([{
"id": lst.id,
"name": lst.name,
"status": lst.status,
"week_date": lst.week_date,
"created_at": lst.created_at,
"item_count": len(lst.items),
"checked_count": sum(1 for i in lst.items if i.is_checked),
} for lst in lists])
@mcp.tool()
async def get_active_shopping_list() -> str:
"""Retourne la première liste en statut 'draft' ou 'active' avec tous ses articles triés."""
async with AsyncSessionLocal() as session:
stmt = (
select(ShoppingList)
.where(ShoppingList.status.in_(["draft", "active"]))
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
.order_by(ShoppingList.status.asc(), ShoppingList.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lst = result.scalar_one_or_none()
if not lst:
return _dumps({"error": "Aucune liste active (statut draft ou active)"})
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
return _dumps({
"id": lst.id,
"name": lst.name,
"status": lst.status,
"item_count": len(lst.items),
"checked_count": sum(1 for i in lst.items if i.is_checked),
"items": [{
"id": item.id,
"name": item.custom_name or (item.product.name if item.product else "Article inconnu"),
"quantity": item.quantity,
"unit": item.unit,
"is_checked": item.is_checked,
} for item in sorted_items],
})
@mcp.tool()
async def search_products(q: str) -> str:
"""Recherche dans le catalogue produits par nom, description ou catégorie."""
if not q or not q.strip():
return _dumps({"error": "Paramètre q requis"})
async with AsyncSessionLocal() as session:
stmt = (
select(Product)
.where(
or_(
Product.name.ilike(f"%{q}%"),
Product.description.ilike(f"%{q}%"),
Product.category.ilike(f"%{q}%"),
)
)
.order_by(Product.frequency_score.desc())
.limit(20)
)
result = await session.execute(stmt)
products = result.scalars().all()
return _dumps([{
"id": p.id,
"name": p.name,
"brand": p.brand,
"category": p.category,
"default_unit": p.default_unit,
"frequency_score": p.frequency_score,
"last_purchased_at": p.last_purchased_at,
} for p in products])
@mcp.tool()
async def create_shopping_list(name: str | None = None) -> str:
"""Crée une liste de courses. name auto = semaine ISO courante si absent (ex: 'S22 2026')."""
async with AsyncSessionLocal() as session:
lst = ShoppingList(name=name or _iso_week_label())
session.add(lst)
await session.commit()
await session.refresh(lst)
return _dumps({
"id": lst.id,
"name": lst.name,
"status": lst.status,
"created_at": lst.created_at,
})
@mcp.tool()
async def add_shopping_item(
list_id: str,
name: str,
quantity: float | None = 1.0,
unit: str | None = None,
) -> str:
"""Ajoute un article à une liste de courses."""
async with AsyncSessionLocal() as session:
try:
lid = uuid.UUID(list_id)
except ValueError:
return _dumps({"error": f"UUID de liste invalide : {list_id}"})
lst = await session.get(ShoppingList, lid)
if not lst:
return _dumps({"error": f"Liste introuvable : {list_id}"})
item = ListItem(
list_id=lid,
custom_name=name,
quantity=Decimal(str(quantity)) if quantity is not None else None,
unit=unit,
)
session.add(item)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"name": item.custom_name,
"quantity": item.quantity,
"unit": item.unit,
"is_checked": item.is_checked,
})
@mcp.tool()
async def check_shopping_item(list_id: str, item_id: str) -> str:
"""Coche un article (marque comme acheté). Met à jour les stats du produit lié si présent."""
async with AsyncSessionLocal() as session:
try:
lid = uuid.UUID(list_id)
iid = uuid.UUID(item_id)
except ValueError:
return _dumps({"error": "UUID invalide (list_id ou item_id)"})
stmt = (
select(ListItem)
.where(ListItem.id == iid, ListItem.list_id == lid)
.options(selectinload(ListItem.product))
)
result = await session.execute(stmt)
item = result.scalar_one_or_none()
if not item:
return _dumps({"error": f"Article introuvable : {item_id} dans liste {list_id}"})
was_checked = item.is_checked
item.is_checked = True
if not was_checked and item.product:
product = item.product
today = date_type.today()
if product.last_purchased_at and product.last_purchased_at < today:
days = (today - product.last_purchased_at).days
if product.avg_interval_days is None:
product.avg_interval_days = Decimal(str(days))
else:
product.avg_interval_days = Decimal(str(
round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1)
))
product.last_purchased_at = today
product.frequency_score += 1
await session.commit()
return _dumps({"id": item.id, "is_checked": item.is_checked})
+1
View File
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
data_dir: str = "/data"
redis_url: str = "redis://redis:6379"
cors_origins: str = "http://localhost:3000"
mcp_api_key: str = ""
@property
def cors_origins_list(self) -> list[str]:
+29
View File
@@ -0,0 +1,29 @@
import hmac
import json
from starlette.types import ASGIApp, Receive, Scope, Send
from app.core.config import settings
class MCPAuthMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path", "").startswith("/mcp"):
headers = dict(scope.get("headers", []))
auth = headers.get(b"authorization", b"").decode()
expected = f"Bearer {settings.mcp_api_key}"
if not settings.mcp_api_key or not hmac.compare_digest(auth, expected):
body = json.dumps({"detail": "Unauthorized"}).encode()
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
(b"www-authenticate", b"Bearer"),
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
+6 -2
View File
@@ -7,22 +7,24 @@ from app.api.admin import router as admin_router
from app.api.events import router as events_router
from app.api.health import router as health_router
from app.api.media import router as media_router
from app.api.mcp_server import mcp
from app.api.notes import router as notes_router
from app.api.todos import router as todos_router
from app.api.shopping import router as shopping_router
from app.core.config import settings
from app.core.mcp_auth import MCPAuthMiddleware
from app.core.redis import init_redis, close_redis
from app.data.seed import run_seed
@asynccontextmanager
async def lifespan(app: FastAPI):
# Crée les dossiers data/ au démarrage
for subdir in ("uploads", "notes", "backup"):
Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True)
await run_seed()
await init_redis()
yield
async with mcp.session_manager.run():
yield
await close_redis()
@@ -35,6 +37,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(MCPAuthMiddleware)
app.include_router(health_router, prefix="/api")
app.include_router(events_router, prefix="/api/events")
@@ -44,4 +47,5 @@ app.include_router(notes_router, prefix="/api/notes")
app.include_router(todos_router, prefix="/api/todos")
app.include_router(shopping_router, prefix="/api/shopping")
app.mount("/mcp", mcp.streamable_http_app())
app.mount("/media", StaticFiles(directory=str(settings.upload_path)), name="media")
+1
View File
@@ -8,5 +8,6 @@ pillow==11.1.0
python-multipart==0.0.20
httpx==0.28.0
arq==0.26.1
mcp>=1.9,<2.0
pytest==8.3.4
pytest-asyncio==0.24.0
+14
View File
@@ -5,6 +5,7 @@ from sqlalchemy.pool import NullPool
from app.main import app
from app.core import database
from app.core.config import settings
import app.api.mcp_server as mcp_server_module
def make_test_engine():
@@ -12,6 +13,19 @@ def make_test_engine():
return create_async_engine(settings.database_url, poolclass=NullPool)
@pytest.fixture
async def mcp_nullpool_session():
"""Remplace AsyncSessionLocal dans mcp_server par un sessionmaker NullPool
pour éviter les conflits d'event loop entre tests."""
engine = make_test_engine()
test_session_factory = async_sessionmaker(engine, expire_on_commit=False)
original = mcp_server_module.AsyncSessionLocal
mcp_server_module.AsyncSessionLocal = test_session_factory
yield
mcp_server_module.AsyncSessionLocal = original
await engine.dispose()
@pytest.fixture
async def db_session():
engine = make_test_engine()
+238
View File
@@ -0,0 +1,238 @@
import json
import uuid
import pytest
from sqlalchemy import delete
import app.api.mcp_server as mcp_server_module
from app.core.config import settings
from app.api.mcp_server import (
get_todos, create_todo, update_todo, postpone_todo, delete_todo,
)
from app.api.mcp_server import (
search_notes, get_note, create_note, update_note, delete_note,
)
from app.api.mcp_server import (
get_shopping_lists, get_active_shopping_list, search_products,
create_shopping_list, add_shopping_item, check_shopping_item,
)
from app.models.todos import TodoItem
from app.models.notes import NoteItem
from app.models.shopping import ShoppingList, ListItem
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
@pytest.fixture(autouse=True)
async def cleanup_mcp_todos():
yield
# Utilise AsyncSessionLocal depuis le module mcp_server (patché par mcp_nullpool_session)
async with mcp_server_module.AsyncSessionLocal() as session:
await session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_MCP_%")))
await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%")))
await session.execute(delete(ShoppingList).where(ShoppingList.name.like("TEST_MCP_%")))
await session.commit()
async def test_get_todos_retourne_liste_json():
result = await get_todos(status="pending")
data = json.loads(result)
assert isinstance(data, list)
async def test_create_todo_outil_cree_une_tache():
result = await create_todo(title="TEST_MCP_todo_create")
data = json.loads(result)
assert data["title"] == "TEST_MCP_todo_create"
assert data["status"] == "pending"
assert data["priority"] == "medium"
# Cleanup
await delete_todo(id=str(data["id"]))
async def test_update_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_update"))
result = await update_todo(id=str(created["id"]), status="done")
data = json.loads(result)
assert data["status"] == "done"
await delete_todo(id=str(created["id"]))
async def test_postpone_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_postpone"))
result = await postpone_todo(id=str(created["id"]), days=3)
data = json.loads(result)
assert data["postponed_count"] == 1
await delete_todo(id=str(created["id"]))
async def test_delete_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_delete"))
result = await delete_todo(id=str(created["id"]))
data = json.loads(result)
assert "deleted" in data
async def test_update_todo_id_invalide():
result = await update_todo(id="pas-un-uuid", title="x")
data = json.loads(result)
assert "error" in data
async def test_delete_todo_introuvable():
result = await delete_todo(id="00000000-0000-0000-0000-000000000000")
data = json.loads(result)
assert "error" in data
# ── NOTES ──────────────────────────────────────────────────────────────────────
async def test_search_notes_retourne_liste():
result = await search_notes(query="inexistant_xyz_abc_999")
data = json.loads(result)
assert isinstance(data, list)
assert data == []
async def test_search_notes_fts_trouve_par_mot_cle():
await create_note(title="TEST_MCP_note_fts", content="recette de cuisine française traditionnelle")
result = await search_notes(query="cuisine")
data = json.loads(result)
assert any(n["title"] == "TEST_MCP_note_fts" for n in data)
async def test_create_note_outil():
result = await create_note(title="TEST_MCP_note_create", content="Contenu de test MCP")
data = json.loads(result)
assert data["title"] == "TEST_MCP_note_create"
assert "id" in data
await delete_note(id=str(data["id"]))
async def test_get_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_get", content="Contenu get"))
result = await get_note(id=str(created["id"]))
data = json.loads(result)
assert data["title"] == "TEST_MCP_note_get"
assert data["content"] == "Contenu get"
assert "attachments" in data
await delete_note(id=str(created["id"]))
async def test_update_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_update", content="avant"))
result = await update_note(id=str(created["id"]), content="après")
data = json.loads(result)
assert "id" in data
await delete_note(id=str(created["id"]))
async def test_delete_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_delete", content="x"))
result = await delete_note(id=str(created["id"]))
data = json.loads(result)
assert "deleted" in data
async def test_get_note_introuvable():
result = await get_note(id="00000000-0000-0000-0000-000000000000")
data = json.loads(result)
assert "error" in data
# ── SHOPPING ──────────────────────────────────────────────────────────────────
async def test_get_shopping_lists_retourne_liste():
result = await get_shopping_lists()
data = json.loads(result)
assert isinstance(data, list)
async def test_create_shopping_list_outil():
result = await create_shopping_list(name="TEST_MCP_liste")
data = json.loads(result)
assert data["name"] == "TEST_MCP_liste"
assert data["status"] == "draft"
assert "id" in data
async def test_create_shopping_list_nom_auto():
result = await create_shopping_list()
data = json.loads(result)
# Le nom auto est au format "S{semaine} {année}" (ex: "S22 2026")
assert data["name"].startswith("S")
# Cleanup manuel — la liste auto n'a pas le préfixe TEST_MCP_
async with mcp_server_module.AsyncSessionLocal() as session:
await session.execute(
delete(ShoppingList).where(ShoppingList.id == uuid.UUID(data["id"]))
)
await session.commit()
async def test_add_shopping_item_outil():
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_item"))
result = await add_shopping_item(
list_id=str(liste["id"]),
name="TEST_MCP_article",
quantity=2.0,
unit="kg",
)
data = json.loads(result)
assert data["name"] == "TEST_MCP_article"
assert data["is_checked"] is False
assert float(data["quantity"]) == 2.0
async def test_check_shopping_item_outil():
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_check"))
article = json.loads(await add_shopping_item(
list_id=str(liste["id"]),
name="TEST_MCP_article_check",
))
result = await check_shopping_item(
list_id=str(liste["id"]),
item_id=str(article["id"]),
)
data = json.loads(result)
assert data["is_checked"] is True
async def test_search_products_retourne_liste():
result = await search_products(q="inexistant_xyz_abc_999")
data = json.loads(result)
assert isinstance(data, list)
assert data == []
async def test_get_active_shopping_list_structure():
result = await get_active_shopping_list()
data = json.loads(result)
assert isinstance(data, dict)
async def test_add_item_liste_invalide():
result = await add_shopping_item(list_id="pas-un-uuid", name="article")
data = json.loads(result)
assert "error" in data
# ── AUTH ──────────────────────────────────────────────────────────────────────
async def test_mcp_auth_rejet_sans_token(client):
"""Le middleware renvoie 401 si aucun header Authorization."""
resp = await client.get("/mcp")
assert resp.status_code == 401
async def test_mcp_auth_rejet_mauvais_token(client):
"""Le middleware renvoie 401 si le token est incorrect."""
resp = await client.get("/mcp", headers={"Authorization": "Bearer mauvais-token"})
assert resp.status_code == 401
async def test_mcp_auth_accepte_bon_token(client, monkeypatch):
"""Le middleware laisse passer avec le token correct."""
monkeypatch.setattr(settings, "mcp_api_key", "test-mcp-key-xyz")
resp = await client.get(
"/mcp",
headers={"Authorization": "Bearer test-mcp-key-xyz"},
)
assert resp.status_code != 401
+253
View File
@@ -0,0 +1,253 @@
# mon design system — Package Smartphone (iOS / Android)
> Adaptation mobile complète de mon design system Gruvbox seventies.
> **45+ composants nommés**, optimisés tactile (hit targets ≥ 44px), animations fluides,
> dark + light, gestes nommés et testables, contrôle complet du clavier virtuel.
>
> Version 1.0 · iOS & Android
---
## 🚀 Démarrage rapide
```html
<!DOCTYPE html>
<html data-theme="dark">
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="tokens/tokens.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<!-- Base UI -->
<script type="text/babel" src="components/ui-kit.jsx"></script>
<!-- Mobile -->
<script type="text/babel" src="components/mobile-kit.jsx"></script>
<script type="text/babel" src="components/mobile-sheets.jsx"></script>
<script type="text/babel" src="components/mobile-gestures.jsx"></script>
<script type="text/babel" src="components/mobile-swipeable.jsx"></script>
<script type="text/babel" src="components/mobile-forms.jsx"></script>
<script type="text/babel" src="components/mobile-apps.jsx"></script>
<script type="text/babel">
// Ton app ici
</script>
</body>
</html>
```
---
## 📂 Contenu du package
```
package-smartphone/
├── README.md ← Ce fichier (guide humain)
├── consigne_mobile.md ← Brief pour agents IA
├── tokens/
│ ├── tokens.css ← Variables CSS web (dark + light)
│ ├── tokens.gnome.css ← Pour apps GTK
│ └── tokens.json ← Format générique
├── components/
│ ├── ui-kit.jsx ← Base (Button, Icon, Popup, gauges…)
│ ├── mobile-kit.jsx ← StatusBar, NavBar, TabBar, ActionCard…
│ ├── mobile-sheets.jsx ← BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh
│ ├── mobile-gestures.jsx ← useGesture, GestureZone (9 gestes nommés)
│ ├── mobile-swipeable.jsx ← SwipeableRow (swipe-to-reveal actions)
│ ├── mobile-forms.jsx ← TextInput, DateInput, Dropdown, Checkbox, Radio, MediaInsert…
│ └── mobile-apps.jsx ← Avatar+menu, Onboarding, Chat, Calendar, Maps, Scanner, Caméra, Files
└── examples/
├── exemple-mobile.html ← Vue d'ensemble + gestes interactifs
├── exemple-mobile-saisie.html ← Login, profil, formulaire complet, clavier virtuel
└── exemple-mobile-apps.html ← 10 écrans : onboarding, chat, calendrier, maps, scanner, caméra, fichiers, settings
```
Tous les exemples ont leurs composants copiés à côté pour fonctionner sans navigation `../`. Ouvre n'importe lequel directement dans le navigateur.
---
## 🧩 Tous les composants nommés
### Structure d'écran
- **`StatusBar`** — barre de statut iOS (heure, signal, batterie)
- **`NavBar`** — barre titre, mode compact ou large, bouton retour, actions à droite
- **`TabBar`** — barre d'onglets en bas (3-5 sections)
### Boutons & contrôles
- **`PrimaryButton`** — gros bouton plein largeur (primary, ghost, danger)
- **`IconButton`** — bouton icône seul avec tooltip
- **`Toggle`** — switch on/off
- **`SegmentedControl`** — sélecteur 2-4 options exclusives
- **`Button`** — bouton générique (depuis ui-kit)
### Listes
- **`ListSection / ListRow`** — liste de réglages iOS-style
- **`ActionCard`** — grosse tuile tactile pour grille d'apps
- **`SwipeableRow`** — ligne révélant des actions au swipe (Mail/Things)
- **`FileExplorer`** — liste de fichiers avec icônes par type + actions swipe
### Saisie
- **`FormField`** — wrapper avec label + hint + erreur
- **`TextInput`** — champ texte avec contrôle complet du clavier virtuel
- **`DateInput`** — picker date/heure/datetime natif mobile
- **`Dropdown`** — select avec sélecteur natif
- **`CheckboxItem`** — case à cocher avec label + description
- **`RadioGroup`** — options exclusives empilées
- **`MediaInsert`** — grille pour ajouter photo/image/vidéo/audio/fichier/GPS
- **`SearchBar`** — recherche tactile avec icône loupe
- **`FilterChips`** — chips horizontaux multi-sélection
### Fenêtres / Dialogues
- **`BottomSheet`** — feuille modale qui monte du bas (swipe-down pour fermer)
- **`ActionSheet`** — menu d'actions iOS (2-6 choix)
- **`AlertDialog`** — alerte centrée bloquante (confirmation destructive)
- **`Toast`** — notification éphémère 2.5s
- **`Popup`** — modale glassmorphism (depuis ui-kit)
### Identité utilisateur
- **`Avatar`** — bouton rond avec initiales (haut-droite de chaque écran)
- **`AvatarMenu`** — popup descendant : Profil / Paramètres / Aide / Déconnexion
- **`AvatarLogo`** — gros logo carré arrondi (login, profil)
- **`BiometricButton`** — Face ID / Touch ID
### Patterns d'app
- **`OnboardingSlider`** — slides + dots + suivant/passer
- **`ChatBubble`** — bulle message (envoyé/reçu, statut ✓ ✓✓)
- **`ChatComposer`** — barre d'envoi avec joindre + audio + send
- **`CalendarMonth`** — vue mois avec évènements
- **`MapView`** — carte avec pins colorés
- **`QrScannerView`** — viseur scanner avec ligne animée
- **`CameraView`** — viseur photo avec règle des tiers
### Gestes
- **`useGesture`** — hook qui détecte 9 gestes nommés
- **`GestureZone`** — zone interactive qui affiche le geste détecté
### Datavis (mobile-friendly)
- **`RadialGauge`**, **`BatteryGauge`** (compact ou standard), **`BigRadialGauge`**, **`Sparkline`**, **`LineChart`**, **`StatusLed`**
---
## ✋ Gestes nommés
9 gestes détectables par `useGesture()` :
| Nom | Geste | Usage typique |
|-------------|---------------------------------------|---------------------------------------|
| `Tap` | Pression rapide | Action principale (équiv. clic) |
| `DoubleTap` | Deux Tap rapprochés (< 300 ms) | Zoomer, liker |
| `LongPress` | Pression maintenue ≥ 500 ms | Menu contextuel, sélection |
| `SwipeLeft` | Glisser vers la gauche | Écran suivant, supprimer une ligne |
| `SwipeRight`| Glisser vers la droite | Écran précédent, archiver |
| `SwipeUp` | Glisser vers le haut | Plus de détails, fermer popup |
| `SwipeDown` | Glisser vers le bas | Rafraîchir, fermer BottomSheet |
| `Pan` | Glisser en continu | Déplacer un élément, scroll horizontal|
| `Pinch` | Écarter / rapprocher 2 doigts | Zoomer carte ou image |
---
## ⌨️ Clavier virtuel — paramètres
`TextInput` accepte 4 props clés pour contrôler le clavier mobile :
### `keyboard` (inputmode) — quel clavier afficher
- `text` — clavier standard
- `numeric` — pavé 0-9 (codes, OTP)
- `decimal` — pavé + virgule (prix, mesures)
- `tel` — pavé téléphone
- `email` — clavier texte + @ et . directs
- `url` — clavier texte + / et .com
- `search` — clavier standard, touche Entrée = "Rechercher"
- `none` — aucun clavier (picker custom)
### `autocomplete` — aide à la saisie système
- `email` · `tel` · `name` · `given-name` · `family-name`
- `address-line1` · `postal-code` · `country`
- `current-password` · `new-password`
- `one-time-code` (auto-lecture SMS sur iOS/Android !)
- `off`
### `enterHint` — texte de la touche Entrée
- `send` · `search` · `go` · `done` · `next` · `previous`
### `autocapitalize` — majuscules auto
- `sentences` · `words` · `characters` · `off`
### Combinaisons usuelles
| Cas | Réglages |
|--------------------|-------------------------------------------------------------------|
| Email | `keyboard="email" autocomplete="email" autocapitalize="off"` |
| Mot de passe | `type="password" autocomplete="current-password"` (ou `new-password`) |
| Code OTP SMS | `keyboard="numeric" autocomplete="one-time-code" maxLength={6}` |
| Téléphone | `keyboard="tel" autocomplete="tel"` |
| Recherche | `keyboard="search" enterHint="search"` |
| Prix / mesure | `keyboard="decimal"` |
| Adresse postale | `autocomplete="address-line1"`, puis `postal-code`, `country` |
| Texte libre | `autocapitalize="sentences" spellCheck={true}` |
---
## 🪟 Types de fenêtres mobile
| Type | Quand l'utiliser | Geste pour fermer |
|----------------|------------------------------------------------------------------|----------------------|
| `BottomSheet` | Action contextuelle, formulaire court, choix dans une liste | SwipeDown ↓ |
| `ActionSheet` | Menu d'actions (2-6 choix) sur un élément | Tap "Annuler" ou hors|
| `AlertDialog` | Confirmation destructive (suppression, déconnexion) | Volontairement bloquant|
| `Toast` | Feedback succès/erreur après une action | Auto 2.5s |
| `Popup` | Modale centrée plus large (form de config avancé) | ✕ ou clic extérieur |
---
## 📱 3 pages d'exemples (à ouvrir)
1. **`examples/exemple-mobile.html`** — vue d'ensemble, gestes interactifs, premières patterns
2. **`examples/exemple-mobile-saisie.html`** — login, profil, formulaire complet, clavier virtuel
3. **`examples/exemple-mobile-apps.html`** — 10 patterns d'app : onboarding, chat, calendrier, maps, scanner QR, caméra, fichiers, settings, avatar menu
Chaque page affiche un smartphone à gauche (basculable iOS/Android, sombre/clair) et la doc commentée à droite avec mini-visuels SVG sous chaque composant et écran.
---
## ⚙️ Paramétrage
Tout fonctionne avec les **mêmes tokens CSS** que le package desktop. Pour changer une couleur, édite `tokens/tokens.css` :
```css
:root[data-theme="dark"] {
--accent: #fe8019; /* Orange Gruvbox */
--bg-1: #2a231d; /* Fond app */
--ok: #4dbb26;
--warn: #fabd2f;
--err: #fb4934;
--blue: #3db0d1;
--purple: #c882c8;
}
```
Bascule de thème :
```js
document.documentElement.dataset.theme = 'light'; // ou 'dark'
```
---
## ✅ Checklist d'intégration
- [ ] Polices chargées (Inter, JetBrains Mono, Share Tech Mono)
- [ ] Font Awesome 6 chargé
- [ ] `tokens/tokens.css` chargé
- [ ] `data-theme="dark"` (ou "light") sur `<html>`
- [ ] React 18 + Babel chargés
- [ ] `ui-kit.jsx` chargé en premier
- [ ] Modules mobile chargés dans l'ordre : kit → sheets → gestures → swipeable → forms → apps
---
## 🤖 Pour les agents IA
Lire `consigne_mobile.md` pour les règles d'utilisation et conventions.
@@ -0,0 +1,659 @@
/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});
@@ -0,0 +1,385 @@
/* ============================================================
mobile-forms.jsx
Composants de saisie mobile avec contrôle du clavier virtuel.
Tous nommés et exposés sur window.
============================================================ */
const { useState: uMF, useRef: rMF } = React;
/* ============================================================
FormField — wrapper standard pour un champ
Nom système : FormField
Affiche : label · description · le champ · message d'erreur/hint
============================================================ */
function FormField({ label, hint, error, required, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{label && (
<label style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
</label>
)}
{children}
{(error || hint) && (
<div style={{
fontSize: 12,
color: error ? 'var(--err)' : 'var(--ink-4)',
lineHeight: 1.4,
}}>{error || hint}</div>
)}
</div>
);
}
/* ============================================================
TextInput — champ texte avec contrôle complet du clavier virtuel
Nom système : TextInput
Props clavier virtuel (mobile uniquement) :
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
'given-name'|'family-name'|'current-password'|'new-password'|
'one-time-code'|'off'… (Web Authentication API)
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
spellCheck: bool
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
pattern: regex de validation
============================================================ */
function TextInput({
value, onChange, placeholder, type = 'text', icon, trailing,
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
error,
}) {
const C = multiline ? 'textarea' : 'input';
const inputProps = {
value, onChange: (e) => onChange(e.target.value),
placeholder,
inputMode: keyboard,
autoComplete: autocomplete,
autoCapitalize: autocapitalize,
spellCheck,
enterKeyHint: enterHint,
pattern, maxLength,
rows: multiline ? rows : undefined,
type: !multiline ? type : undefined,
style: {
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
fontSize: 15,
padding: multiline ? '4px 0' : 0,
resize: multiline ? 'vertical' : undefined,
minHeight: multiline ? rows * 22 : undefined,
},
};
return (
<div style={{
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
<C {...inputProps} />
{trailing}
</div>
);
}
/* ============================================================
DateInput — date picker natif mobile
Nom système : DateInput
============================================================ */
function DateInput({ value, onChange, mode = 'date' }) {
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
<input
type={mode}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: 'var(--font-mono)', fontSize: 15,
colorScheme: 'dark',
}}
/>
</div>
);
}
/* ============================================================
Dropdown — select natif stylisé
Nom système : Dropdown
============================================================ */
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
position: 'relative',
}}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
fontFamily: 'var(--font-ui)', fontSize: 15,
appearance: 'none', WebkitAppearance: 'none',
paddingRight: 24,
}}>
<option value="">{placeholder}</option>
{options.map((o) => (
typeof o === 'string'
? <option key={o} value={o}>{o}</option>
: <option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
</div>
);
}
/* ============================================================
CheckboxItem — case à cocher (style iOS)
Nom système : CheckboxItem
Cas : oui/non sur une option, sélection multiple dans une liste
============================================================ */
function CheckboxItem({ checked, onChange, label, description }) {
return (
<label className="touch-press" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '12px 14px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: checked ? 'var(--accent)' : 'var(--bg-1)',
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--bg-1)',
flex: '0 0 auto', marginTop: 1,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
transition: 'all .12s',
}}>
{checked && <Icon name="play" size={11} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
</label>
);
}
/* ============================================================
RadioGroup — groupe d'options exclusives
Nom système : RadioGroup
============================================================ */
function RadioGroup({ value, onChange, options }) {
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
overflow: 'hidden',
}}>
{options.map((o, i) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const d = typeof o === 'object' ? o.description : null;
const active = value === v;
return (
<label key={v} className="touch-press" style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
background: 'var(--bg-1)',
}}>
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
</div>
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
</label>
);
})}
</div>
);
}
/* ============================================================
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
Nom système : MediaInsert
Cas : ajouter une pièce jointe dans un formulaire mobile.
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
et navigator.geolocation pour le GPS.
============================================================ */
function MediaInsert({ onPick }) {
const items = [
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
];
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
}}>
{items.map((it) => (
<label key={it.id} className="touch-press" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, padding: '14px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
color: 'var(--ink-1)',
cursor: 'pointer',
textAlign: 'center',
WebkitTapHighlightColor: 'transparent',
minHeight: 72,
}}>
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
{!it.special && (
<input type="file" accept={it.accept} capture={it.capture}
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
style={{ display: 'none' }} />
)}
{it.special && (
<input type="button" onClick={() => {
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
navigator.geolocation.getCurrentPosition(
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
(err) => onPick && onPick('gps', { error: err.message }),
);
}} style={{ display: 'none' }} />
)}
</label>
))}
</div>
);
}
/* ============================================================
AvatarLogo — gros logo rond pour écran de connexion
Nom système : AvatarLogo
============================================================ */
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
return (
<div style={{
width: size, height: size, borderRadius: size * 0.28,
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: glow
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
margin: '0 auto',
}}>
<Icon name={icon} size={size * 0.45} />
</div>
);
}
/* ============================================================
BiometricButton — bouton biométrie (Face ID / Touch ID)
Nom système : BiometricButton
============================================================ */
function BiometricButton({ kind = 'face', label, onClick }) {
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
return (
<button onClick={onClick} className="touch-press" style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '8px 14px',
background: 'transparent', border: 'none',
color: 'var(--accent)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
{lbl}
</button>
);
}
Object.assign(window, {
FormField, TextInput, DateInput, Dropdown,
CheckboxItem, RadioGroup, MediaInsert,
AvatarLogo, BiometricButton,
});
/* ============================================================
CATALOGUE KEYBOARD — pour la doc
============================================================ */
const KEYBOARD_CATALOG = [
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
];
const AUTOCOMPLETE_CATALOG = [
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
{ name: 'tel', usage: 'Numéro de téléphone' },
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
{ name: 'off', usage: 'Désactive complètement les suggestions' },
];
const ENTER_HINT_CATALOG = [
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
{ name: 'previous', usage: 'Revenir au champ précédent' },
];
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
@@ -0,0 +1,286 @@
/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });
@@ -0,0 +1,407 @@
/* ============================================================
mobile-kit.jsx
Composants mobile-first du design system.
Tous nommés explicitement et exposés sur window.
Tactile-ready : hit targets ≥ 44px, animations fluides,
pas de hover, feedback au touch.
============================================================ */
const { useState: uM, useRef: rM, useEffect: eM } = React;
/* ============================================================
StatusBar — barre de statut iOS-like (en haut de l'écran)
Nom système : StatusBar
Usage : décor en haut de toute page mobile.
============================================================ */
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
return (
<div style={{
height: 44, flex: '0 0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 22px',
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color: 'var(--ink-1)',
}}>
<span>{time}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* signal bars */}
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
{[1, 2, 3, 4].map((b) => (
<span key={b} style={{
width: 3, height: 3 + b * 2, borderRadius: 1,
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
}} />
))}
</span>
<Icon name="network" size={13} />
{/* battery */}
<span style={{
width: 24, height: 11, borderRadius: 3,
border: '1px solid var(--ink-1)',
position: 'relative', marginLeft: 2,
}}>
<span style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${battery / 100})`,
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
borderRadius: 1,
}} />
<span style={{
position: 'absolute', right: -3, top: 3, bottom: 3,
width: 2, background: 'var(--ink-1)',
borderRadius: '0 1px 1px 0',
}} />
</span>
</span>
</div>
);
}
/* ============================================================
NavBar — barre de navigation en haut (titre + actions)
Nom système : NavBar
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
============================================================ */
function NavBar({ title, subtitle, onBack, right, large }) {
return (
<div style={{
flex: '0 0 auto',
padding: large ? '8px 16px 16px' : '8px 12px',
display: 'flex', flexDirection: 'column', gap: 4,
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderBottom: '1px solid var(--border-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
{onBack && (
<button onClick={onBack} style={{
width: 36, height: 36, borderRadius: 8,
background: 'transparent', border: 'none',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>
<Icon name="chevL" size={20} />
</button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!large && (
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
{title}
</div>
)}
</div>
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
</div>
{large && (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
</div>
)}
</div>
);
}
/* ============================================================
TabBar — barre d'onglets en bas (iOS/Android)
Nom système : TabBar
Usage : navigation principale entre 3-5 sections de l'app.
============================================================ */
function TabBar({ items, active, onSelect }) {
return (
<div style={{
flex: '0 0 auto',
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
padding: '6px 8px 18px',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderTop: '1px solid var(--border-2)',
}}>
{items.map((it) => {
const isActive = active === it.id;
return (
<button key={it.id} onClick={() => onSelect(it.id)} style={{
flex: 1, minHeight: 50,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 3, padding: 0,
background: 'transparent', border: 'none',
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
cursor: 'pointer',
transition: 'color .2s, transform .12s',
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
}}>
<Icon name={it.icon} size={22} />
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em', textTransform: 'uppercase',
fontWeight: isActive ? 700 : 500,
}}>{it.label}</span>
</button>
);
})}
</div>
);
}
/* ============================================================
ListRow — ligne d'une liste réglages (style iOS)
Nom système : ListRow
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
============================================================ */
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
const isInteractive = !!onClick;
const Tag = isInteractive ? 'button' : 'div';
return (
<Tag onClick={onClick} style={{
width: '100%',
minHeight: 52,
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: 'transparent',
border: 'none', borderBottom: '1px solid var(--border-1)',
color: danger ? 'var(--err)' : 'var(--ink-1)',
cursor: isInteractive ? 'pointer' : 'default',
textAlign: 'left',
transition: 'background .12s',
WebkitTapHighlightColor: 'transparent',
}}
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
{icon && (
<span style={{
width: 30, height: 30, borderRadius: 7,
background: iconColor || 'var(--bg-4)',
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
}}>
<Icon name={icon} size={15} />
</span>
)}
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
{right}
</Tag>
);
}
/* ============================================================
ListSection — groupe de ListRow avec titre
Nom système : ListSection
============================================================ */
function ListSection({ title, hint, children }) {
return (
<div style={{ marginBottom: 18 }}>
{title && (
<div style={{
padding: '0 16px 6px',
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>{title}</div>
)}
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
margin: '0 12px',
overflow: 'hidden',
boxShadow: 'var(--shadow-1)',
}}>{children}</div>
{hint && (
<div style={{
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
lineHeight: 1.4,
}}>{hint}</div>
)}
</div>
);
}
/* ============================================================
ActionCard — grosse carte d'action tactile
Nom système : ActionCard
Usage : actions principales sur écran d'accueil.
============================================================ */
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
return (
<button onClick={onClick} className="touch-press" style={{
flex: 1, minWidth: 0, minHeight: 110,
padding: 14,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--ink-1)',
textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 6,
cursor: 'pointer',
boxShadow: 'var(--tile-3d)',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 38, height: 38, borderRadius: 9,
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
}}>
<Icon name={icon} size={18} />
</span>
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
{value && (
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
)}
{badge && (
<span style={{
position: 'absolute', top: 10, right: 10,
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px',
background: 'var(--err)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge}</span>
)}
</button>
);
}
/* ============================================================
PrimaryButton — gros bouton plein largeur tactile
Nom système : PrimaryButton
Usage : action principale d'un écran (sauvegarder, valider).
============================================================ */
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
const sizes = {
md: { h: 44, fontSize: 14 },
lg: { h: 52, fontSize: 16 },
}[size];
const styles = {
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="touch-press" style={{
width: '100%',
height: sizes.h,
background: styles.bg,
color: styles.fg,
border: `1px solid ${styles.bd}`,
borderRadius: 12,
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
WebkitTapHighlightColor: 'transparent',
}}>
{icon && <Icon name={icon} size={18} />}
{children}
</button>
);
}
/* ============================================================
SegmentedControl — sélecteur segmenté iOS-style
Nom système : SegmentedControl
Usage : 2-4 options exclusives, jamais plus.
============================================================ */
function SegmentedControl({ value, onChange, options }) {
return (
<div style={{
display: 'flex',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 9,
padding: 3,
gap: 2,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'string' ? null : o.icon;
const active = value === v;
return (
<button key={v} onClick={() => onChange(v)} style={{
flex: 1, minHeight: 36,
padding: '6px 10px',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: 'none', borderRadius: 6,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
transition: 'background .18s, color .18s, transform .12s',
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={13} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
SearchBar — champ de recherche mobile
Nom système : SearchBar
============================================================ */
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
}} />
{value && (
<button onClick={() => onChange('')} style={{
width: 22, height: 22, borderRadius: '50%',
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}><Icon name="close" size={10} /></button>
)}
</div>
);
}
Object.assign(window, {
StatusBar, NavBar, TabBar, ListRow, ListSection,
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
});
/* Effets tactiles : pression au touch (pas de hover) */
(function injectMobileFX() {
if (document.getElementById('mobile-fx')) return;
const s = document.createElement('style');
s.id = 'mobile-fx';
s.textContent = `
.touch-press {
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
}
.touch-press:active {
transform: scale(0.97);
filter: brightness(0.92);
}
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,390 @@
/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet — feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet — menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog — alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast — notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB — Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh — wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});
@@ -0,0 +1,137 @@
/* ============================================================
mobile-swipeable.jsx
SwipeableRow — ligne qui révèle des actions au swipe.
============================================================ */
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
/* ============================================================
SwipeableRow
Nom système : SwipeableRow
Cas d'usage : ligne d'une liste avec actions cachées
(archive, suppression, marquer comme lu…).
Style iOS Mail / Things / Apple Reminders.
Gestes : SwipeLeft (révèle leftActions à droite),
SwipeRight (révèle rightActions à gauche),
Tap sur la ligne (action principale),
Tap sur une action (déclenche l'action puis ferme).
============================================================ */
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
// leftActions s'affichent quand on swipe vers la GAUCHE
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
const [tx, setTx] = uSw(0);
const [dragging, setDragging] = uSw(false);
const startX = rSw(0);
const initialTx = rSw(0);
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
const snap = (x) => {
if (x < -leftW * 0.5) setTx(-leftW);
else if (x > rightW * 0.5) setTx(rightW);
else setTx(0);
};
const onStart = (e) => {
setDragging(true);
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
initialTx.current = tx;
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
let d = initialTx.current + (x - startX.current);
// limite + élasticité hors zone
if (d > rightW) d = rightW + (d - rightW) * 0.3;
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
setTx(d);
};
const onEnd = () => {
setDragging(false);
snap(tx);
};
const fire = (action) => {
setTx(0);
setTimeout(() => action.onClick && action.onClick(), 200);
};
const handleTap = (e) => {
if (tx !== 0) { setTx(0); return; }
if (Math.abs(tx) < 4 && onTap) onTap(e);
};
return (
<div style={{
position: 'relative',
overflow: 'hidden',
background: 'var(--bg-3)',
WebkitUserSelect: 'none', userSelect: 'none',
}}>
{/* Actions à GAUCHE (révélées par swipe droit) */}
{rightActions.length > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: rightW,
}}>
{rightActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--info)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Actions à DROITE (révélées par swipe gauche) */}
{leftActions.length > 0 && (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: leftW,
}}>
{leftActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--err)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Ligne déplaçable */}
<div
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
onClick={handleTap}
style={{
position: 'relative',
background: 'var(--bg-3)',
transform: `translateX(${tx}px)`,
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
touchAction: 'pan-y',
}}>
{children}
</div>
</div>
);
}
Object.assign(window, { SwipeableRow });
@@ -0,0 +1,656 @@
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,192 @@
# Consignes mobile — mon design system
> **Tu es un agent IA qui produit du code mobile avec ce design system.**
> Lis ce fichier ENTIER avant d'écrire la moindre ligne. Suis les règles à la lettre.
---
## 🎯 Identité
- **Cibles** : iOS et Android via HTML/JS (Cordova, Capacitor, ou PWA)
- **Largeur ref** : 390px (iPhone 14 / Galaxy S22)
- **Hit target min** : 44 × 44px (Apple HIG / Material)
- **Style** : Gruvbox seventies — orange brûlé, fond brun délavé en sombre / gris clair usé en clair
---
## 📁 Fichiers
| Fichier | Composants exposés sur window |
|-------------------------------|----------------------------------------------------------------------------|
| `components/ui-kit.jsx` | Icon, Tooltip, Button, IconButton, Toggle, StatusLed, Popup, BatteryGauge, RadialGauge, BigRadialGauge, TreeNav, Sparkline, LineChart |
| `components/mobile-kit.jsx` | StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar |
| `components/mobile-sheets.jsx`| BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh |
| `components/mobile-gestures.jsx`| useGesture, GestureZone, GESTURE_CATALOG |
| `components/mobile-swipeable.jsx`| SwipeableRow |
| `components/mobile-forms.jsx` | FormField, TextInput, DateInput, Dropdown, CheckboxItem, RadioGroup, MediaInsert, AvatarLogo, BiometricButton |
| `components/mobile-apps.jsx` | Avatar, AvatarMenu, OnboardingSlider, ChatBubble, ChatComposer, CalendarMonth, MapView, FilterChips, QrScannerView, CameraView, FileExplorer |
---
## ⚠️ Règles absolues
1. **Hit targets ≥ 44 × 44 px** sur TOUT élément tactile. Pas de petits boutons.
2. **Pas de hover** — c'est du tactile. Utilise la pression au touch via `.touch-press`.
3. **Tooltips** sur tous les `<IconButton>` isolés (la prop `label` les active automatiquement).
4. **Toujours des polices natives mobile** : Inter / JetBrains Mono / Share Tech Mono via tokens.
5. **Animations fluides** : 180-300ms, easing `cubic-bezier(.3,.7,.3,1.2)` pour entrée, `cubic-bezier(.3,.6,.3,1)` pour mouvement.
6. **Toujours `<TabBar>` en bas** comme navigation primaire (3-5 sections).
7. **JAMAIS de popup centrée modale standard** — utilise `BottomSheet`, `ActionSheet`, `AlertDialog` ou `Toast`.
8. **Bouton Avatar en haut à droite** de chaque écran principal pour accès rapide au menu utilisateur.
9. **Variables CSS uniquement** — pas de hex en dur (`color: var(--accent)`, jamais `color: #fe8019`).
10. **Smartphone d'abord** — toute interaction doit fonctionner avec un seul pouce.
---
## 🧩 Cas → Composant à utiliser
### Structure d'écran (toujours)
```jsx
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Mon écran" right={<Avatar name="M" onClick={openMenu} />} />
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{/* contenu */}
</div>
</div>
```
Si la page fait partie d'une nav principale, ajoute `<TabBar>` après. Si elle a une action principale flottante, ajoute `<FAB>`.
### Liste à actions cachées
```jsx
<SwipeableRow
onTap={() => open(item)}
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)', onClick: del }]} // swipe gauche
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)', onClick: read }]} // swipe droit
>
<div>{contenu de la ligne}</div>
</SwipeableRow>
```
### Champ texte typé
```jsx
// Email
<TextInput value={...} onChange={...} keyboard="email" autocomplete="email" autocapitalize="off" />
// Mot de passe
<TextInput value={...} onChange={...} type="password" autocomplete="current-password" />
// OTP SMS
<TextInput value={...} onChange={...} keyboard="numeric" autocomplete="one-time-code" maxLength={6} />
// Recherche
<TextInput value={...} onChange={...} keyboard="search" enterHint="search" />
```
### Confirmation destructive
```jsx
<AlertDialog open={open} onClose={...}
icon="alert" iconColor="var(--err)"
title="Supprimer ?"
message="Cette action est irréversible."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: del },
]} />
```
### Choix dans une liste
```jsx
<BottomSheet open={...} onClose={...} title="Choisir">
<RadioGroup value={...} onChange={...} options={[...]} />
</BottomSheet>
```
### Menu d'actions sur élément
```jsx
<ActionSheet open={...} onClose={...} title="Actions"
actions={[
{ label: 'Modifier', icon: 'cog' },
{ label: 'Partager', icon: 'download' },
{ label: 'Supprimer', icon: 'close', danger: true },
]} />
```
### Feedback après action
```jsx
<Toast open={msg !== null} onClose={() => setMsg(null)} message={msg} variant="ok" />
```
### Menu utilisateur (avatar haut-droite)
```jsx
<AvatarMenu open={...} onClose={...} name="Marc" email="marc@..."
items={[
{ icon: 'user', label: 'Mon profil' },
{ icon: 'cog', label: 'Paramètres' },
{ icon: 'power', label: 'Se déconnecter', danger: true },
]} />
```
---
## 🚫 Anti-patterns
-`window.alert / confirm` → utilise `AlertDialog`
-`<button>` nu sans hit target → utilise `IconButton` ou `PrimaryButton`
- ❌ Hover effects → pas d'hover sur mobile, utilise `.touch-press`
- ❌ Popups centrées pour formulaire court → `BottomSheet`
- ❌ Sidebars > 240px → utilise plutôt drawer ou TabBar
- ❌ Tableaux denses → liste swipeable avec actions
- ❌ Couleurs en dur → toujours `var(--token)`
- ❌ Police arbitraire → `var(--font-ui)`, `var(--font-mono)`, `var(--font-terminal)`
- ❌ Boutons texte sans icône pour actions critiques → ajoute toujours une icône
- ❌ Inputs sans `keyboard`/`autocomplete` adaptés → frustre l'utilisateur
---
## 📐 Tailles
| Élément | Taille |
|----------------------|--------------------------------------------------|
| StatusBar | 44px |
| NavBar compact | 52px |
| NavBar large | ~90px |
| TabBar | 70px (avec safe area) |
| ListRow | min 52px |
| PrimaryButton lg | 52px |
| IconButton | 34px (def) / 26px (compact) / 44px (large) |
| FAB | 56 × 56 ronde |
| Toggle | 42 × 22 |
| Radius cartes | 10-14px |
| Radius boutons | 8-12px |
| Avatar | 36px (header) / 48-72px (profile) |
| Espacement standard | 8 / 12 / 14 / 18 / 24px |
---
## 💡 Détails à respecter
- **Safe area bottom** : la TabBar a déjà un `padding-bottom: 18px` pour la home indicator iOS.
- **Backdrop-filter blur** : utilisé sur NavBar/TabBar/AlertBg pour effet vitre.
- **SwipeableRow** snap : ouvre/ferme à 50% de la largeur des actions.
- **AvatarMenu** : un seul ouvert à la fois, ferme au clic extérieur (backdrop).
- **Toast** : auto-ferme à 2.5s sauf prop `duration` différent.
- **PullToRefresh** : seulement quand `scrollTop === 0`.
---
## 🌗 Dark / Light
Tout fonctionne automatiquement via `data-theme="dark|light"` sur un parent. **Toujours tester les deux** :
- En sombre : tokens chauds bruns
- En clair : gris clair usé (pas blanc pur), accent orange plus contrasté
---
## 🔚 En cas de doute
- Composant pas sûr ? → `examples/exemple-mobile-apps.html` montre quasi tous les cas
- Geste pas clair ? → onglet Gestes du smartphone dans `exemple-mobile.html`
- Saisie spéciale ? → `exemple-mobile-saisie.html` + section "Antisèche"
Toujours préférer un composant existant à un custom. Quand tu doutes, **demande**.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — patterns d'app</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 130px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="mobile-apps.jsx"></script>
<script type="text/babel" src="exemple-mobile-apps-combined.jsx"></script>
</body>
</html>
@@ -0,0 +1,341 @@
/* ============================================================
exemple-mobile-saisie-app.jsx — partie 1
Écrans du smartphone (Login, Profile, Form, SwipeList).
La partie Doc + ROOT est dans exemple-mobile-saisie-doc.jsx.
============================================================ */
const { useState: uMS, useEffect: eMS } = React;
/* ============================================================
ÉCRAN 1 — Login
============================================================ */
function ScreenLogin({ onLogin, showToast }) {
const [email, setEmail] = uMS('');
const [pwd, setPwd] = uMS('');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<div style={{
flex: 1, padding: '32px 24px', overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<div style={{ textAlign: 'center', marginTop: 24, marginBottom: 12 }}>
<AvatarLogo icon="server" size={80} />
<div style={{ fontSize: 26, fontWeight: 700, marginTop: 16 }}>Bienvenue</div>
<div style={{ fontSize: 14, color: 'var(--ink-3)', marginTop: 4 }}>Connecte-toi à ton compte</div>
</div>
<FormField label="Email">
<TextInput value={email} onChange={setEmail}
placeholder="prenom@exemple.com"
type="email" icon="bell"
keyboard="email"
autocomplete="email"
autocapitalize="off"
spellCheck={false}
enterHint="next" />
</FormField>
<FormField label="Mot de passe" hint="≥ 8 caractères, 1 chiffre">
<TextInput value={pwd} onChange={setPwd}
placeholder="••••••••"
type="password" icon="power"
keyboard="text"
autocomplete="current-password"
autocapitalize="off"
spellCheck={false}
enterHint="go" />
</FormField>
<a href="#" onClick={(e) => { e.preventDefault(); showToast('Email de réinitialisation envoyé'); }}
style={{ fontSize: 13, color: 'var(--accent)', textAlign: 'right', textDecoration: 'none' }}>
Mot de passe oublié ?
</a>
<div style={{ marginTop: 6 }}>
<PrimaryButton icon="play" onClick={() => { onLogin(); showToast('Bienvenue Marc !'); }}>
Se connecter
</PrimaryButton>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0',
color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11,
}}>
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
OU
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24 }}>
<BiometricButton kind="face" onClick={() => { onLogin(); showToast('Face ID OK'); }} />
<BiometricButton kind="touch" onClick={() => { onLogin(); showToast('Touch ID OK'); }} />
</div>
<div style={{ textAlign: 'center', marginTop: 16, fontSize: 14, color: 'var(--ink-3)' }}>
Pas encore de compte ?{' '}
<a href="#" onClick={(e) => e.preventDefault()} style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
S'inscrire
</a>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 2 — Profile (avec bouton Paramètres haut-droite)
============================================================ */
function ScreenProfile({ openSettings }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Profil" right={
<IconButton icon="cog" label="Paramètres" onClick={openSettings} size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<div style={{
padding: '20px 16px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<AvatarLogo icon="user" size={72} />
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 8 }}>Marc Dupont</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }} className="mono">admin · marc@exemple.com</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--ok-glow)', color: 'var(--ok)',
border: '1px solid var(--ok)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>● connecté</span>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--accent-tint)', color: 'var(--accent)',
border: '1px solid var(--accent)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>premium</span>
</div>
</div>
<ListSection title="Mon compte">
<ListRow icon="user" iconColor="var(--blue)" label="Informations personnelles" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" value="3" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--ok)" label="Sécurité & connexion" onClick={() => {}} />
</ListSection>
<ListSection title="Mes données">
<ListRow icon="download" iconColor="var(--info)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="folder" iconColor="var(--purple)" label="Mes documents" value="124" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="close" iconColor="var(--ink-4)" label="Se déconnecter" onClick={() => {}} />
</ListSection>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 3 — Form (formulaire de saisie complet)
============================================================ */
function ScreenForm({ showToast, openSheet }) {
const [title, setTitle] = uMS('');
const [date, setDate] = uMS('2026-05-21');
const [time, setTime] = uMS('14:30');
const [body, setBody] = uMS('');
const [category, setCategory] = uMS('');
const [priority, setPriority] = uMS('normal');
const [tags, setTags] = uMS({ urgent: false, perso: true, travail: false });
const [confirmed, setConfirmed] = uMS(false);
const [media, setMedia] = uMS([]);
const onMedia = (kind, data) => {
if (kind === 'gps' && data && data.lat) {
setMedia([...media, { kind: 'gps', label: `GPS · ${data.lat.toFixed(4)}, ${data.lon.toFixed(4)}` }]);
} else if (data && data.name) {
setMedia([...media, { kind, label: `${kind} · ${data.name}` }]);
} else {
showToast(`${kind} sélectionné`);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Nouvelle note"
onBack={() => showToast('Retour')}
right={
<button onClick={() => { showToast('Enregistré'); }} style={{
padding: '6px 12px',
background: 'transparent', border: 'none',
color: 'var(--accent)', fontFamily: 'var(--font-ui)',
fontWeight: 700, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>OK</button>
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 80px' }}>
<FormField label="Titre" required>
<TextInput value={title} onChange={setTitle}
placeholder="Titre de la note"
keyboard="text" autocapitalize="sentences"
enterHint="next" maxLength={80} icon="list" />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<FormField label="Date">
<DateInput value={date} onChange={setDate} mode="date" />
</FormField>
<FormField label="Heure">
<DateInput value={time} onChange={setTime} mode="time" />
</FormField>
</div>
<FormField label="Contenu" hint="Décris ce qui doit être fait.">
<TextInput value={body} onChange={setBody}
placeholder="Tape ton texte ici…"
multiline rows={4}
keyboard="text" autocapitalize="sentences"
spellCheck={true} />
</FormField>
<FormField label="Catégorie">
<Dropdown value={category} onChange={setCategory}
placeholder="Choisir une catégorie…"
options={[
{ value: 'todo', label: 'À faire' },
{ value: 'note', label: 'Note simple' },
{ value: 'meeting', label: 'Réunion' },
{ value: 'bug', label: 'Bug à corriger' },
]} />
</FormField>
<FormField label="Priorité">
<RadioGroup value={priority} onChange={setPriority} options={[
{ value: 'low', label: 'Basse', description: 'Sans urgence' },
{ value: 'normal', label: 'Normale', description: 'Par défaut' },
{ value: 'high', label: 'Haute', description: 'À traiter rapidement' },
]} />
</FormField>
<FormField label="Étiquettes">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries({ urgent: 'Urgent', perso: 'Perso', travail: 'Travail' }).map(([k, v]) => (
<CheckboxItem key={k}
checked={tags[k]}
onChange={(c) => setTags({ ...tags, [k]: c })}
label={v} />
))}
</div>
</FormField>
<FormField label="Pièces jointes">
<MediaInsert onPick={onMedia} />
{media.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{media.map((m, i) => (
<div key={i} style={{
padding: '8px 12px', fontSize: 12, fontFamily: 'var(--font-mono)',
background: 'var(--bg-3)', border: '1px solid var(--border-2)',
borderRadius: 8, color: 'var(--ink-2)',
}}>📎 {m.label}</div>
))}
</div>
)}
</FormField>
<FormField label="Code de confirmation" hint="On t'envoie un code par SMS.">
<TextInput value="" onChange={() => {}}
placeholder="123456"
keyboard="numeric"
autocomplete="one-time-code"
maxLength={6} icon="bell" />
</FormField>
<CheckboxItem checked={confirmed} onChange={setConfirmed}
label="J'accepte les conditions"
description="En cochant, tu acceptes notre politique." />
<div style={{ marginTop: 16 }}>
<PrimaryButton icon="download" onClick={() => showToast('Note enregistrée')}>
Enregistrer la note
</PrimaryButton>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 4 — Liste avec SwipeableRow
============================================================ */
function ScreenSwipe({ showToast }) {
const [items, setItems] = uMS([
{ id: 1, title: 'Sauvegarde serveur OK', from: 'cron@srv', time: '14:02', unread: true },
{ id: 2, title: 'Latence élevée détectée', from: 'monitoring', time: '13:58', unread: true },
{ id: 3, title: 'Rappel : réunion équipe', from: 'agenda', time: '11:30', unread: false },
{ id: 4, title: 'Mise à jour disponible', from: 'systeme', time: '09:14', unread: false },
{ id: 5, title: 'Nouveau hôte sur le réseau', from: 'ipwatch', time: '08:42', unread: true },
]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Notifications" subtitle="essaie de swiper une ligne ←→" />
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 80 }}>
{items.map((it) => (
<SwipeableRow key={it.id}
onTap={() => showToast(`Ouvrir : ${it.title}`)}
rightActions={[
{ label: 'Lu', icon: 'play', color: 'var(--info)',
onClick: () => setItems(items.map((x) => x.id === it.id ? { ...x, unread: false } : x)) },
{ label: 'Épingl.', icon: 'bell', color: 'var(--accent)',
onClick: () => showToast('Épinglé') },
]}
leftActions={[
{ label: 'Archiv.', icon: 'folder', color: 'var(--ok)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Archivé'); } },
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Supprimé'); } },
]}>
<div style={{
padding: '14px 16px',
display: 'flex', gap: 12, alignItems: 'flex-start',
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: it.unread ? 'var(--accent)' : 'transparent',
marginTop: 6, flex: '0 0 auto',
boxShadow: it.unread ? '0 0 6px var(--accent-glow)' : 'none',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: it.unread ? 700 : 500, color: 'var(--ink-1)' }}>{it.from}</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }} className="mono">{it.time}</span>
</div>
<div style={{ fontSize: 14, color: 'var(--ink-2)', marginTop: 2 }}>{it.title}</div>
</div>
</div>
</SwipeableRow>
))}
{items.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ink-3)' }}>
Plus de notifications — fais un swipe sur une ligne ←→ pour voir les actions.
</div>
)}
<div style={{
padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)',
}}>
swipe gauche : archiver/supprimer · swipe droit : marquer lu/épingler
</div>
</div>
</div>
);
}
Object.assign(window, { ScreenLogin, ScreenProfile, ScreenForm, ScreenSwipe });
@@ -0,0 +1,486 @@
/* ============================================================
exemple-mobile-saisie-doc.jsx — partie 2
Doc panneau droit (catalogue commenté avec visuels) + ROOT.
============================================================ */
const { useState: uDS, useEffect: eDS } = React;
/* ============================================================
VISUALS ============================================================ */
/* Mini-clavier virtuel selon le type */
function KeyboardVisual({ kind }) {
const wrap = (cells) => (
<div style={{
padding: 10, background: 'var(--bg-1)',
border: '1px solid var(--border-2)', borderRadius: 8,
display: 'flex', flexDirection: 'column', gap: 4,
width: '100%',
}}>{cells.map((c, i) => <React.Fragment key={i}>{c}</React.Fragment>)}</div>
);
const row = (keys, big) => (
<div style={{ display: 'flex', gap: 3, justifyContent: 'center' }}>
{keys.map((k, i) => (
<span key={i} style={{
flex: big ? 1 : '0 1 auto',
minWidth: big ? 0 : 16, height: 22, padding: '0 4px',
background: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--accent)' : 'var(--bg-3)',
color: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--bg-1)' : 'var(--ink-1)',
border: '1px solid var(--border-2)',
borderRadius: 4,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
}}>{k}</span>
))}
</div>
);
if (kind === 'text') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','↵'], true)]);
if (kind === 'numeric') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row(['','0','⌫'], true)]);
if (kind === 'decimal') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row([',','0','⌫'], true)]);
if (kind === 'tel') return wrap([row(['1','2 ABC','3 DEF'], true), row(['4 GHI','5 JKL','6 MNO'], true), row(['7 PQRS','8 TUV','9 WXYZ'], true), row(['+ * #','0 +','⌫'], true)]);
if (kind === 'email') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','@','espace','.','↵'], true)]);
if (kind === 'url') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','.','/','.com','Aller'], true)]);
if (kind === 'search') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','Rechercher'], true)]);
if (kind === 'none') return (
<div style={{
padding: 14, background: 'var(--bg-1)',
border: '1px dashed var(--border-3)', borderRadius: 8,
textAlign: 'center', color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)', fontSize: 11,
}}>(aucun clavier picker custom)</div>
);
return null;
}
/* Mini SVG phone pour montrer les écrans */
function ScreenVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block' }}>
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{inner}
</svg>
);
if (type === 'login') return phone(
<g>
<circle cx="50" cy="40" r="12" fill="var(--accent)"/>
<rect x="20" y="68" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="82" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="100" width="60" height="11" rx="5" fill="var(--accent)"/>
<line x1="22" y1="125" x2="42" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<line x1="58" y1="125" x2="78" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<text x="50" y="128" textAnchor="middle" fontSize="6" fontFamily="JetBrains Mono" fill="var(--ink-4)">OU</text>
<circle cx="42" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
<circle cx="58" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
</g>
);
if (type === 'profile') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Profil</text>
<rect x="80" y="17" width="9" height="9" rx="2" fill="var(--accent-tint)" stroke="var(--accent)" strokeWidth="0.5"/>
<circle cx="50" cy="48" r="12" fill="var(--accent)"/>
<rect x="30" y="65" width="40" height="5" rx="2" fill="var(--ink-2)"/>
<rect x="36" y="74" width="28" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="10" y="92" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="110" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="128" width="80" height="14" rx="4" fill="var(--bg-2)"/>
</g>
);
if (type === 'form') return phone(
<g>
<rect x="10" y="22" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="52" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="50" width="80" height="22" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="78" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<circle cx="16" cy="98" r="2.5" fill="none" stroke="var(--accent)"/>
<rect x="22" y="96" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<circle cx="16" cy="106" r="2.5" fill="var(--accent)"/>
<rect x="22" y="104" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<rect x="10" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--accent)"/>
<rect x="38" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="66" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="158" width="80" height="12" rx="5" fill="var(--accent)"/>
</g>
);
if (type === 'swipe') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Boîte</text>
<rect x="3" y="32" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="52" x2="97" y2="52" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="52" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="72" x2="97" y2="72" stroke="var(--border-1)" strokeWidth="0.4"/>
<g transform="translate(-26, 0)">
<rect x="3" y="72" width="94" height="20" fill="var(--bg-3)"/>
</g>
<rect x="71" y="72" width="13" height="20" fill="var(--info)"/>
<rect x="84" y="72" width="13" height="20" fill="var(--accent)"/>
<text x="77.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Lu</text>
<text x="90.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Pin</text>
<rect x="3" y="92" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="112" x2="97" y2="112" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="112" width="94" height="20" fill="var(--bg-3)"/>
<path d="M 80 102 l -6 0 M 80 102 l 4 -3 M 80 102 l 4 3" stroke="var(--accent)" strokeWidth="1" fill="none"/>
</g>
);
return phone(null);
}
/* ============================================================
DOC PANEL
============================================================ */
function NamedItem({ name, desc, location, preview }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
{preview && (
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
}}>{preview}</div>
)}
</div>
);
}
function ScreenCard({ type, name, when, why, gestures, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<ScreenVisual type={type} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>Écran {name}</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
{gestures && <div className="row-use"><span className="k">Gestes</span><span className="v">{gestures}</span></div>}
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
function Doc() {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Saisie & formulaires mobile</h2>
<p className="desc">
Suite logique de la variante mobile : <strong>écrans de connexion, profil, formulaire complet,
liste swipeable</strong>. Tous les composants sont nommés et le clavier virtuel se configure
précisément (8 types, autocomplete système, touche Entrée personnalisable).
</p>
</section>
{/* ÉCRANS */}
<section id="screens">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> 4 écrans modèles</h2>
<p className="desc">Chaque écran combine plusieurs composants. Bascule entre eux via les onglets en bas du smartphone.</p>
<ScreenCard
type="login"
name="Connexion"
when="Avant tout accès à l'app, ou pour se reconnecter."
why="Format unifié : logo + email + mot de passe + biométrie + lien créa de compte."
gestures="Tap sur champs · Tap sur Face ID / Touch ID · enterKeyHint='go' soumet le formulaire"
example="Onglet ☐ Login du smartphone à gauche" />
<ScreenCard
type="profile"
name="Profil utilisateur"
when="L'utilisateur veut voir/modifier ses infos."
why="Tête de page avec avatar + actions de compte + bouton ⚙ paramètres en haut à droite."
gestures="Tap sur ⚙ ouvre une BottomSheet de paramètres"
example="Onglet ☐ Profil du smartphone" />
<ScreenCard
type="form"
name="Formulaire de saisie"
when="Création/édition d'un objet (note, tâche, contact…)."
why="Tous les types d'inputs en une seule page : titre, dates, textarea, dropdown, radio, checkboxes, médias."
gestures="Tap sur OK valide · onBack remonte d'un cran"
example="Onglet ☐ Formulaire du smartphone" />
<ScreenCard
type="swipe"
name="Liste swipeable"
when="Liste d'éléments avec actions cachées (mails, notifs, tâches)."
why="Économise l'espace : actions hors-écran révélées au geste."
gestures="SwipeLeft → archive/supprime · SwipeRight → marquer lu/épingler · Tap → ouvrir"
example="Onglet ☐ Notifications du smartphone" />
</section>
{/* COMPOSANTS */}
<section id="components">
<h2><Icon name="cog" size={22} style={{ color: 'var(--accent)' }} /> Composants de saisie</h2>
<p className="desc">Tous ont une API homogène : <code className="mono" style={{color:'var(--accent)'}}>value / onChange / label / hint / error</code>. Les inputs supportent en plus le contrôle clavier virtuel.</p>
<NamedItem name="FormField" location="Wrapper de tout champ"
desc="Cadre standard : label en haut, champ au milieu, hint/erreur en bas. À utiliser autour de chaque champ pour homogénéiser." />
<NamedItem name="TextInput" location="Formulaire, Login"
desc="Champ texte unifié avec contrôle complet du clavier virtuel : type d'entrée (text/email/numeric/tel…), auto-complétion système (email, mot de passe, code OTP), texte de la touche Entrée (next, send, search…), majuscules auto, correction orthographique. Mode multiline pour textarea."
preview={<TextInput value="exemple@..." onChange={() => {}} keyboard="email" icon="bell" />} />
<NamedItem name="DateInput" location="Formulaire"
desc="Date/heure picker natif du téléphone. Modes : date, time, datetime-local, month, week. Affiche le picker iOS/Android natif au focus."
preview={<DateInput value="2026-05-21" onChange={() => {}} mode="date" />} />
<NamedItem name="Dropdown" location="Formulaire"
desc="Select natif avec habillage Gruvbox. Sur mobile, ouvre le sélecteur roulette iOS ou le menu déroulant Android. À utiliser dès 4+ options."
preview={<Dropdown value="" onChange={() => {}} placeholder="Choisir…" options={['Option A', 'Option B', 'Option C']} />} />
<NamedItem name="CheckboxItem" location="Formulaire"
desc="Case à cocher avec label + description optionnelle. Pour des options indépendantes (multi-sélection)."
preview={<CheckboxItem checked={true} onChange={() => {}} label="J'accepte les conditions" description="En cochant tu acceptes notre politique." />} />
<NamedItem name="RadioGroup" location="Formulaire"
desc="Liste d'options exclusives empilées verticalement avec puce circulaire. Pour 2-6 options. Au-delà, utilise un Dropdown."
preview={<RadioGroup value="b" onChange={() => {}} options={[
{ value: 'a', label: 'Option A', description: 'Première option' },
{ value: 'b', label: 'Option B', description: 'Deuxième option' },
]} />} />
<NamedItem name="MediaInsert" location="Formulaire"
desc="Grille 3 colonnes de boutons pour ajouter une pièce jointe : Photo (caméra arrière), Image (galerie), Vidéo, Audio (micro), Fichier (doc), Position (GPS via navigator.geolocation). Chaque type définit l'attribut HTML accept et capture."
preview={<MediaInsert onPick={() => {}} />} />
<NamedItem name="AvatarLogo" location="Login, Profil"
desc="Gros logo carré arrondi avec icône et glow accent. Pour l'identité visuelle d'un écran (login, profil, vide d'état)."
preview={<AvatarLogo icon="server" size={48} />} />
<NamedItem name="BiometricButton" location="Login"
desc="Bouton biométrique (Face ID / Touch ID). Style natif iOS — icône large + label. À placer sous le bouton principal de login."
preview={<div style={{display:'flex', gap: 16, justifyContent:'center'}}><BiometricButton kind="face" /><BiometricButton kind="touch" /></div>} />
<NamedItem name="SwipeableRow" location="Liste swipeable"
desc="Ligne d'une liste qui révèle des actions au swipe. leftActions = actions à droite (révélées en swipant vers la gauche), rightActions = actions à gauche (révélées en swipant vers la droite). Chaque action a icon, label, color, onClick. Tap sur la ligne = onTap principal."
preview={
<SwipeableRow
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)' }]}
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)' }]}>
<div style={{ padding: 12, background: 'var(--bg-3)', fontSize: 13 }}>
swipe-moi dans un sens ou l'autre →
</div>
</SwipeableRow>
} />
</section>
{/* CLAVIER VIRTUEL */}
<section id="keyboard">
<h2><Icon name="terminal" size={22} style={{ color: 'var(--accent)' }} /> Clavier virtuel</h2>
<p className="desc">
Sur mobile, le clavier qui s'affiche dépend de la prop <code className="mono" style={{color:'var(--accent)'}}>keyboard</code> (attribut HTML <code className="mono">inputmode</code>).
Choisis le BON type pour faire gagner du temps à l'utilisateur — exemple : <code className="mono">keyboard="numeric"</code> pour un code OTP fait apparaître directement le pavé numérique au lieu du clavier complet.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{KEYBOARD_CATALOG.map((k) => (
<div key={k.name} className="card" style={{ margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="pill-name">{k.name}</span>
</div>
<KeyboardVisual kind={k.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 10, lineHeight: 1.4 }}>{k.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 4 }}>
Usage : {k.usage}
</div>
</div>
))}
</div>
</section>
{/* AUTOCOMPLETE */}
<section id="autocomplete">
<h2><Icon name="refresh" size={22} style={{ color: 'var(--accent)' }} /> Aide à la saisie (autocomplete)</h2>
<p className="desc">
L'attribut <code className="mono" style={{color:'var(--accent)'}}>autocomplete</code> dit au système ce que représente le champ.
Sur iOS/Android, ça déclenche : remplissage automatique (nom, email, adresse), proposition du mot de passe enregistré, génération d'un nouveau mot de passe, lecture auto du code SMS reçu.
</p>
<div className="card">
{AUTOCOMPLETE_CATALOG.map((a) => (
<div key={a.name} className="row-use">
<span className="k">{a.name}</span>
<span className="v">{a.usage}</span>
</div>
))}
</div>
</section>
{/* ENTER KEY HINT */}
<section id="enter-hint">
<h2><Icon name="chevR" size={22} style={{ color: 'var(--accent)' }} /> Touche Entrée — enterKeyHint</h2>
<p className="desc">
La touche en bas à droite du clavier peut afficher un mot différent selon le contexte (au lieu du standard "Entrée").
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{ENTER_HINT_CATALOG.map((e) => (
<div key={e.name} className="card" style={{ margin: 0, padding: 14 }}>
<div style={{
display: 'inline-block',
padding: '4px 12px', borderRadius: 6,
background: 'var(--accent)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
marginBottom: 8,
}}>{e.name}</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{e.usage}</div>
</div>
))}
</div>
</section>
{/* CHEAT SHEET */}
<section id="cheatsheet">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Antisèche · combinaisons utiles</h2>
<div className="card">
<div className="row-use">
<span className="k">Email</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="email"</code> + <code className="mono">autocomplete="email"</code> + <code className="mono">autocapitalize="off"</code></span>
</div>
<div className="row-use">
<span className="k">Mot de passe</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>type="password"</code> + <code className="mono">autocomplete="current-password"</code> (ou <code className="mono">"new-password"</code> en inscription)</span>
</div>
<div className="row-use">
<span className="k">Code OTP SMS</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="numeric"</code> + <code className="mono">autocomplete="one-time-code"</code> + <code className="mono">maxLength=6</code></span>
</div>
<div className="row-use">
<span className="k">Téléphone</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="tel"</code> + <code className="mono">autocomplete="tel"</code></span>
</div>
<div className="row-use">
<span className="k">Recherche</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="search"</code> + <code className="mono">enterHint="search"</code></span>
</div>
<div className="row-use">
<span className="k">Prix / mesure</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="decimal"</code></span>
</div>
<div className="row-use">
<span className="k">Adresse</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocomplete="address-line1"</code>, puis <code className="mono">postal-code</code>, <code className="mono">country</code></span>
</div>
<div className="row-use">
<span className="k">Texte libre</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocapitalize="sentences"</code> + <code className="mono">spellCheck=true</code></span>
</div>
</div>
</section>
</div>
);
}
/* ============================================================
APP ROOT
============================================================ */
function PhoneAppSaisie({ theme }) {
const [tab, setTab] = uDS('login');
const [toast, setToast] = uDS(null);
const [sheet, setSheet] = uDS(false);
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)', color: 'var(--ink-1)',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'login' && <ScreenLogin onLogin={() => setTab('profile')} showToast={showToast} />}
{tab === 'profile' && <ScreenProfile openSettings={() => setSheet(true)} />}
{tab === 'form' && <ScreenForm showToast={showToast} />}
{tab === 'swipe' && <ScreenSwipe showToast={showToast} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'login', icon: 'user', label: 'login' },
{ id: 'profile', icon: 'cog', label: 'profil' },
{ id: 'form', icon: 'list', label: 'form' },
{ id: 'swipe', icon: 'chevR', label: 'notifs' },
]}
/>
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Paramètres rapides">
<ListSection>
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="refresh" iconColor="var(--ok)" label="Sync auto" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
<ListSection>
<ListRow icon="power" iconColor="var(--err)" label="Se déconnecter" danger onClick={() => { setSheet(false); setTab('login'); }} />
</ListSection>
</BottomSheet>
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
function App() {
const [theme, setTheme] = uDS('dark');
const [device, setDevice] = uDS('ios');
eDS(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<React.Fragment>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="list" size={16} />
</div>
<h1>Exemple mobile · saisie <small>login · profil · form · swipe · clavier virtuel</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-mobile.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}><Icon name="chevL" size={12} /> exemple mobile</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneAppSaisie theme={theme} />
</div>
</div>
<div className="legend">↑ teste les écrans, swipe les lignes, joue avec les formulaires</div>
</div>
<Doc />
</div>
</React.Fragment>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — saisie & formulaires</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 140px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-app.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-doc.jsx"></script>
</body>
</html>
@@ -0,0 +1,952 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — mon design system</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background:
radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
/* Topbar de la page */
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
/* Layout : 2 colonnes — phone à gauche, doc à droite */
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
/* Sticky phone */
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
/* Mockup smartphone */
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow:
0 0 0 2px #2a2520,
0 0 0 8px #1a1612,
0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907;
border-radius: 18px;
z-index: 100;
pointer-events: none;
}
/* Phone controls */
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px;
padding: 3px;
gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px;
background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3);
cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent);
color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
/* Side doc */
.doc {
min-width: 0;
}
.doc section {
margin-bottom: 36px;
scroll-margin-top: 80px;
}
.doc h2 {
font-size: 22px;
margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc h2 .name {
font-family: var(--font-mono);
color: var(--accent);
font-size: 18px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px;
background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px;
box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
font-weight: 600;
}
.doc .row-use {
display: grid;
grid-template-columns: 130px 1fr;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.nav-jump {
position: sticky; top: 80px;
padding: 14px 0;
display: flex; flex-direction: column; gap: 4px;
font-family: var(--font-mono); font-size: 12px;
}
.nav-jump a {
padding: 6px 12px;
color: var(--ink-3);
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
}
.nav-jump a:hover {
background: var(--bg-3); color: var(--ink-1);
border-left-color: var(--accent);
}
/* Légende — utilisé un peu partout */
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
/* ============================================================
ECRANS DU SMARTPHONE — chacun illustre un cas d'usage
============================================================ */
/* Écran ACCUEIL : ActionCards en grille + FAB */
function PhoneHome({ goto, showToast }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<StatusBar />
<NavBar large title="Accueil" subtitle="jeudi 21 mai · tout est OK" right={
<IconButton icon="bell" label="Notifications" size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: 16, paddingBottom: 80 }}>
<SearchBar value="" onChange={() => {}} />
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginTop: 14,
}}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="network" iconColor="var(--blue)" title="Réseau" subtitle="8.4 Mb/s" value="OK" />
<ActionCard icon="disk" iconColor="var(--ok)" title="Stockage" subtitle="2 disques" value="28%" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
<div style={{ marginTop: 18 }}>
<div className="label" style={{ marginBottom: 8 }}>Services</div>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
}}>
{[
{ name: 'nginx', status: 'ok', meta: 'actif' },
{ name: 'postgres', status: 'ok', meta: 'actif' },
{ name: 'redis', status: 'warn', meta: 'latent' },
{ name: 'worker_01', status: 'err', meta: 'arrêté' },
].map((s, i, a) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
borderBottom: i < a.length - 1 ? '1px solid var(--border-1)' : 'none',
}}>
<StatusLed status={s.status} pulse={s.status !== 'ok'} />
<span className="mono" style={{ flex: 1, fontSize: 14, color: 'var(--ink-1)' }}>{s.name}</span>
<span className="mono" style={{ fontSize: 11, color: s.status === 'err' ? 'var(--err)' : s.status === 'warn' ? 'var(--warn)' : 'var(--ok)' }}>{s.meta}</span>
</div>
))}
</div>
</div>
</div>
<FAB icon="plus" label="Ajouter" onClick={() => showToast('Action FAB')} />
</div>
);
}
/* Écran DASHBOARD : KPIs + jauges */
function PhoneDashboard() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Dashboard" />
<div style={{ flex: 1, overflowY: 'auto', padding: 14, paddingBottom: 80 }}>
{/* KPIs compacts */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
<BatteryGauge compact value={64} label="CPU" icon="cpu" warnAt={70} errAt={85} />
<BatteryGauge compact value={42} label="Mémoire" icon="memory" />
<BatteryGauge compact value={28} label="Disque" icon="disk" />
<BatteryGauge compact value={92} label="Réseau" icon="network" warnAt={70} errAt={85} />
</div>
{/* Grande jauge */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
display: 'flex', flexDirection: 'column', alignItems: 'center',
marginBottom: 14,
}}>
<div className="label" style={{ alignSelf: 'flex-start', marginBottom: 8 }}>Score santé</div>
<BigRadialGauge value={87} label="stable" />
</div>
{/* Graphique */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
}}>
<div className="label" style={{ marginBottom: 8 }}>Trafic · 24h</div>
<LineChart h={140} labels={[]} series={[
{ color: 'var(--accent)', points: [12,18,14,22,28,35,30,42,38,45,52,48,55,60,52,58,45,50,38,44,36,40,32,38] },
]} />
</div>
</div>
</div>
);
}
/* Écran RÉGLAGES : ListRow style iOS */
function PhoneSettings({ openSheet, openAlert }) {
const [auto, setAuto] = useState(true);
const [notif, setNotif] = useState(false);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Réglages" />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<ListSection title="Compte">
<ListRow icon="user" iconColor="var(--blue)" label="Marc" value="admin" onClick={() => {}} />
<ListRow icon="server" iconColor="var(--accent)" label="Instance" value="prod" onClick={() => {}} />
</ListSection>
<ListSection title="Notifications" hint="Choisis quand l'app doit te déranger.">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh"
right={<Toggle on={auto} onChange={setAuto} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Notifications push"
right={<Toggle on={notif} onChange={setNotif} />} />
</ListSection>
<ListSection title="Apparence">
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => openSheet()} />
<ListRow icon="cog" iconColor="var(--ink-3)" label="Densité" value="Confort" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="download" iconColor="var(--ok)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--err)" label="Supprimer mon compte" danger onClick={openAlert} />
</ListSection>
</div>
</div>
);
}
/* Écran GESTES : terrain de test pour chaque geste */
function PhoneGestures({ activeGesture, setActiveGesture }) {
const filter = activeGesture === 'all' ? [] : [activeGesture];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Gestes" subtitle="teste chaque interaction tactile" />
<div style={{ padding: '0 14px 12px' }}>
<SegmentedControl
value={activeGesture}
onChange={setActiveGesture}
options={[
{ value: 'all', label: 'tous' },
{ value: 'tap', label: 'tap', icon: 'play' },
{ value: 'swipe', label: 'swipe', icon: 'chevR' },
{ value: 'pan', label: 'drag', icon: 'grid' },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px 80px' }}>
<GestureZone label="zone tactile · essaie ici" accept={filter} />
<div className="legend" style={{ marginTop: 8, marginBottom: 6 }}> tap · double-tap · long-press · swipe · pan · pinch</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} style={{
padding: 10,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<Icon name={g.icon} size={12} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-1)' }}>{g.name}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.3 }}>{g.desc}</div>
</div>
))}
</div>
</div>
</div>
);
}
/* ============================================================
APP COMPLÈTE DU PHONE — navigation par TabBar
============================================================ */
function PhoneApp({ theme }) {
const [tab, setTab] = useState('home');
const [sheet, setSheet] = useState(false);
const [alert, setAlert] = useState(false);
const [action, setAction] = useState(false);
const [toast, setToast] = useState(null);
const [activeGesture, setActiveGesture] = useState('all');
const [themeChoice, setThemeChoice] = useState('dark');
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)',
color: 'var(--ink-1)',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'home' && <PhoneHome showToast={showToast} />}
{tab === 'dash' && <PhoneDashboard />}
{tab === 'gestures' && <PhoneGestures activeGesture={activeGesture} setActiveGesture={setActiveGesture} />}
{tab === 'settings' && <PhoneSettings openSheet={() => setSheet(true)} openAlert={() => setAlert(true)} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'home', icon: 'grid', label: 'accueil' },
{ id: 'dash', icon: 'chart', label: 'dashboard' },
{ id: 'gestures', icon: 'play', label: 'gestes' },
{ id: 'settings', icon: 'cog', label: 'réglages' },
]}
/>
{/* BottomSheet : choix du thème (depuis Réglages > Thème) */}
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Choisir le thème">
<SegmentedControl
value={themeChoice}
onChange={setThemeChoice}
options={[
{ value: 'dark', label: 'Sombre', icon: 'moon' },
{ value: 'light', label: 'Clair', icon: 'sun' },
{ value: 'auto', label: 'Auto', icon: 'clock' },
]} />
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 14, lineHeight: 1.5 }}>
Le thème "Auto" suit automatiquement les réglages de ton téléphone (jour/nuit).
</div>
</BottomSheet>
{/* AlertDialog : confirmation destructive */}
<AlertDialog
open={alert} onClose={() => setAlert(false)}
icon="alert" iconColor="var(--err)"
title="Supprimer le compte ?"
message="Cette action est irréversible. Toutes tes données seront perdues."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: () => showToast('Compte supprimé') },
]} />
{/* ActionSheet : ouverte depuis le FAB de l'accueil */}
<ActionSheet
open={action} onClose={() => setAction(false)}
title="Que veux-tu faire ?"
actions={[
{ label: 'Lancer un scan', icon: 'refresh', onClick: () => showToast('Scan lancé') },
{ label: 'Nouveau dashboard', icon: 'plus', onClick: () => showToast('Dashboard créé') },
{ label: 'Importer données', icon: 'download' },
{ label: 'Supprimer tout', icon: 'power', danger: true },
]} />
{/* Toast */}
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
/* ============================================================
PAGE DOC à droite — catalogue avec noms en clair
============================================================ */
function Doc({ currentScreen }) {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="memory" size={22} style={{ color: 'var(--accent)' }} /> Variante mobile</h2>
<p className="desc">
Adaptation smartphone de mon design system (Gruvbox seventies).
<strong> Chaque composant a un nom explicite</strong> que tu peux utiliser pour
le demander à ton agent IA ou à un développeur. Hit targets 44px,
animations fluides, dark + light, optimisé iOS / Android.
</p>
<div className="card">
<div className="row-use"><span className="k">Largeur réf.</span><span className="v">390 px (iPhone 14, Galaxy S22)</span></div>
<div className="row-use"><span className="k">Hit target min.</span><span className="v">44 × 44 px (recommandation Apple/Google)</span></div>
<div className="row-use"><span className="k">Navigation</span><span className="v">TabBar en bas (3-5 sections)</span></div>
<div className="row-use"><span className="k">Action principale</span><span className="v">FAB bottom-right (Material) ou bouton plein largeur (iOS)</span></div>
<div className="row-use"><span className="k">Modales</span><span className="v">BottomSheet (priorité) · ActionSheet · AlertDialog</span></div>
</div>
</section>
{/* COMPOSANTS PHARES */}
<section id="components">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> Composants nommés</h2>
<p className="desc">Vois-les en vrai dans le téléphone à gauche. Le nom est ce que tu emploies dans le code.</p>
<NamedComp name="StatusBar" desc="Barre de statut iOS-like en haut de l'écran (heure, signal, batterie). Purement décorative." location="Tous les écrans" />
<NamedComp name="NavBar" desc="Barre de titre. Variante large pour écran d'accueil, ou compacte avec bouton retour pour écran enfant." location="Tous les écrans" />
<NamedComp name="TabBar" desc="Barre d'onglets en bas, 3-5 sections principales de l'app. C'est ta navigation primaire." location="Toujours visible" />
<NamedComp name="ActionCard" desc="Grande tuile tactile avec icône colorée + titre + valeur. Idéale en grille 2 colonnes pour un dashboard d'accueil." location="Accueil" />
<NamedComp name="ListSection / ListRow" desc="Liste de réglages style iOS. ListRow = une ligne (icône + label + valeur + chevron). Toute ligne fait ≥ 52px." location="Réglages" />
<NamedComp name="PrimaryButton" desc="Gros bouton 52px plein largeur. Variante primary, ghost, danger. Pour l'action principale d'un écran." location="Réglages > formulaires" />
<NamedComp name="SegmentedControl" desc="Sélecteur segmenté pour 2-4 options exclusives (jamais plus, sinon utilise un Select)." location="Gestes (filtre) · BottomSheet (choix thème)" />
<NamedComp name="SearchBar" desc="Champ de recherche avec icône loupe et bouton effacer. Padding tactile généreux." location="Accueil" />
<NamedComp name="FAB" desc="Floating Action Button. Toujours en bas à droite. Une seule action principale par écran. Style Android Material." location="Accueil" />
</section>
{/* FENÊTRES / DIALOGUES */}
<section id="windows">
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Types de fenêtres</h2>
<p className="desc">Sur mobile, on évite les modales centrées. Voici les 4 types à utiliser à la place, chacun avec son cas.</p>
<WindowType
name="BottomSheet"
when="Action contextuelle, formulaire court, choix dans une liste."
why="Accessible au pouce, geste swipe down pour fermer, sensation native."
gesture="SwipeDown ↓ pour fermer · drag du handle en haut"
example="Sur ce smartphone : Réglages > Thème → ouvre une BottomSheet"
/>
<WindowType
name="ActionSheet"
when="Choix parmi 2-6 actions sur un élément (équiv. menu contextuel desktop)."
why="Style iOS natif, l'utilisateur sait que c'est une liste d'options."
gesture="Tap sur une option · Tap hors zone ou bouton Annuler pour fermer"
example="Tape le FAB orange sur l'accueil"
/>
<WindowType
name="AlertDialog"
when="Message critique, demande de confirmation ferme (suppression, déconnexion)."
why="Centré, bloque l'attention. À utiliser avec parcimonie."
gesture="Tap sur Annuler / Confirmer (pas de swipe pour fermer — c'est volontairement bloquant)"
example="Réglages > Supprimer mon compte"
/>
<WindowType
name="Toast"
when="Feedback éphémère après une action (succès, erreur)."
why="Non bloquant, disparaît seul après 2.5s."
gesture="Aucun — disparaît automatiquement"
example="Toute action ci-dessus déclenche un Toast en haut"
/>
</section>
{/* GESTES */}
<section id="gestures">
<h2><Icon name="play" size={22} style={{ color: 'var(--accent)' }} /> Gestes tactiles</h2>
<p className="desc">
Onglet <strong>Gestes</strong> en bas du smartphone zone interactive pour tester
chaque geste. Le nom du geste s'affiche en temps réel.
</p>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginBottom: 16,
}}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} className="card" style={{ padding: 14, margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Icon name={g.icon} size={14} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>{g.name}</span>
</div>
<GestureAnim name={g.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 8 }}>{g.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 2 }}>
Usage : {g.usage}
</div>
</div>
))}
</div>
<div className="card">
<h3 style={{
margin: '0 0 8px', fontFamily: 'var(--font-mono)',
fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>Utilitaire</h3>
<div className="row-use">
<span className="k">useGesture()</span>
<span className="v">Hook React qui transforme un élément en zone tactile. Pose les handlers <code className="mono" style={{ color:'var(--accent)' }}>onTap / onSwipeLeft / onLongPress / onPinch</code> etc.</span>
</div>
<div className="row-use">
<span className="k">GestureZone</span>
<span className="v">Composant prêt-à-l'emploi qui affiche le geste détecté + un journal des 5 derniers. Utilisé dans l'onglet Gestes.</span>
</div>
</div>
</section>
{/* INSTALLATION */}
<section id="install">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Comment utiliser</h2>
<p className="desc">
Ajoute ces lignes en plus de <code className="mono" style={{ color:'var(--accent)' }}>ui-kit.jsx</code> :
</p>
<div className="card" style={{ background:'#15110c', padding: 16 }}>
<pre className="mono" style={{
margin: 0, fontSize: 12, lineHeight: 1.6, color: 'var(--ink-2)',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{`<scr` + `ipt type="text/babel" src="components/ui-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-sheets.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-gestures.jsx"></scr` + `ipt>`}
</pre>
</div>
<p className="desc" style={{ marginTop: 16 }}>
Tu retrouves ensuite dans <code className="mono" style={{ color:'var(--accent)' }}>window</code> tous les composants exposés :
<strong> StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar,
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh, useGesture, GestureZone</strong>.
</p>
</section>
</div>
);
}
function NamedComp({ name, desc, location }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: 72,
}}>
<ComponentPreview name={name} />
</div>
</div>
);
}
/* ============================================================
ComponentPreview — mini-rendu live de chaque composant nommé
============================================================ */
function ComponentPreview({ name }) {
// Réduit la taille via un wrapper compact
const wrap = (children, w = '100%') => (
<div style={{ width: w, maxWidth: 320 }}>{children}</div>
);
if (name === 'StatusBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><StatusBar /></div>);
if (name === 'NavBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><NavBar title="Mon écran" /></div>);
if (name === 'TabBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><TabBar active="a" onSelect={() => {}} items={[
{ id: 'a', icon: 'grid', label: 'accueil' },
{ id: 'b', icon: 'chart', label: 'stats' },
{ id: 'c', icon: 'cog', label: 'réglages' },
]} /></div>);
if (name === 'ActionCard') return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, width: '100%', maxWidth: 280 }}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
);
if (name === 'ListSection / ListRow') return wrap(
<ListSection title="Notifications">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Push" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
);
if (name === 'PrimaryButton') return wrap(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<PrimaryButton icon="download">Enregistrer</PrimaryButton>
<PrimaryButton variant="ghost">Annuler</PrimaryButton>
</div>
);
if (name === 'SegmentedControl') return wrap(
<SegmentedControl value="a" onChange={() => {}} options={[
{ value: 'a', label: 'Sombre', icon: 'moon' },
{ value: 'b', label: 'Clair', icon: 'sun' },
{ value: 'c', label: 'Auto', icon: 'clock' },
]} />
);
if (name === 'SearchBar') return wrap(<SearchBar value="" onChange={() => {}} placeholder="rechercher…" />);
if (name === 'FAB') return (
<div style={{ position: 'relative', width: 220, height: 90, background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, padding: 10, color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>écran…</div>
<div style={{ position: 'absolute', bottom: 10, right: 10 }}>
<button className="touch-press" style={{
width: 48, height: 48, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 14px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25)',
}}><Icon name="plus" size={20} /></button>
</div>
</div>
);
return null;
}
function WindowType({ name, when, why, gesture, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<WindowVisual type={name} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>&lt;{name}/&gt;</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
<div className="row-use"><span className="k">Gestes</span><span className="v">{gesture}</span></div>
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
/* ============================================================
WindowVisual — mini SVG phone + zone modale colorée
============================================================ */
function WindowVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block', margin: '0 auto' }}>
{/* Cadre téléphone */}
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{/* indication contenu */}
<rect x="10" y="18" width="50" height="3" rx="1.5" fill="var(--ink-4)" opacity="0.5"/>
<rect x="10" y="26" width="60" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
<rect x="10" y="32" width="40" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
{inner}
</svg>
);
if (type === 'BottomSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="68" rx="8" fill="var(--accent)" opacity="0.92"/>
<rect x="44" y="114" width="12" height="2.5" rx="1.25" fill="var(--bg-1)"/>
<path d="M 50 145 v 14 M 46 155 l 4 5 l 4 -5" stroke="var(--bg-1)" strokeWidth="1.5" fill="none" opacity="0.7"/>
</g>
);
if (type === 'ActionSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="50" rx="6" fill="var(--accent)" opacity="0.85"/>
<line x1="10" y1="122" x2="90" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="135" x2="90" y2="135" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="148" x2="90" y2="148" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<rect x="6" y="162" width="88" height="14" rx="6" fill="var(--bg-1)" stroke="var(--accent)" strokeWidth="1"/>
<text x="50" y="172" textAnchor="middle" fill="var(--accent)" fontSize="7" fontFamily="Inter" fontWeight="700">Annuler</text>
</g>
);
if (type === 'AlertDialog') return phone(
<g>
<rect x="0" y="0" width="100" height="180" fill="#000" opacity="0.45"/>
<rect x="3" y="2" width="94" height="176" rx="14" fill="none" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="16" y="66" width="68" height="56" rx="8" fill="var(--err)" opacity="0.92"/>
<circle cx="50" cy="82" r="6" fill="var(--bg-1)" opacity="0.95"/>
<line x1="30" y1="96" x2="70" y2="96" stroke="var(--bg-1)" strokeWidth="1.4" opacity="0.85"/>
<line x1="36" y1="102" x2="64" y2="102" stroke="var(--bg-1)" strokeWidth="1" opacity="0.6"/>
<line x1="16" y1="112" x2="84" y2="112" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="50" y1="112" x2="50" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
</g>
);
if (type === 'Toast') return phone(
<g>
<rect x="8" y="18" width="84" height="14" rx="7" fill="var(--ok)" opacity="0.95"/>
<circle cx="16" cy="25" r="2.5" fill="var(--bg-1)"/>
<line x1="22" y1="25" x2="80" y2="25" stroke="var(--bg-1)" strokeWidth="1.5" opacity="0.7"/>
</g>
);
return phone(null);
}
/* ============================================================
GestureAnim — animation SVG par geste
============================================================ */
function GestureAnim({ name }) {
const sty = {
width: '100%', height: 80,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
};
const dot = (cx, cy, r = 6) => <circle cx={cx} cy={cy} r={r} fill="var(--accent)" />;
const trail = (path) => (
<path d={path} stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none" />
);
const arrow = (x, y, dir) => {
const v = { l: 'l 5 -4 m -5 4 l 5 4', r: 'l -5 -4 m 5 4 l -5 4', u: 'l -4 5 m 4 -5 l 4 5', d: 'l -4 -5 m 4 5 l 4 -5' }[dir];
return <path d={`M ${x} ${y} ${v}`} stroke="var(--ink-3)" strokeWidth="1.2" fill="none" strokeLinecap="round"/>;
};
if (name === 'Tap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;22;6" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9" dur="1.6s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'DoubleTap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;14;6;14;6" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9;0;0.9" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'LongPress') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;24" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0" dur="2s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
<text x="50" y="54" textAnchor="middle" fontSize="7" fontFamily="JetBrains Mono" fill="var(--ink-3)">500ms</text>
</svg>
);
if (name === 'SwipeLeft') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 78 30 L 22 30')}
{arrow(22, 30, 'l')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="78;22" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="cy" values="30;30" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeRight') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 22 30 L 78 30')}
{arrow(78, 30, 'r')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="22;78" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeUp') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 52 L 50 10')}
{arrow(50, 10, 'u')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="52;10" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeDown') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 10 L 50 52')}
{arrow(50, 52, 'd')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="10;52" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'Pan') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<path d="M 20 45 Q 35 8 50 30 T 80 18" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none"/>
<circle r="6" fill="var(--accent)">
<animateMotion dur="2s" repeatCount="indefinite" path="M 20 45 Q 35 8 50 30 T 80 18"/>
</circle>
</svg>
);
if (name === 'Pinch') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
</circle>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</circle>
<line y1="30" y2="30" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3">
<animate attributeName="x1" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="x2" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</line>
</svg>
);
return null;
}
/* ============================================================
ROOT
============================================================ */
function App() {
const [theme, setTheme] = useState('dark');
const [device, setDevice] = useState('ios');
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="memory" size={16} />
</div>
<h1>Exemple mobile <small>composants nommés · gestes testables · v1.0</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-tout.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<Icon name="chevL" size={12} /> exemple desktop
</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneApp theme={theme} />
</div>
</div>
<div className="legend">↑ utilise le smartphone comme un vrai téléphone</div>
</div>
<Doc />
</div>
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
@@ -0,0 +1,659 @@
/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});
@@ -0,0 +1,385 @@
/* ============================================================
mobile-forms.jsx
Composants de saisie mobile avec contrôle du clavier virtuel.
Tous nommés et exposés sur window.
============================================================ */
const { useState: uMF, useRef: rMF } = React;
/* ============================================================
FormField — wrapper standard pour un champ
Nom système : FormField
Affiche : label · description · le champ · message d'erreur/hint
============================================================ */
function FormField({ label, hint, error, required, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{label && (
<label style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
</label>
)}
{children}
{(error || hint) && (
<div style={{
fontSize: 12,
color: error ? 'var(--err)' : 'var(--ink-4)',
lineHeight: 1.4,
}}>{error || hint}</div>
)}
</div>
);
}
/* ============================================================
TextInput — champ texte avec contrôle complet du clavier virtuel
Nom système : TextInput
Props clavier virtuel (mobile uniquement) :
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
'given-name'|'family-name'|'current-password'|'new-password'|
'one-time-code'|'off'… (Web Authentication API)
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
spellCheck: bool
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
pattern: regex de validation
============================================================ */
function TextInput({
value, onChange, placeholder, type = 'text', icon, trailing,
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
error,
}) {
const C = multiline ? 'textarea' : 'input';
const inputProps = {
value, onChange: (e) => onChange(e.target.value),
placeholder,
inputMode: keyboard,
autoComplete: autocomplete,
autoCapitalize: autocapitalize,
spellCheck,
enterKeyHint: enterHint,
pattern, maxLength,
rows: multiline ? rows : undefined,
type: !multiline ? type : undefined,
style: {
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
fontSize: 15,
padding: multiline ? '4px 0' : 0,
resize: multiline ? 'vertical' : undefined,
minHeight: multiline ? rows * 22 : undefined,
},
};
return (
<div style={{
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
<C {...inputProps} />
{trailing}
</div>
);
}
/* ============================================================
DateInput — date picker natif mobile
Nom système : DateInput
============================================================ */
function DateInput({ value, onChange, mode = 'date' }) {
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
<input
type={mode}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: 'var(--font-mono)', fontSize: 15,
colorScheme: 'dark',
}}
/>
</div>
);
}
/* ============================================================
Dropdown — select natif stylisé
Nom système : Dropdown
============================================================ */
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
position: 'relative',
}}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
fontFamily: 'var(--font-ui)', fontSize: 15,
appearance: 'none', WebkitAppearance: 'none',
paddingRight: 24,
}}>
<option value="">{placeholder}</option>
{options.map((o) => (
typeof o === 'string'
? <option key={o} value={o}>{o}</option>
: <option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
</div>
);
}
/* ============================================================
CheckboxItem — case à cocher (style iOS)
Nom système : CheckboxItem
Cas : oui/non sur une option, sélection multiple dans une liste
============================================================ */
function CheckboxItem({ checked, onChange, label, description }) {
return (
<label className="touch-press" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '12px 14px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: checked ? 'var(--accent)' : 'var(--bg-1)',
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--bg-1)',
flex: '0 0 auto', marginTop: 1,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
transition: 'all .12s',
}}>
{checked && <Icon name="play" size={11} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
</label>
);
}
/* ============================================================
RadioGroup — groupe d'options exclusives
Nom système : RadioGroup
============================================================ */
function RadioGroup({ value, onChange, options }) {
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
overflow: 'hidden',
}}>
{options.map((o, i) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const d = typeof o === 'object' ? o.description : null;
const active = value === v;
return (
<label key={v} className="touch-press" style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
background: 'var(--bg-1)',
}}>
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
</div>
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
</label>
);
})}
</div>
);
}
/* ============================================================
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
Nom système : MediaInsert
Cas : ajouter une pièce jointe dans un formulaire mobile.
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
et navigator.geolocation pour le GPS.
============================================================ */
function MediaInsert({ onPick }) {
const items = [
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
];
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
}}>
{items.map((it) => (
<label key={it.id} className="touch-press" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, padding: '14px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
color: 'var(--ink-1)',
cursor: 'pointer',
textAlign: 'center',
WebkitTapHighlightColor: 'transparent',
minHeight: 72,
}}>
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
{!it.special && (
<input type="file" accept={it.accept} capture={it.capture}
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
style={{ display: 'none' }} />
)}
{it.special && (
<input type="button" onClick={() => {
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
navigator.geolocation.getCurrentPosition(
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
(err) => onPick && onPick('gps', { error: err.message }),
);
}} style={{ display: 'none' }} />
)}
</label>
))}
</div>
);
}
/* ============================================================
AvatarLogo — gros logo rond pour écran de connexion
Nom système : AvatarLogo
============================================================ */
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
return (
<div style={{
width: size, height: size, borderRadius: size * 0.28,
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: glow
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
margin: '0 auto',
}}>
<Icon name={icon} size={size * 0.45} />
</div>
);
}
/* ============================================================
BiometricButton — bouton biométrie (Face ID / Touch ID)
Nom système : BiometricButton
============================================================ */
function BiometricButton({ kind = 'face', label, onClick }) {
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
return (
<button onClick={onClick} className="touch-press" style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '8px 14px',
background: 'transparent', border: 'none',
color: 'var(--accent)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
{lbl}
</button>
);
}
Object.assign(window, {
FormField, TextInput, DateInput, Dropdown,
CheckboxItem, RadioGroup, MediaInsert,
AvatarLogo, BiometricButton,
});
/* ============================================================
CATALOGUE KEYBOARD — pour la doc
============================================================ */
const KEYBOARD_CATALOG = [
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
];
const AUTOCOMPLETE_CATALOG = [
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
{ name: 'tel', usage: 'Numéro de téléphone' },
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
{ name: 'off', usage: 'Désactive complètement les suggestions' },
];
const ENTER_HINT_CATALOG = [
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
{ name: 'previous', usage: 'Revenir au champ précédent' },
];
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
@@ -0,0 +1,286 @@
/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });
@@ -0,0 +1,407 @@
/* ============================================================
mobile-kit.jsx
Composants mobile-first du design system.
Tous nommés explicitement et exposés sur window.
Tactile-ready : hit targets ≥ 44px, animations fluides,
pas de hover, feedback au touch.
============================================================ */
const { useState: uM, useRef: rM, useEffect: eM } = React;
/* ============================================================
StatusBar — barre de statut iOS-like (en haut de l'écran)
Nom système : StatusBar
Usage : décor en haut de toute page mobile.
============================================================ */
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
return (
<div style={{
height: 44, flex: '0 0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 22px',
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color: 'var(--ink-1)',
}}>
<span>{time}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* signal bars */}
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
{[1, 2, 3, 4].map((b) => (
<span key={b} style={{
width: 3, height: 3 + b * 2, borderRadius: 1,
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
}} />
))}
</span>
<Icon name="network" size={13} />
{/* battery */}
<span style={{
width: 24, height: 11, borderRadius: 3,
border: '1px solid var(--ink-1)',
position: 'relative', marginLeft: 2,
}}>
<span style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${battery / 100})`,
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
borderRadius: 1,
}} />
<span style={{
position: 'absolute', right: -3, top: 3, bottom: 3,
width: 2, background: 'var(--ink-1)',
borderRadius: '0 1px 1px 0',
}} />
</span>
</span>
</div>
);
}
/* ============================================================
NavBar — barre de navigation en haut (titre + actions)
Nom système : NavBar
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
============================================================ */
function NavBar({ title, subtitle, onBack, right, large }) {
return (
<div style={{
flex: '0 0 auto',
padding: large ? '8px 16px 16px' : '8px 12px',
display: 'flex', flexDirection: 'column', gap: 4,
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderBottom: '1px solid var(--border-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
{onBack && (
<button onClick={onBack} style={{
width: 36, height: 36, borderRadius: 8,
background: 'transparent', border: 'none',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>
<Icon name="chevL" size={20} />
</button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!large && (
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
{title}
</div>
)}
</div>
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
</div>
{large && (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
</div>
)}
</div>
);
}
/* ============================================================
TabBar — barre d'onglets en bas (iOS/Android)
Nom système : TabBar
Usage : navigation principale entre 3-5 sections de l'app.
============================================================ */
function TabBar({ items, active, onSelect }) {
return (
<div style={{
flex: '0 0 auto',
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
padding: '6px 8px 18px',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderTop: '1px solid var(--border-2)',
}}>
{items.map((it) => {
const isActive = active === it.id;
return (
<button key={it.id} onClick={() => onSelect(it.id)} style={{
flex: 1, minHeight: 50,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 3, padding: 0,
background: 'transparent', border: 'none',
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
cursor: 'pointer',
transition: 'color .2s, transform .12s',
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
}}>
<Icon name={it.icon} size={22} />
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em', textTransform: 'uppercase',
fontWeight: isActive ? 700 : 500,
}}>{it.label}</span>
</button>
);
})}
</div>
);
}
/* ============================================================
ListRow — ligne d'une liste réglages (style iOS)
Nom système : ListRow
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
============================================================ */
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
const isInteractive = !!onClick;
const Tag = isInteractive ? 'button' : 'div';
return (
<Tag onClick={onClick} style={{
width: '100%',
minHeight: 52,
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: 'transparent',
border: 'none', borderBottom: '1px solid var(--border-1)',
color: danger ? 'var(--err)' : 'var(--ink-1)',
cursor: isInteractive ? 'pointer' : 'default',
textAlign: 'left',
transition: 'background .12s',
WebkitTapHighlightColor: 'transparent',
}}
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
{icon && (
<span style={{
width: 30, height: 30, borderRadius: 7,
background: iconColor || 'var(--bg-4)',
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
}}>
<Icon name={icon} size={15} />
</span>
)}
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
{right}
</Tag>
);
}
/* ============================================================
ListSection — groupe de ListRow avec titre
Nom système : ListSection
============================================================ */
function ListSection({ title, hint, children }) {
return (
<div style={{ marginBottom: 18 }}>
{title && (
<div style={{
padding: '0 16px 6px',
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>{title}</div>
)}
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
margin: '0 12px',
overflow: 'hidden',
boxShadow: 'var(--shadow-1)',
}}>{children}</div>
{hint && (
<div style={{
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
lineHeight: 1.4,
}}>{hint}</div>
)}
</div>
);
}
/* ============================================================
ActionCard — grosse carte d'action tactile
Nom système : ActionCard
Usage : actions principales sur écran d'accueil.
============================================================ */
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
return (
<button onClick={onClick} className="touch-press" style={{
flex: 1, minWidth: 0, minHeight: 110,
padding: 14,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--ink-1)',
textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 6,
cursor: 'pointer',
boxShadow: 'var(--tile-3d)',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 38, height: 38, borderRadius: 9,
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
}}>
<Icon name={icon} size={18} />
</span>
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
{value && (
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
)}
{badge && (
<span style={{
position: 'absolute', top: 10, right: 10,
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px',
background: 'var(--err)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge}</span>
)}
</button>
);
}
/* ============================================================
PrimaryButton — gros bouton plein largeur tactile
Nom système : PrimaryButton
Usage : action principale d'un écran (sauvegarder, valider).
============================================================ */
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
const sizes = {
md: { h: 44, fontSize: 14 },
lg: { h: 52, fontSize: 16 },
}[size];
const styles = {
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="touch-press" style={{
width: '100%',
height: sizes.h,
background: styles.bg,
color: styles.fg,
border: `1px solid ${styles.bd}`,
borderRadius: 12,
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
WebkitTapHighlightColor: 'transparent',
}}>
{icon && <Icon name={icon} size={18} />}
{children}
</button>
);
}
/* ============================================================
SegmentedControl — sélecteur segmenté iOS-style
Nom système : SegmentedControl
Usage : 2-4 options exclusives, jamais plus.
============================================================ */
function SegmentedControl({ value, onChange, options }) {
return (
<div style={{
display: 'flex',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 9,
padding: 3,
gap: 2,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'string' ? null : o.icon;
const active = value === v;
return (
<button key={v} onClick={() => onChange(v)} style={{
flex: 1, minHeight: 36,
padding: '6px 10px',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: 'none', borderRadius: 6,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
transition: 'background .18s, color .18s, transform .12s',
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={13} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
SearchBar — champ de recherche mobile
Nom système : SearchBar
============================================================ */
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
}} />
{value && (
<button onClick={() => onChange('')} style={{
width: 22, height: 22, borderRadius: '50%',
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}><Icon name="close" size={10} /></button>
)}
</div>
);
}
Object.assign(window, {
StatusBar, NavBar, TabBar, ListRow, ListSection,
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
});
/* Effets tactiles : pression au touch (pas de hover) */
(function injectMobileFX() {
if (document.getElementById('mobile-fx')) return;
const s = document.createElement('style');
s.id = 'mobile-fx';
s.textContent = `
.touch-press {
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
}
.touch-press:active {
transform: scale(0.97);
filter: brightness(0.92);
}
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,390 @@
/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet — feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet — menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog — alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast — notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB — Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh — wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});
@@ -0,0 +1,137 @@
/* ============================================================
mobile-swipeable.jsx
SwipeableRow — ligne qui révèle des actions au swipe.
============================================================ */
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
/* ============================================================
SwipeableRow
Nom système : SwipeableRow
Cas d'usage : ligne d'une liste avec actions cachées
(archive, suppression, marquer comme lu…).
Style iOS Mail / Things / Apple Reminders.
Gestes : SwipeLeft (révèle leftActions à droite),
SwipeRight (révèle rightActions à gauche),
Tap sur la ligne (action principale),
Tap sur une action (déclenche l'action puis ferme).
============================================================ */
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
// leftActions s'affichent quand on swipe vers la GAUCHE
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
const [tx, setTx] = uSw(0);
const [dragging, setDragging] = uSw(false);
const startX = rSw(0);
const initialTx = rSw(0);
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
const snap = (x) => {
if (x < -leftW * 0.5) setTx(-leftW);
else if (x > rightW * 0.5) setTx(rightW);
else setTx(0);
};
const onStart = (e) => {
setDragging(true);
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
initialTx.current = tx;
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
let d = initialTx.current + (x - startX.current);
// limite + élasticité hors zone
if (d > rightW) d = rightW + (d - rightW) * 0.3;
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
setTx(d);
};
const onEnd = () => {
setDragging(false);
snap(tx);
};
const fire = (action) => {
setTx(0);
setTimeout(() => action.onClick && action.onClick(), 200);
};
const handleTap = (e) => {
if (tx !== 0) { setTx(0); return; }
if (Math.abs(tx) < 4 && onTap) onTap(e);
};
return (
<div style={{
position: 'relative',
overflow: 'hidden',
background: 'var(--bg-3)',
WebkitUserSelect: 'none', userSelect: 'none',
}}>
{/* Actions à GAUCHE (révélées par swipe droit) */}
{rightActions.length > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: rightW,
}}>
{rightActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--info)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Actions à DROITE (révélées par swipe gauche) */}
{leftActions.length > 0 && (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: leftW,
}}>
{leftActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--err)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Ligne déplaçable */}
<div
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
onClick={handleTap}
style={{
position: 'relative',
background: 'var(--bg-3)',
transform: `translateX(${tx}px)`,
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
touchAction: 'pan-y',
}}>
{children}
</div>
</div>
);
}
Object.assign(window, { SwipeableRow });
@@ -0,0 +1,656 @@
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,204 @@
/* ============================================================
ui-tokens.css
Design tokens Gruvbox Seventies — dark (par défaut) + light.
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
============================================================ */
:root,
[data-theme="dark"] {
/* Couches de fond — sombre délavé, brun-gris chaud */
--bg-0: #221c17; /* niveau le plus profond (rare) */
--bg-1: #2a231d; /* fond app */
--bg-2: #322a23; /* panneaux */
--bg-3: #3c332a; /* cartes */
--bg-4: #4a4035; /* hover */
--bg-5: #5a4f43; /* press / actif */
/* Surfaces translucides */
--surf-glass: rgba(50, 42, 35, 0.72);
--surf-glass-strong: rgba(50, 42, 35, 0.92);
--surf-glass-soft: rgba(50, 42, 35, 0.42);
/* Bordures */
--border-1: rgba(168, 153, 132, 0.18);
--border-2: rgba(168, 153, 132, 0.32);
--border-3: rgba(168, 153, 132, 0.55);
/* Texte */
--ink-1: #f2e5c7; /* cream principal */
--ink-2: #d5c4a1; /* secondaire */
--ink-3: #a89984; /* labels / hints */
--ink-4: #7c6f64; /* désactivé */
/* Accent orange seventies */
--accent: #fe8019;
--accent-soft: #d65d0e;
--accent-glow: rgba(254, 128, 25, 0.35);
--accent-tint: rgba(254, 128, 25, 0.12);
/* Statuts */
--ok: #4dbb26;
--ok-glow: rgba(77, 187, 38, 0.45);
--warn: #fabd2f;
--warn-glow: rgba(250, 189, 47, 0.45);
--err: #fb4934;
--err-glow: rgba(251, 73, 52, 0.4);
--info: #83a598;
--info-glow: rgba(131, 165, 152, 0.4);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #3db0d1;
--blue-glow: rgba(61, 176, 209, 0.45);
--purple: #c882c8;
--purple-glow: rgba(200, 130, 200, 0.45);
/* Ombres */
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
--tile-3d:
inset 0 1px 0 rgba(255, 230, 180, 0.12),
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
0 1px 0 rgba(0, 0, 0, 0.35),
0 2px 4px rgba(0, 0, 0, 0.4),
0 8px 18px rgba(0, 0, 0, 0.5);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 230, 180, 0.18),
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
0 1px 0 rgba(0, 0, 0, 0.4),
0 4px 8px rgba(0, 0, 0, 0.5),
0 14px 28px rgba(0, 0, 0, 0.55);
/* Polices */
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
}
[data-theme="light"] {
/* Gris clair usé, légèrement chaud (pas blanc pur) */
--bg-0: #b8b2a3;
--bg-1: #d5d0c5;
--bg-2: #dcd7cc;
--bg-3: #e3ded3;
--bg-4: #ccc6b8;
--bg-5: #bdb6a7;
--surf-glass: rgba(220, 215, 204, 0.72);
--surf-glass-strong: rgba(220, 215, 204, 0.94);
--surf-glass-soft: rgba(220, 215, 204, 0.42);
--border-1: rgba(60, 56, 54, 0.15);
--border-2: rgba(60, 56, 54, 0.28);
--border-3: rgba(60, 56, 54, 0.5);
--ink-1: #28241f;
--ink-2: #3c3836;
--ink-3: #5a544c;
--ink-4: #8a8278;
--accent: #af3a03;
--accent-soft: #d65d0e;
--accent-glow: rgba(175, 58, 3, 0.28);
--accent-tint: rgba(175, 58, 3, 0.08);
--ok: #3c911c;
--ok-glow: rgba(60, 145, 28, 0.32);
--warn: #b57614;
--warn-glow: rgba(181, 118, 20, 0.35);
--err: #9d0006;
--err-glow: rgba(157, 0, 6, 0.3);
--info: #427b58;
--info-glow: rgba(66, 123, 88, 0.3);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #2d82a3;
--blue-glow: rgba(45, 130, 163, 0.32);
--purple: #8c468c;
--purple-glow: rgba(140, 70, 140, 0.32);
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
/* Relief light : highlight haut blanc cassé + ombre marquée */
--tile-3d:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
0 1px 0 rgba(60, 50, 40, 0.1),
0 2px 4px rgba(60, 50, 40, 0.12),
0 8px 18px rgba(60, 50, 40, 0.18);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
0 1px 0 rgba(60, 50, 40, 0.15),
0 4px 8px rgba(60, 50, 40, 0.18),
0 14px 28px rgba(60, 50, 40, 0.22);
}
/* ============================================================
Reset minimal + base typo
============================================================ */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-ui);
font-size: 14px;
color: var(--ink-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--font-mono); }
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
.label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
}
/* ============================================================
Surfaces — relief 3D marqué, AUCUN effet hover
============================================================ */
.glass {
background: var(--surf-glass);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid var(--border-2);
box-shadow: var(--tile-3d);
}
.glass-strong {
background: var(--surf-glass-strong);
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border: 1px solid var(--border-3);
box-shadow: var(--tile-3d-strong);
}
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
.interactive {
cursor: pointer;
transition: transform .04s ease-out, box-shadow .04s, background .04s;
transform: translateY(0);
}
.interactive:active {
transform: translateY(1px);
box-shadow: var(--shadow-press) !important;
filter: brightness(0.92);
}
/* Scrollbar custom */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
@@ -0,0 +1,378 @@
/* ============================================================
tokens.gnome.css — Tokens pour applications GNOME (GTK 4 / libadwaita)
Gruvbox seventies · v1.0
============================================================
Usage dans une app GTK 4 / libadwaita :
#include <gtk/gtk.h>
GtkCssProvider *provider = gtk_css_provider_new();
gtk_css_provider_load_from_path(provider, "tokens.gnome.css");
gtk_style_context_add_provider_for_display(
gdk_display_get_default(), GTK_STYLE_PROVIDER(provider),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
Python (PyGObject) :
css_provider = Gtk.CssProvider()
css_provider.load_from_path("tokens.gnome.css")
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
GJS :
const provider = new Gtk.CssProvider();
provider.load_from_path('tokens.gnome.css');
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
============================================================ */
/* ============================================================
THÈME SOMBRE (défaut)
============================================================ */
/* Couches de fond (du plus profond au plus haut) */
@define-color bg_0 #221c17;
@define-color bg_1 #2a231d;
@define-color bg_2 #322a23;
@define-color bg_3 #3c332a;
@define-color bg_4 #4a4035;
@define-color bg_5 #5a4f43;
/* Encres / texte */
@define-color ink_1 #f2e5c7;
@define-color ink_2 #d5c4a1;
@define-color ink_3 #a89984;
@define-color ink_4 #7c6f64;
/* Accent orange seventies */
@define-color accent_color #fe8019;
@define-color accent_soft #d65d0e;
@define-color accent_fg_color #221c17;
/* Statuts */
@define-color success_color #4dbb26;
@define-color warning_color #fabd2f;
@define-color error_color #fb4934;
@define-color info_color #83a598;
@define-color blue_color #3db0d1;
@define-color purple_color #c882c8;
/* Bordures */
@define-color border_1 alpha(#a89984, 0.18);
@define-color border_2 alpha(#a89984, 0.32);
@define-color border_3 alpha(#a89984, 0.55);
/* Couleurs sémantiques GNOME / libadwaita (overrides) */
@define-color window_bg_color @bg_1;
@define-color window_fg_color @ink_1;
@define-color view_bg_color @bg_2;
@define-color view_fg_color @ink_1;
@define-color headerbar_bg_color @bg_2;
@define-color headerbar_fg_color @ink_1;
@define-color headerbar_border_color @border_2;
@define-color headerbar_backdrop_color @bg_1;
@define-color sidebar_bg_color @bg_2;
@define-color sidebar_fg_color @ink_1;
@define-color sidebar_backdrop_color @bg_1;
@define-color popover_bg_color @bg_3;
@define-color popover_fg_color @ink_1;
@define-color card_bg_color @bg_3;
@define-color card_fg_color @ink_1;
@define-color shade_color alpha(black, 0.4);
@define-color scrollbar_outline_color alpha(@ink_3, 0.3);
/* ============================================================
COMPOSANTS GTK — habillage Gruvbox seventies
============================================================ */
/* Fond global */
window {
background-color: @window_bg_color;
color: @window_fg_color;
font-family: 'Inter', 'Cantarell', sans-serif;
font-size: 14px;
}
/* HeaderBar (barre de titre) */
headerbar {
background: @bg_2;
color: @ink_1;
border-bottom: 1px solid @border_2;
box-shadow: inset 0 1px 0 alpha(white, 0.04);
min-height: 48px;
}
headerbar .title {
font-weight: 700;
font-size: 15px;
}
headerbar .subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: @ink_3;
}
/* Boutons — relief 3D et accent */
button {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 6px 12px;
font-weight: 500;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.3),
0 1px 2px alpha(black, 0.4);
transition: all 60ms ease;
}
button:active {
background: @bg_4;
box-shadow: inset 0 2px 4px alpha(black, 0.5);
transform: translateY(1px);
}
button:disabled {
color: @ink_4;
opacity: 0.6;
}
/* Bouton "suggested-action" = primary (accent orange) */
button.suggested-action {
background: @accent_color;
color: @accent_fg_color;
border-color: @accent_soft;
box-shadow:
inset 0 1px 0 alpha(white, 0.2),
0 2px 6px alpha(@accent_color, 0.35);
}
button.suggested-action:active {
background: @accent_soft;
}
/* Bouton "destructive-action" = danger */
button.destructive-action {
background: @bg_3;
color: @error_color;
border-color: @error_color;
}
/* Bouton plat (toolbar) */
button.flat {
background: transparent;
border-color: transparent;
box-shadow: none;
}
button.flat:hover {
background: @bg_3;
}
/* Champs de saisie */
entry,
text {
background: @bg_1;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 8px 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.3);
}
entry:focus,
text:focus {
border-color: @accent_color;
outline: 2px solid alpha(@accent_color, 0.18);
outline-offset: -1px;
}
/* Listes / treeview */
list,
treeview {
background: @bg_2;
color: @ink_1;
}
list > row {
padding: 8px 12px;
border-bottom: 1px solid @border_1;
}
list > row:selected,
treeview:selected {
background: alpha(@accent_color, 0.12);
color: @ink_1;
border-left: 3px solid @accent_color;
}
/* Switch (toggle) */
switch {
background: @bg_4;
border: 1px solid @border_2;
border-radius: 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 22px;
min-width: 42px;
}
switch:checked {
background: @accent_color;
border-color: @accent_soft;
box-shadow: 0 0 10px alpha(@accent_color, 0.35);
}
switch slider {
background: @ink_2;
border-radius: 50%;
min-width: 18px;
min-height: 18px;
}
switch:checked slider {
background: @accent_fg_color;
}
/* Scale (slider) */
scale trough {
background: @bg_1;
border-radius: 4px;
min-height: 6px;
}
scale highlight {
background: @accent_color;
border-radius: 4px;
}
scale slider {
background: @ink_1;
border: 2px solid @accent_color;
border-radius: 50%;
min-width: 16px;
min-height: 16px;
box-shadow: 0 1px 4px alpha(black, 0.5);
}
/* Progress bar (jauge horizontale type batterie) */
progressbar trough {
background: @bg_1;
border: 1px solid @border_2;
border-radius: 4px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 12px;
}
progressbar progress {
background: @success_color;
border-radius: 3px;
box-shadow: 0 0 8px alpha(@success_color, 0.45);
}
/* Niveaux de progression sémantiques (à appliquer via add_css_class) */
progressbar.warning progress { background: @warning_color; }
progressbar.error progress { background: @error_color; }
progressbar.info progress { background: @info_color; }
/* Notebook / onglets */
notebook header {
background: @bg_2;
border-bottom: 1px solid @border_2;
}
notebook tab {
padding: 8px 16px;
color: @ink_3;
border-top: 2px solid transparent;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
notebook tab:checked {
color: @ink_1;
border-top-color: @accent_color;
background: @bg_3;
}
/* Popover */
popover contents {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 32px alpha(black, 0.55);
}
/* Menubutton / dropdown */
menubutton button {
padding: 4px 8px;
}
/* Status pill (badge) — à appliquer sur GtkLabel.status */
label.status {
padding: 2px 8px;
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
label.status.ok { background: alpha(@success_color, 0.18); color: @success_color; }
label.status.warn { background: alpha(@warning_color, 0.18); color: @warning_color; }
label.status.error { background: alpha(@error_color, 0.18); color: @error_color; }
label.status.info { background: alpha(@info_color, 0.18); color: @info_color; }
/* Texte monospace / terminal */
label.mono,
.mono {
font-family: 'JetBrains Mono', monospace;
}
label.terminal,
.terminal {
font-family: 'Share Tech Mono', 'VT323', monospace;
letter-spacing: 0.02em;
}
/* Carte tuile (à appliquer via add_css_class("tile")) */
.tile,
.card {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 12px;
padding: 14px;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.4),
0 2px 4px alpha(black, 0.4),
0 6px 14px alpha(black, 0.45);
}
/* Scrollbar */
scrollbar slider {
background: @border_2;
border-radius: 4px;
min-width: 6px;
min-height: 6px;
}
scrollbar slider:hover {
background: @accent_soft;
}
/* ============================================================
THÈME CLAIR — à charger en alternative
Pour appliquer le thème clair, charger ce fichier puis
`tokens.gnome.light.css` (à dupliquer en remplaçant
les @define-color des fonds et encres) OU appliquer
un settings GTK light :
g_object_set(gtk_settings, "gtk-application-prefer-dark-theme",
FALSE, NULL);
Et fournir un fichier dérivé avec les valeurs ci-dessous :
============================================================ */
/*
bg_0: #b8b2a3
bg_1: #d5d0c5
bg_2: #dcd7cc
bg_3: #e3ded3
bg_4: #ccc6b8
bg_5: #bdb6a7
ink_1: #28241f
ink_2: #3c3836
ink_3: #5a544c
ink_4: #8a8278
accent_color: #af3a03
success_color: #3c911c
warning_color: #b57614
error_color: #9d0006
info_color: #427b58
blue_color: #2d82a3
purple_color: #8c468c
*/
@@ -0,0 +1,136 @@
{
"$schema": "design-tokens-v1",
"name": "mon design system — gruvbox seventies",
"version": "1.0.0",
"description": "Design system Gruvbox seventies. Orange brûlé, fond brun délavé en sombre / gris clair usé en clair. Deux thèmes dark/light parfaitement à parité.",
"themes": {
"dark": {
"bg": {
"0": { "value": "#221c17", "description": "Niveau le plus profond, rare" },
"1": { "value": "#2a231d", "description": "Fond application principal" },
"2": { "value": "#322a23", "description": "Panneaux (sidebar, headerbar)" },
"3": { "value": "#3c332a", "description": "Cartes, tuiles" },
"4": { "value": "#4a4035", "description": "Hover, état actif" },
"5": { "value": "#5a4f43", "description": "Press, sélection forte" }
},
"ink": {
"1": { "value": "#f2e5c7", "description": "Texte principal (cream)" },
"2": { "value": "#d5c4a1", "description": "Texte secondaire" },
"3": { "value": "#a89984", "description": "Labels, hints" },
"4": { "value": "#7c6f64", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#fe8019", "description": "Orange Gruvbox seventies" },
"soft": { "value": "#d65d0e", "description": "Orange foncé (hover, bordures)" },
"glow": { "value": "rgba(254, 128, 25, 0.35)" },
"tint": { "value": "rgba(254, 128, 25, 0.12)" }
},
"status": {
"ok": { "value": "#4dbb26" },
"warn": { "value": "#fabd2f" },
"err": { "value": "#fb4934" },
"info": { "value": "#83a598" }
},
"extra": {
"blue": { "value": "#3db0d1" },
"purple": { "value": "#c882c8" }
},
"border": {
"1": { "value": "rgba(168, 153, 132, 0.18)" },
"2": { "value": "rgba(168, 153, 132, 0.32)" },
"3": { "value": "rgba(168, 153, 132, 0.55)" }
}
},
"light": {
"bg": {
"0": { "value": "#b8b2a3", "description": "Niveau le plus profond" },
"1": { "value": "#d5d0c5", "description": "Fond application principal" },
"2": { "value": "#dcd7cc", "description": "Panneaux" },
"3": { "value": "#e3ded3", "description": "Cartes, tuiles" },
"4": { "value": "#ccc6b8", "description": "Hover" },
"5": { "value": "#bdb6a7", "description": "Press" }
},
"ink": {
"1": { "value": "#28241f", "description": "Texte principal" },
"2": { "value": "#3c3836", "description": "Texte secondaire" },
"3": { "value": "#5a544c", "description": "Labels, hints" },
"4": { "value": "#8a8278", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#af3a03", "description": "Orange brûlé (variante contrastée)" },
"soft": { "value": "#d65d0e" },
"glow": { "value": "rgba(175, 58, 3, 0.28)" },
"tint": { "value": "rgba(175, 58, 3, 0.08)" }
},
"status": {
"ok": { "value": "#3c911c" },
"warn": { "value": "#b57614" },
"err": { "value": "#9d0006" },
"info": { "value": "#427b58" }
},
"extra": {
"blue": { "value": "#2d82a3" },
"purple": { "value": "#8c468c" }
},
"border": {
"1": { "value": "rgba(60, 56, 54, 0.15)" },
"2": { "value": "rgba(60, 56, 54, 0.28)" },
"3": { "value": "rgba(60, 56, 54, 0.5)" }
}
}
},
"typography": {
"fonts": {
"ui": { "family": "Inter", "weights": [400, 500, 600, 700], "fallback": ["Cantarell", "system-ui", "sans-serif"] },
"mono": { "family": "JetBrains Mono", "weights": [400, 500, 600, 700], "fallback": ["ui-monospace", "monospace"] },
"terminal": { "family": "Share Tech Mono", "weights": [400], "fallback": ["VT323", "Courier New", "monospace"] }
},
"scale": {
"label": { "size": 11, "weight": 500, "transform": "uppercase", "tracking": "0.08em", "family": "mono" },
"caption": { "size": 12, "weight": 400, "family": "ui" },
"body": { "size": 14, "weight": 400, "family": "ui" },
"body-emph": { "size": 14, "weight": 600, "family": "ui" },
"title": { "size": 18, "weight": 700, "family": "ui" },
"h2": { "size": 22, "weight": 700, "family": "ui" },
"h1": { "size": 28, "weight": 700, "family": "ui" },
"display": { "size": 44, "weight": 700, "family": "ui" },
"kpi": { "size": 28, "weight": 700, "family": "mono" }
}
},
"radius": {
"xs": 3,
"sm": 4,
"md": 6,
"lg": 8,
"xl": 10,
"2xl": 12,
"pill": 999
},
"spacing": {
"1": 4,
"2": 6,
"3": 8,
"4": 10,
"5": 12,
"6": 14,
"7": 16,
"8": 18,
"9": 20,
"10": 24,
"12": 32,
"14": 40,
"16": 56
},
"shadows": {
"1": "0 1px 2px rgba(0,0,0,0.4)",
"2": "0 4px 12px rgba(0,0,0,0.45)",
"3": "0 12px 32px rgba(0,0,0,0.55)",
"press": "inset 0 2px 4px rgba(0,0,0,0.5)",
"tile3d": "inset 0 1px 0 rgba(255,230,180,0.12), inset 0 -1px 0 rgba(0,0,0,0.45), 0 1px 0 rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 8px 18px rgba(0,0,0,0.5)"
},
"motion": {
"fast": "60ms ease",
"normal": "180ms cubic-bezier(.3,.7,.3,1.2)",
"slow": "400ms cubic-bezier(.3,.6,.3,1)"
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ services:
UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
CORS_ORIGINS: http://localhost:3001,http://localhost:3000
CORS_ORIGINS: http://localhost:3001,http://localhost:3000,http://10.0.1.45:3001
MCP_API_KEY: ${MCP_API_KEY}
volumes:
- ./backend/app:/app/app
- ./data:/data
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
# MCP Server — Design Spec
## Objectif
Intégrer un serveur MCP (Model Context Protocol) directement dans le backend FastAPI de HomeHub, exposant 16 outils aux agents IA (Hermes, Claude Code, Codex) via le transport Streamable HTTP (standard 2025).
---
## Architecture
```
Hermes (10.0.0.80)
│ POST/GET http://10.0.0.50:3001/mcp (via nginx)
nginx (frontend container)
│ proxy_pass → http://backend:8000/mcp
│ proxy_buffering off, proxy_read_timeout 86400s
FastAPI backend
├── MCPAuthMiddleware → vérifie Authorization: Bearer <MCP_API_KEY>
└── /mcp ← MCP Server (Streamable HTTP, SDK officiel mcp>=1.9)
├── 5 outils Todos → SQLAlchemy async (pool partagé)
├── 5 outils Notes → SQLAlchemy async (pool partagé)
└── 6 outils Shopping → SQLAlchemy async (pool partagé)
```
Le serveur MCP partage le pool de connexions PostgreSQL du backend. Il n'y a pas de service Docker supplémentaire.
---
## Authentification
- Header requis : `Authorization: Bearer <MCP_API_KEY>`
- `MCP_API_KEY` définie dans `.env` / `docker-compose.yml` (variable d'environnement)
- Un middleware FastAPI intercepte toutes les requêtes `/mcp*` et renvoie HTTP 401 si le token est absent ou incorrect
- Les autres routes du backend (`/api/*`) ne sont pas affectées
---
## Outils exposés
### Todos (5 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `get_todos` | `status: str = "pending"`, `domain: str?`, `priority: str?` | Liste filtrée des tâches |
| `create_todo` | `title: str`, `due_date: str?` (ISO 8601), `priority: str?`, `domain: str?` | Crée une tâche |
| `update_todo` | `id: str`, `title: str?`, `status: str?`, `priority: str?` | Modifie une tâche |
| `postpone_todo` | `id: str`, `days: int` | Reporte la date d'échéance |
| `delete_todo` | `id: str` | Supprime une tâche |
### Notes (5 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `search_notes` | `query: str?`, `category: str?`, `tag: str?` | Recherche FTS PostgreSQL (français) |
| `get_note` | `id: str` | Retourne une note complète avec pièces jointes |
| `create_note` | `title: str`, `content: str`, `category: str?`, `tags: list[str]?` | Crée une note |
| `update_note` | `id: str`, `title: str?`, `content: str?`, `tags: list[str]?` | Modifie une note |
| `delete_note` | `id: str` | Supprime une note |
### Shopping (6 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `get_shopping_lists` | — | Toutes les listes avec compteurs d'articles |
| `get_active_shopping_list` | — | Première liste en statut `draft` avec ses articles |
| `search_products` | `q: str` | Recherche dans le catalogue produits |
| `create_shopping_list` | `name: str?` | Crée une liste (nom auto = semaine ISO si absent) |
| `add_shopping_item` | `list_id: str`, `name: str`, `quantity: float? = 1`, `unit: str?` | Ajoute un article à une liste |
| `check_shopping_item` | `list_id: str`, `item_id: str` | Coche un article (marque comme acheté) |
---
## Fichiers à créer/modifier
| Fichier | Action | Rôle |
|---------|--------|------|
| `backend/app/api/mcp_server.py` | Créer | Définition des 16 outils MCP, logique métier |
| `backend/app/core/mcp_auth.py` | Créer | Middleware Bearer token pour `/mcp*` |
| `backend/app/main.py` | Modifier | Mount du serveur MCP + enregistrement middleware |
| `backend/requirements.txt` | Modifier | Ajout `mcp>=1.9` |
| `backend/app/core/config.py` | Modifier | Ajout champ `mcp_api_key: str` dans Settings |
| `docker-compose.yml` | Modifier | Ajout variable `MCP_API_KEY` |
| `frontend/nginx.conf` | Modifier | Location `/mcp` dédiée (no-buffer, long timeout) |
---
## Clé API générée
```
MCP_API_KEY=4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI
```
À ajouter dans `docker-compose.yml` sous `backend.environment` et dans tout fichier `.env` local.
---
## Configuration Hermes (post-déploiement)
```yaml
servers:
homehub:
url: http://10.0.0.50:3001/mcp
headers:
Authorization: "Bearer 4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI"
```
Après configuration : `/reload-mcp` dans l'interface Hermes pour prendre en compte les nouveaux outils.
---
## Retours des outils
Chaque outil retourne du JSON structuré cohérent avec les schémas Pydantic existants du backend. En cas d'erreur (ID introuvable, paramètre invalide), l'outil retourne un objet `{"error": "<message>"}` sans lever d'exception MCP (pour ne pas interrompre le contexte de l'agent).
---
## Dépendances
- `mcp>=1.9` — SDK officiel Anthropic (Streamable HTTP transport)
- Aucune autre dépendance externe — SQLAlchemy, FastAPI, Pydantic déjà présents
---
## Hors périmètre
- Authentification OAuth2 / multi-utilisateur (réseau local, clé statique suffit)
- Outils de gestion des pièces jointes (upload binaire incompatible MCP)
- Analyse frigo Vision LLM (Phase 9 séparée)
- Websocket / stdio transport (Hermes utilise HTTP)
+11
View File
@@ -25,6 +25,17 @@ server {
proxy_read_timeout 86400s;
}
location /mcp {
proxy_pass http://backend:8000/mcp;
proxy_set_header Host localhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.5.9",
"version": "0.5.11",
"type": "module",
"scripts": {
"dev": "vite",
+40 -20
View File
@@ -18,31 +18,51 @@ export async function fetchStats(): Promise<AppStats> {
return res.json() as Promise<AppStats>
}
export interface BackupFile {
filename: string
size: number
created_at: string
}
export async function fetchBackups(): Promise<BackupFile[]> {
const res = await fetch('/api/admin/backups')
if (!res.ok) throw new Error('Erreur chargement sauvegardes')
return res.json() as Promise<BackupFile[]>
}
export async function createBackup(): Promise<BackupFile> {
export async function downloadBackup(): Promise<string> {
const res = await fetch('/api/admin/backup', { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { detail?: string }
throw new Error(err.detail ?? 'Erreur lors du backup')
}
return res.json() as Promise<BackupFile>
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') ?? ''
const match = /filename="?([^";]+)"?/.exec(disposition)
const filename = match?.[1] ?? `homehub_${new Date().toISOString().slice(0, 10)}.tar.gz`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
return filename
}
export async function restoreBackup(filename: string): Promise<void> {
const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { detail?: string }
throw new Error(err.detail ?? 'Erreur lors de la restauration')
}
export async function uploadAndRestore(file: File, onProgress?: (pct: number) => void): Promise<void> {
await new Promise<void>((resolve, reject) => {
const form = new FormData()
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/admin/restore')
if (onProgress) {
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) onProgress(Math.round((ev.loaded / ev.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else {
try {
const err = JSON.parse(xhr.responseText) as { detail?: string }
reject(new Error(err.detail ?? `Erreur ${xhr.status}`))
} catch {
reject(new Error(`Erreur ${xhr.status}`))
}
}
}
xhr.onerror = () => reject(new Error('Erreur réseau'))
xhr.send(form)
})
}
+6 -1
View File
@@ -15,9 +15,14 @@ input, textarea, select {
user-select: text;
}
html, body {
margin: 0;
overflow-x: hidden;
max-width: 100vw;
}
body {
font-family: var(--font-ui);
background-color: var(--bg-1);
color: var(--ink-1);
margin: 0;
}
+69 -60
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useRef, useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
import { fetchBackups, createBackup, restoreBackup, fetchStats, type BackupFile, type AppStats } from '../api/admin'
import { downloadBackup, uploadAndRestore, fetchStats, type AppStats } from '../api/admin'
const sectionStyle: React.CSSProperties = {
background: 'var(--bg-3)',
@@ -39,30 +39,28 @@ function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })
}
export default function ConfigPage() {
const navigate = useNavigate()
const { theme, setTheme, fontScale, setFontScale } = useTheme()
const [stats, setStats] = useState<AppStats | null>(null)
const [backups, setBackups] = useState<BackupFile[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [restoring, setRestoring] = useState(false)
const [restoreProgress, setRestoreProgress] = useState(0)
const [backupError, setBackupError] = useState<string | null>(null)
const [backupInfo, setBackupInfo] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetchStats().then(setStats).catch(() => null)
fetchBackups().then(setBackups).catch(() => setBackups([]))
}, [])
async function handleCreateBackup() {
setBackupLoading(true)
setBackupError(null)
setBackupInfo(null)
try {
const b = await createBackup()
setBackups(prev => [b, ...prev])
const filename = await downloadBackup()
setBackupInfo(`Téléchargé : ${filename}`)
} catch (e) {
setBackupError((e as Error).message)
} finally {
@@ -70,16 +68,22 @@ export default function ConfigPage() {
}
}
async function handleRestore(filename: string) {
if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return
setRestoring(filename)
async function handleRestoreFile(file: File) {
if (!confirm(`Restaurer depuis "${file.name}" ? La base actuelle (BDD + médias) sera remplacée.`)) return
setRestoring(true)
setRestoreProgress(0)
setBackupError(null)
setBackupInfo(null)
try {
await restoreBackup(filename)
await uploadAndRestore(file, pct => setRestoreProgress(pct))
setBackupInfo('Restauration réussie')
fetchStats().then(setStats).catch(() => null)
} catch (e) {
setBackupError((e as Error).message)
} finally {
setRestoring(null)
setRestoring(false)
setRestoreProgress(0)
if (fileRef.current) fileRef.current.value = ''
}
}
@@ -189,10 +193,11 @@ export default function ConfigPage() {
{/* Sauvegarde & Restauration */}
<div style={sectionStyle}>
<div style={labelStyle}>Base de données</div>
<div style={labelStyle}>Sauvegarde complète (BDD + médias)</div>
<button
onClick={handleCreateBackup}
disabled={backupLoading}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8, border: 'none',
background: 'var(--ok)', color: '#1d2021',
@@ -202,59 +207,63 @@ export default function ConfigPage() {
opacity: backupLoading ? 0.6 : 1,
}}
>
<i className="fa-solid fa-database" />
{backupLoading ? 'Sauvegarde en cours' : 'Créer une sauvegarde'}
<i className="fa-solid fa-download" />
{backupLoading ? 'Préparation' : 'Télécharger une archive'}
</button>
<input
ref={fileRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) void handleRestoreFile(f)
}}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
cursor: restoring ? 'default' : 'pointer', minHeight: 44,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: restoring ? 0.6 : 1,
}}
>
<i className="fa-solid fa-upload" />
{restoring
? (restoreProgress < 100 ? `Upload ${restoreProgress}%…` : 'Restauration en cours')
: 'Restaurer depuis une archive'}
</button>
{restoring && (
<div style={{ height: 6, background: 'var(--bg-4)', borderRadius: 999, overflow: 'hidden' }}>
<div style={{
width: `${restoreProgress}%`, height: '100%',
background: 'var(--warn)', transition: 'width 0.2s ease',
}} />
</div>
)}
{backupError && (
<div style={{ color: 'var(--err)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupError}
</div>
)}
{backups.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
<div style={{ ...labelStyle, marginBottom: 0 }}>Sauvegardes disponibles</div>
{backups.map(b => (
<div
key={b.filename}
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px',
}}
>
<i className="fa-solid fa-file-zipper" style={{ color: 'var(--ink-3)', fontSize: 14, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.filename}
</div>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-4)' }}>
{formatDate(b.created_at)} — {formatSize(b.size)}
</div>
</div>
<button
onClick={() => void handleRestore(b.filename)}
disabled={restoring === b.filename}
style={{
padding: '5px 10px', borderRadius: 6,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)', fontFamily: 'var(--font-ui)', fontSize: 11,
cursor: restoring === b.filename ? 'default' : 'pointer',
flexShrink: 0, opacity: restoring === b.filename ? 0.5 : 1,
}}
>
{restoring === b.filename ? '' : 'Restaurer'}
</button>
</div>
))}
{backupInfo && (
<div style={{ color: 'var(--ok)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupInfo}
</div>
)}
{backups.length === 0 && !backupLoading && (
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
Aucune sauvegarde disponible
</div>
)}
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 11, lineHeight: 1.4 }}>
L'archive contient le dump PostgreSQL et tout le dossier <code style={{ fontFamily: 'var(--font-mono)' }}>uploads/</code> (photos, audio, vidéos). À restaurer sur une instance compatible.
</div>
</div>
{/* Taille du texte */}
+10 -9
View File
@@ -326,10 +326,10 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── COLLAPSED ───────────────────────────────────────────────────────────────
if (state === 'collapsed') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px' }}>
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400 }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400, overflowWrap: 'anywhere' }}>
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')}
</span>
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
@@ -345,17 +345,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── SEMI (défaut) ───────────────────────────────────────────────────────────
if (state === 'semi') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
<div style={{
color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
overflowWrap: 'anywhere',
} as React.CSSProperties}>
{note.content}
</div>
@@ -370,18 +371,18 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo,
// ─── EXPANDED ────────────────────────────────────────────────────────────────
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0 }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
</div>
{toggleBtn}
</div>
<div>{renderMarkdown(note.content)}</div>
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
{mediaSection}
{metaLine}
{actionButtons}
+88 -3
View File
@@ -30,6 +30,24 @@ const inputStyle: React.CSSProperties = {
const noSelect: React.CSSProperties = { userSelect: 'none' }
function isoWeek(d: Date): { week: number; year: number } {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7))
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
return { week, year: date.getUTCFullYear() }
}
function isListOutdated(name: string | null | undefined): boolean {
if (!name) return false
const m = /^S(\d{1,2})\s+(\d{4})$/.exec(name)
if (!m) return false
const listWeek = parseInt(m[1], 10)
const listYear = parseInt(m[2], 10)
const { week, year } = isoWeek(new Date())
return year > listYear || (year === listYear && week > listWeek)
}
function QtyControls({ qty, onDecrement, onIncrement }: { qty: number; onDecrement: () => void; onIncrement: () => void }) {
const btnBase: React.CSSProperties = {
width: 32, height: 32, borderRadius: 8, border: 'none',
@@ -334,10 +352,13 @@ export default function ShoppingPage() {
}
}
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
async function handleFinish() {
if (!currentList) return
try {
await finishShopping(currentList.id)
setShowFinishConfirm(false)
void loadData()
} catch {
setError('Erreur lors de la finalisation')
@@ -418,6 +439,21 @@ export default function ShoppingPage() {
...noSelect,
}}
>Boutiques</button>
{hasCurrentList && (
<button
className="hidden lg:flex"
onClick={openAddSheet}
style={{
alignItems: 'center', gap: 8,
background: 'var(--accent)', border: 'none',
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
...noSelect,
}}
>
<i className="fa-solid fa-cart-plus" /> Article
</button>
)}
</div>
{/* ── Erreur ── */}
@@ -496,10 +532,27 @@ export default function ShoppingPage() {
padding: '8px 16px',
background: 'var(--bg-3)',
borderBottom: '1px solid var(--bg-4)',
flexWrap: 'wrap',
}}>
<span style={{ flex: 1, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{checkedItems.length}/{currentList.item_count} cochés
</span>
{isListOutdated(currentList.name) && (
<span
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
style={{
background: 'var(--warn)', color: '#1d2021',
borderRadius: 999, padding: '2px 8px',
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4,
...noSelect,
}}
>
<i className="fa-solid fa-triangle-exclamation" style={{ fontSize: 10 }} />
semaine dépassée
</span>
)}
<div style={{ flex: 1 }} />
{pastLists.length > 0 && (
<button
onClick={() => setShowHistoryModal(true)}
@@ -511,14 +564,17 @@ export default function ShoppingPage() {
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer</button>
<button
onClick={() => void handleFinish()}
onClick={() => setShowFinishConfirm(true)}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>Terminer </button>
>
<i className="fa-solid fa-check" /> Clôturer la semaine
</button>
</div>
{/* Articles non cochés */}
@@ -801,6 +857,35 @@ export default function ShoppingPage() {
onStoresChanged={() => void loadData()}
/>
)}
{showFinishConfirm && currentList && (
<Modal title="Clôturer la semaine ?" onClose={() => setShowFinishConfirm(false)} width={420}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p style={{ margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 14, lineHeight: 1.5 }}>
La liste <strong style={{ color: 'var(--ink-1)' }}>{currentList.name ?? 'en cours'}</strong> va être archivée.
</p>
{uncheckedItems.length > 0 ? (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5 }}>
<strong style={{ color: 'var(--warn)' }}>{uncheckedItems.length}</strong> article{uncheckedItems.length > 1 ? 's' : ''} non coché{uncheckedItems.length > 1 ? 's' : ''} {uncheckedItems.length > 1 ? 'seront reportés' : 'sera reporté'} dans la nouvelle liste de la semaine en cours.
</p>
) : (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Tous les articles sont cochés. Une nouvelle liste vide sera créée pour la semaine en cours.
</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowFinishConfirm(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleFinish()}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44 }}
>Clôturer</button>
</div>
</div>
</Modal>
)}
</div>
)
}
+7
View File
@@ -149,6 +149,13 @@ export default function TodosPage() {
<option value="cancelled">Annulé</option>
<option value="">Tous</option>
</select>
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{ alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, cursor: 'pointer', ...noSelect }}
>
<i className="fa-solid fa-plus" /> Nouvelle tâche
</button>
</div>
{error && (