feat(frontend): design système - tokens, composants ui et restylage des pages

Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page
(login, change-password, dashboard) en tokens CSS partagés, ajoute un petit
set de composants standalone réutilisables (ev-button, ev-card, ev-alert,
ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans
Swagger/ReDoc côté backend.

Refs #91
This commit is contained in:
Johan LEROY
2026-09-17 11:14:02 +02:00
parent 3692d486c6
commit 9c78c6dc38
35 changed files with 724 additions and 288 deletions
+48 -2
View File
@@ -1,9 +1,15 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.staticfiles import StaticFiles
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.middleware import SecurityHeadersMiddleware
@@ -18,6 +24,8 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager
@@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS,
debug=resolved.debug,
lifespan=lifespan,
docs_url="/docs" if documentee else None,
redoc_url="/redoc" if documentee else None,
docs_url=None,
redoc_url=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.
def openapi_avec_logo() -> dict[str, object]:
if application.openapi_schema:
return application.openapi_schema
schema = get_openapi(
title=application.title,
version=application.version,
summary=application.summary,
description=application.description,
routes=application.routes,
tags=application.openapi_tags,
)
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
application.openapi_schema = schema
return application.openapi_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)
if resolved.allowed_origins:
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+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.
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
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.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -1,31 +1,36 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<img src="logo.png" alt="EnerVision" class="auth-logo" />
<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
id="current_password"
class="form-input"
type="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
id="new_password"
class="form-input"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
<span class="form-hint">12 à 128 caractères</span>
@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' }}
</button>
</ev-button>
</ev-card>
</form>
</div>
@@ -3,86 +3,42 @@
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--color-bg);
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
.auth-card-wrapper {
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
}
h1 {
.auth-logo {
display: block;
height: 48px;
margin: 0 auto 1rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
color: var(--color-text);
text-align: center;
}
.auth-subtitle {
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.4;
}
text-align: center;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
ev-alert {
display: block;
margin-top: 0.75rem;
}
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 {
ev-button {
display: block;
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;
}
@@ -54,7 +54,7 @@ describe('ChangePassword', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect');
});
@@ -64,7 +64,7 @@ describe('ChangePassword', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]');
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)', () => {
@@ -2,11 +2,14 @@ import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
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';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, Button, Card, Alert],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
@@ -1,36 +1,41 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<img src="logo.png" alt="EnerVision" class="auth-logo" />
<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
id="email"
class="form-input"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<label class="form-label" for="password">Mot de passe</label>
<input
id="password"
class="form-input"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
<ev-alert severity="danger">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(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' }}
</button>
</ev-button>
</ev-card>
</form>
</div>
@@ -3,79 +3,41 @@
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--color-bg);
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
.auth-card-wrapper {
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
}
h1 {
.auth-logo {
display: block;
height: 48px;
margin: 0 auto 1rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
color: var(--color-text);
text-align: center;
}
.auth-subtitle {
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
color: var(--color-text-muted);
font-size: 0.9rem;
}
text-align: center;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
ev-alert {
display: block;
margin-top: 0.75rem;
}
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 {
ev-button {
display: block;
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;
}
@@ -63,7 +63,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
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.');
});
@@ -80,7 +80,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('30s');
});
@@ -90,7 +90,7 @@ describe('Login', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]');
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)', () => {
@@ -3,11 +3,14 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
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';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, Button, Card, Alert],
templateUrl: './login.html',
styleUrl: './login.scss',
})
@@ -1,19 +1,22 @@
<div class="dashboard">
<header class="dashboard__header">
<div class="dashboard__brand">
<img src="logo.png" alt="EnerVision" class="dashboard__logo" />
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
</div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header>
@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) {
<section class="overview">
<div class="card card--gauge">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
<app-consumption-gauge
[consumption]="s.total_consumption_kw"
@@ -23,20 +26,20 @@
>{{ s.total_consumption_kw | number: '1.0-1' }} /
{{ 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__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
</div>
</div>
</ev-card>
<div class="card">
<ev-card class="card">
<span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span>
</div>
</ev-card>
</section>
<section class="chart-section">
@@ -50,8 +53,8 @@
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item" [class]="'alert-item--' + alert.severity">
<span class="alert-item__badge">{{ alert.severity }}</span>
<li class="alert-item">
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
}
@@ -1,23 +1,22 @@
: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;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #1f2937;
color: var(--color-text);
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
}
.dashboard__brand {
display: flex;
align-items: center;
gap: 0.85rem;
h1 {
margin: 0;
@@ -26,6 +25,11 @@
}
}
.dashboard__logo {
height: 40px;
width: auto;
}
.dashboard__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
@@ -38,13 +42,8 @@ h2 {
}
.banner-error {
display: block;
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 {
@@ -55,14 +54,8 @@ h2 {
}
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.card--gauge {
@@ -84,16 +77,16 @@ h2 {
.progress-bar {
height: 6px;
background: #e5e7eb;
border-radius: 999px;
background: var(--color-border-light);
border-radius: var(--radius-pill);
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar__fill {
height: 100%;
background: #3b82f6;
border-radius: 999px;
background: var(--color-primary);
border-radius: var(--radius-pill);
transition: width 0.3s ease;
}
@@ -115,59 +108,26 @@ h2 {
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius);
background: #fef2f2;
border: 1px solid #fecaca;
}
.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);
border-radius: var(--radius-md);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
}
.alert-item__message {
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;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
color: #374151;
color: var(--color-label);
cursor: pointer;
&:hover {
background: #f3f4f6;
background: var(--color-bg);
}
}
@@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load
import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service';
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';
const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE =
'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: 'danger',
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
@@ -54,6 +64,10 @@ export class Dashboard implements OnInit {
});
}
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
onLogout(): void {
this.auth.logout().subscribe({
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: #92400e;
}
: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,27 @@
.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: #fff;
flex-shrink: 0;
}
.ev-badge--success {
background: var(--color-success);
}
.ev-badge--warning {
background: var(--color-warning);
}
.ev-badge--danger {
background: var(--color-danger);
}
.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' | 'neutral';
@Component({
selector: 'ev-badge',
standalone: true,
templateUrl: './badge.html',
styleUrl: './badge.scss',
})
export class Badge {
tone = input<BadgeTone>('neutral');
}
@@ -0,0 +1,3 @@
<button [type]="type()" class="ev-button" [class]="'ev-button--' + variant()" [disabled]="disabled()">
<ng-content></ng-content>
</button>
@@ -0,0 +1,51 @@
.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--primary {
background: var(--color-primary);
color: #fff;
&: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: #fff;
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: #b91c1c;
}
}
@@ -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,15 @@
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);
}
@@ -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 {}
+8 -1
View File
@@ -1 +1,8 @@
/* You can add global styles to this file, and also import other style files */
@use 'styles/tokens';
@use 'styles/forms';
body {
margin: 0;
font-family: var(--font-family);
color: var(--color-text);
}
+37
View File
@@ -0,0 +1,37 @@
.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;
}
.form-error {
margin: var(--space-2) 0 0;
color: var(--color-danger);
font-size: 0.85rem;
}
+39
View File
@@ -0,0 +1,39 @@
: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-bg: #fef2f2;
--color-danger-border: #fecaca;
// 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;
}
@@ -0,0 +1,76 @@
# 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`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, 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`, `.form-error`)
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.
## 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`.
```html
<ev-button type="submit" [disabled]="form.invalid">Valider</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` / `neutral`, défaut
`neutral`), pastille à bord arrondi pour un statut court.
```html
<ev-badge tone="danger">critique</ev-badge>
```
## Logo
`apps/frontend/public/logo.png` (192×128, recadré et compressé depuis l'asset source du projet)
est affiché en en-tête des pages d'authentification et du tableau de bord :
```html
<img src="logo.png" alt="EnerVision" class="auth-logo" />
```
Le favicon reste `apps/frontend/public/favicon.ico` (non remplacé par ce chantier).
Côté backend, l'icône seule (sans le mot-symbole) est servie depuis
`apps/backend/app/static/logo-icon.png` et référencée par `/docs` (favicon Swagger) et `/redoc`
(logo natif via l'extension `x-logo` du schéma OpenAPI, voir `app/main.py`).
## 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éé.