Merge pull request #93 from ineszang/feat/design-system

feat(frontend): design système - tokens, composants ui et restylage des pages
This commit is contained in:
Johan LEROY
2026-09-17 15:56:24 +02:00
committed by GitHub
48 changed files with 870 additions and 333 deletions
+39 -2
View File
@@ -1,9 +1,14 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware from app.api.middleware import SecurityHeadersMiddleware
@@ -18,6 +23,8 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager @asynccontextmanager
@@ -43,11 +50,41 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS, openapi_tags=TAGS,
debug=resolved.debug, debug=resolved.debug,
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs" if documentee else None, docs_url=None,
redoc_url="/redoc" if documentee else None, redoc_url=None,
openapi_url="/openapi.json" if documentee else None, openapi_url="/openapi.json" if documentee else None,
) )
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
openapi_original = application.openapi
def openapi_avec_logo() -> dict[str, object]:
schema = openapi_original()
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
return schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware) application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins: if resolved.allowed_origins:
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+5 -1
View File
@@ -4,7 +4,11 @@
"title": "EnerVision API", "title": "EnerVision API",
"summary": "Collecte, analyse et restitution de séries temporelles énergétiques.", "summary": "Collecte, analyse et restitution de séries temporelles énergétiques.",
"description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n", "description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n",
"version": "0.1.0" "version": "0.1.0",
"x-logo": {
"url": "/static/logo-icon.png",
"altText": "EnerVision"
}
}, },
"paths": { "paths": {
"/api/v1/health/live": { "/api/v1/health/live": {
@@ -81,3 +81,18 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
) )
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None: def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
assert ("GET", chemin) in ROUTES_PUBLIQUES assert ("GET", chemin) in ROUTES_PUBLIQUES
# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette
# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit
# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus.
@pytest.mark.parametrize(
"chemin",
["/docs", "/redoc", "/static/logo-icon.png"],
ids=["swagger_ui", "redoc", "logo_statique"],
)
async def test_the_documentation_routes_are_public_by_design(
app: FastAPI, client: AsyncClient, chemin: str
) -> None:
response = await client.get(chemin)
assert response.status_code == 200
+13
View File
@@ -0,0 +1,13 @@
# Piège : le logo est committé indépendamment à deux endroits (`app/static/`, servi par
# `/docs`/`/redoc`, et `apps/frontend/public/`, servi au front) faute d'étape de build partagée.
# Sans ce test, une mise à jour d'un seul des deux fichiers dérive silencieusement : rien en CI
# ne le détecte.
from pathlib import Path
BACKEND_LOGO = Path(__file__).parent.parent / "app" / "static" / "logo-icon.png"
FRONTEND_LOGO = Path(__file__).parent.parent.parent / "frontend" / "public" / "logo-icon.png"
def test_the_backend_logo_stays_in_sync_with_the_frontend_one() -> None:
assert BACKEND_LOGO.read_bytes() == FRONTEND_LOGO.read_bytes()
+7
View File
@@ -76,6 +76,13 @@ Points à vérifier après toute regénération :
côté backend. Le `docker-compose.yml` n'a aucun service frontend. côté backend. Le `docker-compose.yml` n'a aucun service frontend.
4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx).
## Design système
Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`,
`ev-alert`, `ev-badge`) sont documentés dans
[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md).
Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs.
## Additional Resources ## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -1,31 +1,38 @@
<div class="auth-page"> <div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()"> <form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1> <ev-card>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p> <ev-brand class="auth-brand" />
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
</p>
<label for="current_password">Mot de passe actuel</label> <label class="form-label" for="current_password">Mot de passe actuel</label>
<input <input
id="current_password" id="current_password"
type="password" class="form-input"
formControlName="current_password" type="password"
autocomplete="current-password" formControlName="current_password"
/> autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label> <label class="form-label" for="new_password">Nouveau mot de passe</label>
<input <input
id="new_password" id="new_password"
type="password" class="form-input"
formControlName="new_password" type="password"
autocomplete="new-password" formControlName="new_password"
/> autocomplete="new-password"
<span class="auth-hint">{{ passwordHint }}</span> />
<span class="form-hint">{{ passwordHint }}</span>
@if (errorMessage()) { @if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p> <ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
} }
<button type="submit" [disabled]="form.invalid || isLoading()"> <ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }} {{ isLoading() ? 'Modification...' : 'Valider' }}
</button> </ev-button>
</ev-card>
</form> </form>
</div> </div>
@@ -1,88 +0,0 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -63,7 +63,7 @@ describe('ChangePassword', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage()) fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect'); expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect'); expect(errorEl?.textContent).toContain('incorrect');
}); });
@@ -73,7 +73,7 @@ describe('ChangePassword', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]'); const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true); expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
}); });
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -2,12 +2,16 @@ import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({ @Component({
selector: 'app-change-password', selector: 'app-change-password',
standalone: true, standalone: true,
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
templateUrl: './change-password.html', templateUrl: './change-password.html',
styleUrl: './change-password.scss', styleUrl: './change-password.scss',
}) })
@@ -36,7 +40,9 @@ export class ChangePassword {
}, },
error: () => { error: () => {
this.isLoading.set(false); this.isLoading.set(false);
this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); this.errorMessage.set(
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
);
}, },
}); });
} }
@@ -1,38 +1,43 @@
<div class="auth-page"> <div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()"> <form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Connexion</h1> <ev-card>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p> <ev-brand class="auth-brand" />
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label> <label class="form-label" for="email">Email</label>
<input <input
id="email" id="email"
type="email" class="form-input"
formControlName="email" type="email"
autocomplete="username" formControlName="email"
placeholder="vous@enervision.fr" autocomplete="username"
/> placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label> <label class="form-label" for="password">Mot de passe</label>
<input <input
id="password" id="password"
type="password" class="form-input"
formControlName="password" type="password"
autocomplete="current-password" formControlName="password"
/> autocomplete="current-password"
/>
@if (errorMessage()) { @if (errorMessage()) {
<p class="auth-error"> <ev-alert severity="danger">
{{ errorMessage() }} {{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) { @if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s) (réessayez dans {{ seconds }}s)
} }
</p> </ev-alert>
} }
<button type="submit" [disabled]="form.invalid || isLoading()"> <ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }} {{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button> </ev-button>
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p> <p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
</ev-card>
</form> </form>
</div> </div>
@@ -1,85 +1,3 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
.auth-link { .auth-link {
margin-top: 1rem; margin-top: 1rem;
font-size: 0.85rem; font-size: 0.85rem;
@@ -79,7 +79,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
}); });
@@ -96,7 +96,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30); expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error'); const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('30s'); expect(errorEl?.textContent).toContain('30s');
}); });
@@ -114,7 +114,7 @@ describe('Login', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]'); const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true); expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
}); });
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -3,12 +3,19 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason'; import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import {
MESSAGE_LIEN_RESET_INVALIDE,
MOTIF_LIEN_RESET_INVALIDE,
} from '../../../shared/models/auth-redirect-reason';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [ReactiveFormsModule, RouterLink], imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand],
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss', styleUrl: './login.scss',
}) })
@@ -1,19 +1,24 @@
<div class="dashboard"> <div class="dashboard">
<header class="dashboard__header"> <header class="dashboard__header">
<div> <div class="dashboard__brand">
<h1>Vue d'ensemble</h1> <ev-brand class="dashboard__logo" />
<p class="dashboard__subtitle">Consommation instantanée du parc</p> <div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
</div> </div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button> <ev-button class="logout-button" variant="secondary" [fullWidth]="false" (click)="onLogout()"
>Déconnexion</ev-button
>
</header> </header>
@if (error(); as message) { @if (error(); as message) {
<p class="banner-error" role="alert">{{ message }}</p> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (stats(); as s) { @if (stats(); as s) {
<section class="overview"> <section class="overview">
<div class="card card--gauge"> <ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span> <span class="card__label">Consommation vs capacité</span>
<app-consumption-gauge <app-consumption-gauge
[consumption]="s.total_consumption_kw" [consumption]="s.total_consumption_kw"
@@ -23,20 +28,20 @@
>{{ s.total_consumption_kw | number: '1.0-1' }} / >{{ s.total_consumption_kw | number: '1.0-1' }} /
{{ s.total_capacity_kw | number }} kW</span {{ s.total_capacity_kw | number }} kW</span
> >
</div> </ev-card>
<div class="card"> <ev-card class="card">
<span class="card__label">Charge moyenne du parc</span> <span class="card__label">Charge moyenne du parc</span>
<span class="card__value">{{ s.average_load_percent }} %</span> <span class="card__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div> <div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
</div> </div>
</div> </ev-card>
<div class="card"> <ev-card class="card">
<span class="card__label">Sites suivis</span> <span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span> <span class="card__value">{{ s.total_sites }}</span>
</div> </ev-card>
</section> </section>
<section class="chart-section"> <section class="chart-section">
@@ -50,8 +55,8 @@
<h2>Alertes actives</h2> <h2>Alertes actives</h2>
<ul class="alerts-list"> <ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) { @for (alert of alerts(); track alert.alert_id) {
<li class="alert-item" [class]="'alert-item--' + alert.severity"> <li class="alert-item">
<span class="alert-item__badge">{{ alert.severity }}</span> <ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span> <span class="alert-item__message">{{ alert.message }}</span>
</li> </li>
} }
@@ -1,23 +1,22 @@
:host { :host {
--color-good: #2e7d32;
--color-partial: #f9a825;
--color-degraded: #ef6c00;
--color-critical: #c62828;
--color-bg-card: #ffffff;
--color-border: #e5e7eb;
--color-text-muted: #6b7280;
--radius: 10px;
display: block; display: block;
font-family: 'Segoe UI', system-ui, sans-serif; color: var(--color-text);
color: #1f2937; padding: 2.5rem 2rem;
padding: 2rem;
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
} }
.dashboard__header { .dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem; margin-bottom: 2rem;
}
.dashboard__brand {
display: flex;
align-items: center;
gap: 0.85rem;
h1 { h1 {
margin: 0; margin: 0;
@@ -26,6 +25,10 @@
} }
} }
.dashboard__logo {
font-size: 1.3rem;
}
.dashboard__subtitle { .dashboard__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -38,13 +41,8 @@ h2 {
} }
.banner-error { .banner-error {
display: block;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-critical);
border-left-width: 4px;
border-radius: var(--radius);
background: #fdecea;
color: var(--color-critical);
} }
.overview { .overview {
@@ -55,14 +53,8 @@ h2 {
} }
.card { .card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem; padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
} }
.card--gauge { .card--gauge {
@@ -84,16 +76,16 @@ h2 {
.progress-bar { .progress-bar {
height: 6px; height: 6px;
background: #e5e7eb; background: var(--color-border-light);
border-radius: 999px; border-radius: var(--radius-pill);
overflow: hidden; overflow: hidden;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background: #3b82f6; background: var(--color-primary);
border-radius: 999px; border-radius: var(--radius-pill);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -115,59 +107,11 @@ h2 {
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
border-radius: var(--radius); border-radius: var(--radius-md);
background: #fef2f2; background: var(--color-danger-bg);
border: 1px solid #fecaca; border: 1px solid var(--color-danger-border);
}
.alert-item__badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.55rem;
border-radius: 999px;
color: #fff;
background: var(--color-critical);
flex-shrink: 0;
}
.alert-item--high .alert-item__badge {
background: var(--color-degraded);
}
.alert-item--medium .alert-item__badge {
background: var(--color-partial);
}
.alert-item--low .alert-item__badge {
background: var(--color-good);
} }
.alert-item__message { .alert-item__message {
font-size: 0.9rem; font-size: 0.9rem;
} }
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.logout-button {
padding: 0.5rem 1rem;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
color: #374151;
cursor: pointer;
&:hover {
background: #f3f4f6;
}
}
@@ -148,4 +148,28 @@ describe('Dashboard', () => {
expect(authMock.clearSession).toHaveBeenCalled(); expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
}); });
it('distingue le ton des sévérités high et critical', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
dashboard.badgeToneForSeverity('critical'),
);
});
}); });
@@ -9,16 +9,28 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load
import { AlertsService } from '../../core/services/alerts.service'; import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model'; import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
import { Brand } from '../../shared/components/ui/brand/brand';
import { Button } from '../../shared/components/ui/button/button';
const REFRESH_INTERVAL_MS = 10000; const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE = const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.'; 'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'success',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge, Brand, Button],
templateUrl: './dashboard.html', templateUrl: './dashboard.html',
styleUrl: './dashboard.scss', styleUrl: './dashboard.scss',
}) })
@@ -54,6 +66,10 @@ export class Dashboard implements OnInit {
}); });
} }
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
onLogout(): void { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,27 @@
:host {
display: block;
margin: 0;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 0.85rem;
line-height: 1.4;
}
:host.ev-alert--success {
background: var(--color-success-bg);
border-color: var(--color-success);
color: var(--color-success);
}
:host.ev-alert--warning {
background: var(--color-warning-bg);
border-color: var(--color-warning);
color: var(--color-warning-text);
}
:host.ev-alert--danger {
background: var(--color-danger-bg);
border-color: var(--color-danger-border);
color: var(--color-danger);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Alert } from './alert';
@Component({
standalone: true,
imports: [Alert],
template: `<ev-alert severity="success">C'est fait</ev-alert>`,
})
class AlertHost {}
describe('Alert', () => {
it('applique la classe danger par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents();
const fixture = TestBed.createComponent(Alert);
fixture.detectChanges();
expect(fixture.nativeElement.classList).toContain('ev-alert--danger');
});
it('applique la sévérité demandée et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents();
const fixture = TestBed.createComponent(AlertHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-alert');
expect(el.classList).toContain('ev-alert--success');
expect(el.textContent).toContain("C'est fait");
});
});
@@ -0,0 +1,21 @@
import { Component, HostBinding, input } from '@angular/core';
export type AlertSeverity = 'success' | 'warning' | 'danger';
@Component({
selector: 'ev-alert',
standalone: true,
templateUrl: './alert.html',
styleUrl: './alert.scss',
})
export class Alert {
severity = input<AlertSeverity>('danger');
@HostBinding('class')
get hostClass(): string {
return `ev-alert ev-alert--${this.severity()}`;
}
@HostBinding('attr.role')
readonly role = 'alert';
}
@@ -0,0 +1,3 @@
<span class="ev-badge" [class]="'ev-badge--' + tone()">
<ng-content></ng-content>
</span>
@@ -0,0 +1,35 @@
:host {
display: inline-flex;
flex-shrink: 0;
}
.ev-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
color: var(--color-text-inverse);
}
.ev-badge--success {
background: var(--color-success);
}
.ev-badge--warning {
background: var(--color-warning);
}
.ev-badge--danger {
background: var(--color-danger);
}
.ev-badge--critical {
background: var(--color-critical);
}
.ev-badge--neutral {
background: var(--color-text-muted);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Badge } from './badge';
@Component({
standalone: true,
imports: [Badge],
template: `<ev-badge tone="danger">critique</ev-badge>`,
})
class BadgeHost {}
describe('Badge', () => {
it('applique le ton neutral par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents();
const fixture = TestBed.createComponent(Badge);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral');
});
it('applique le ton demandé et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents();
const fixture = TestBed.createComponent(BadgeHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-badge');
expect(el.classList).toContain('ev-badge--danger');
expect(el.textContent).toContain('critique');
});
});
@@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
export type BadgeTone = 'success' | 'warning' | 'danger' | 'critical' | 'neutral';
@Component({
selector: 'ev-badge',
standalone: true,
templateUrl: './badge.html',
styleUrl: './badge.scss',
})
export class Badge {
tone = input<BadgeTone>('neutral');
}
@@ -0,0 +1,2 @@
<img src="/logo-icon.png" alt="" class="ev-brand__icon" />
<span class="ev-brand__name">EnerVision</span>
@@ -0,0 +1,19 @@
:host {
display: inline-flex;
align-items: center;
gap: 0.45em;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
line-height: 1;
}
.ev-brand__icon {
height: 1.3em;
width: auto;
flex-shrink: 0;
}
.ev-brand__name {
white-space: nowrap;
}
@@ -0,0 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { Brand } from './brand';
describe('Brand', () => {
it("affiche l'icône et le nom EnerVision", async () => {
await TestBed.configureTestingModule({ imports: [Brand] }).compileComponents();
const fixture = TestBed.createComponent(Brand);
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('img.ev-brand__icon');
expect(icon).toBeTruthy();
expect(fixture.nativeElement.textContent).toContain('EnerVision');
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'ev-brand',
standalone: true,
templateUrl: './brand.html',
styleUrl: './brand.scss',
})
export class Brand {}
@@ -0,0 +1,9 @@
<button
[type]="type()"
class="ev-button"
[class]="'ev-button--' + variant()"
[class.ev-button--inline]="!fullWidth()"
[disabled]="disabled()"
>
<ng-content></ng-content>
</button>
@@ -0,0 +1,55 @@
.ev-button {
width: 100%;
padding: 0.7rem;
border: none;
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&.ev-button--inline {
width: auto;
}
}
.ev-button--primary {
background: var(--color-primary);
color: var(--color-text-inverse);
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: var(--color-primary-hover);
}
}
.ev-button--secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-label);
&:not(:disabled):hover {
background: var(--color-bg);
}
}
.ev-button--danger {
background: var(--color-danger);
color: var(--color-text-inverse);
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: var(--color-danger-hover);
}
}
@@ -0,0 +1,50 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Button } from './button';
@Component({
standalone: true,
imports: [Button],
template: `<ev-button>Valider</ev-button>`,
})
class ButtonHost {}
describe('Button', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [Button] }).compileComponents();
});
it('applique la classe de la variante primary par défaut', () => {
const fixture = TestBed.createComponent(Button);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--primary');
});
it('applique la classe de la variante demandée', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('variant', 'danger');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--danger');
});
it('désactive le bouton natif quand disabled est vrai', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents();
const fixture = TestBed.createComponent(ButtonHost);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider');
});
});
@@ -0,0 +1,16 @@
import { Component, input } from '@angular/core';
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
@Component({
selector: 'ev-button',
standalone: true,
templateUrl: './button.html',
styleUrl: './button.scss',
})
export class Button {
variant = input<ButtonVariant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
fullWidth = input(true);
}
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,10 @@
:host {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-5);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
@@ -0,0 +1,22 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Card } from './card';
@Component({
standalone: true,
imports: [Card],
template: `<ev-card><p>Contenu</p></ev-card>`,
})
class CardHost {}
describe('Card', () => {
it('projette son contenu', async () => {
await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents();
const fixture = TestBed.createComponent(CardHost);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('ev-card');
expect(card).toBeTruthy();
expect(card.textContent).toContain('Contenu');
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'ev-card',
standalone: true,
templateUrl: './card.html',
styleUrl: './card.scss',
})
export class Card {}
+2 -2
View File
@@ -2,10 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Frontend</title> <title>EnerVision</title>
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+10 -1
View File
@@ -1 +1,10 @@
/* You can add global styles to this file, and also import other style files */ @use 'styles/tokens';
@use 'styles/forms';
@use 'styles/auth-page';
body {
margin: 0;
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-bg);
}
+57
View File
@@ -0,0 +1,57 @@
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--space-4);
box-sizing: border-box;
background:
radial-gradient(circle at 15% 10%, var(--color-primary-light) 0%, transparent 45%),
radial-gradient(circle at 85% 90%, var(--color-primary-light) 0%, transparent 40%),
var(--color-bg);
}
.auth-card-wrapper {
width: 100%;
max-width: 420px;
ev-card {
padding: 3rem 2.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.06),
0 8px 10px -6px rgba(0, 0, 0, 0.04);
}
.auth-brand {
justify-content: center;
width: 100%;
font-size: 2.1rem;
margin-bottom: 1.75rem;
}
h1 {
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: var(--color-text);
text-align: center;
}
.auth-subtitle {
margin: 0.4rem 0 2rem;
color: var(--color-text-muted);
font-size: 0.95rem;
line-height: 1.4;
text-align: center;
}
ev-alert {
display: block;
margin-top: 0.75rem;
}
ev-button {
display: block;
margin-top: 1.75rem;
}
}
+31
View File
@@ -0,0 +1,31 @@
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-label);
margin-bottom: var(--space-1);
margin-top: var(--space-3);
}
.form-input {
width: 100%;
padding: var(--space-2) 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-family: var(--font-family);
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
}
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--color-disabled);
margin-top: 0.25rem;
}
+43
View File
@@ -0,0 +1,43 @@
:root {
// Marque (dérivé du logo : vert feuille/éclair, halo)
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-primary-light: #dcfce7;
// Neutres (texte, bordures, fonds)
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-label: #374151;
--color-border: #d1d5db;
--color-border-light: #e5e7eb;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-disabled: #9ca3af;
// Sémantique (statuts, alertes)
--color-success: #16a34a;
--color-success-bg: #dcfce7;
--color-warning: #f9a825;
--color-warning-bg: #fef9e7;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-bg: #fef2f2;
--color-danger-border: #fecaca;
--color-critical: #b91c1c;
--color-warning-text: #92400e;
--color-text-inverse: #ffffff;
// Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif;
--radius-sm: 8px;
--radius-md: 12px;
--radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
// Espacements
--space-1: 0.35rem;
--space-2: 0.6rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
}
+4
View File
@@ -16,6 +16,10 @@ Ce qui est en place :
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js. graphique de charge par site, tous deux construits sur Chart.js.
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
réutiliser plutôt que redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée. - L'état vit dans des signaux, sans bibliothèque dédiée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. - Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
- Prettier configuré, parser `angular` pour les gabarits HTML. - Prettier configuré, parser `angular` pour les gabarits HTML.
@@ -0,0 +1,96 @@
# Design système frontend
Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que
redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue
[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence
visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier).
## Tokens
Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une
seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire.
| Variable | Rôle |
|---|---|
| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales |
| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) |
| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes |
| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux |
| `--color-disabled` | Éléments désactivés |
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
| `--font-family` | Police unique de l'application |
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
fond par page.
## Composants partagés
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
dans le tableau `imports` du composant qui l'utilise.
- **`<ev-button>`** (`button/`) : `variant` (`primary` / `secondary` / `danger`, défaut
`primary`), `type` (`button` / `submit`, défaut `button`), `disabled`, `fullWidth` (défaut
`true` ; passer `false` pour un bouton qui ne doit pas occuper toute la largeur de son
conteneur, ex. une action isolée dans un en-tête).
```html
<ev-button type="submit" [disabled]="form.invalid">Valider</ev-button>
<ev-button variant="secondary" [fullWidth]="false">Déconnexion</ev-button>
```
- **`<ev-card>`** (`card/`) : conteneur à padding/rayon/ombre standard, sans input, tout est le
contenu projeté (`<ng-content>`). Le style vit sur `:host` : une classe externe passée par le
parent (`<ev-card class="ma-classe">`) se combine avec le style du composant sans le masquer.
```html
<ev-card><h1>Titre</h1></ev-card>
```
- **`<ev-alert>`** (`alert/`) : `severity` (`success` / `warning` / `danger`, défaut `danger`),
`role="alert"` posé automatiquement. Même principe de style sur `:host`.
```html
<ev-alert severity="danger">Erreur : {{ message }}</ev-alert>
```
- **`<ev-badge>`** (`badge/`) : `tone` (`success` / `warning` / `danger` / `critical` /
`neutral`, défaut `neutral`), pastille à bord arrondi pour un statut court. `danger` et
`critical` sont deux rouges distincts (`--color-danger` vs `--color-critical`, plus sombre) :
une sévérité `critical` ne doit pas se confondre visuellement avec une `high`.
```html
<ev-badge tone="danger">critique</ev-badge>
```
- **`<ev-brand>`** (`brand/`) : lockup icône + « EnerVision », sans input. La taille se pilote
entièrement via `font-size` (l'icône et le texte sont exprimés en `em`, donc ils grossissent
ensemble en gardant le même écart proportionnel) : une page l'agrandit simplement avec
`ev-brand { font-size: 2.1rem; }`. Ne pas recomposer icône + texte en une seule image bitmap :
un essai en ce sens (recadrage pixel de l'asset source) a produit un rendu bruité et un espacement
figé, impossible à ajuster proprement.
```html
<ev-brand />
```
## Logo
L'icône seule (sans le mot-symbole), recadrée depuis l'asset source du projet, vit à deux
endroits qui doivent rester synchronisés si le logo change un jour :
`apps/frontend/public/logo-icon.png` (utilisée par `<ev-brand>`) et
`apps/backend/app/static/logo-icon.png` (référencée par `/docs`, favicon Swagger, et `/redoc` via
l'extension `x-logo` du schéma OpenAPI, voir `app/main.py`). Le mot-symbole « EnerVision » n'est
jamais une image : c'est le texte du composant `<ev-brand>`, en police système.
Le favicon `apps/frontend/public/favicon.ico` est généré depuis la même icône (multi-tailles
16 à 256px).
## Règle pour toute nouvelle page
Utiliser les tokens et les composants ci-dessus plutôt que des valeurs en dur (couleurs
hexadécimales, rayons, espacements). Étendre ce document si un nouveau composant partagé est
créé.
+1
View File
@@ -13,6 +13,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI | | [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI |
| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | | [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP |
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents