From 9c78c6dc3882d12c3ce8958a80a8aad1b30ca7b2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:14:02 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(frontend):=20design=20syst=C3=A8me=20-?= =?UTF-8?q?=20tokens,=20composants=20ui=20et=20restylage=20des=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/backend/app/main.py | 50 ++++++++- apps/backend/app/static/logo-icon.png | Bin 0 -> 36468 bytes apps/frontend/README.md | 7 ++ apps/frontend/public/logo.png | Bin 0 -> 7152 bytes .../auth/change-password/change-password.html | 53 +++++---- .../auth/change-password/change-password.scss | 104 +++++------------- .../change-password/change-password.spec.ts | 4 +- .../auth/change-password/change-password.ts | 5 +- .../src/app/features/auth/login/login.html | 63 ++++++----- .../src/app/features/auth/login/login.scss | 100 ++++++----------- .../src/app/features/auth/login/login.spec.ts | 6 +- .../src/app/features/auth/login/login.ts | 5 +- .../src/app/features/dashboard/dashboard.html | 27 +++-- .../src/app/features/dashboard/dashboard.scss | 96 +++++----------- .../src/app/features/dashboard/dashboard.ts | 18 ++- .../app/shared/components/ui/alert/alert.html | 1 + .../app/shared/components/ui/alert/alert.scss | 27 +++++ .../shared/components/ui/alert/alert.spec.ts | 30 +++++ .../app/shared/components/ui/alert/alert.ts | 21 ++++ .../app/shared/components/ui/badge/badge.html | 3 + .../app/shared/components/ui/badge/badge.scss | 27 +++++ .../shared/components/ui/badge/badge.spec.ts | 30 +++++ .../app/shared/components/ui/badge/badge.ts | 13 +++ .../shared/components/ui/button/button.html | 3 + .../shared/components/ui/button/button.scss | 51 +++++++++ .../components/ui/button/button.spec.ts | 50 +++++++++ .../app/shared/components/ui/button/button.ts | 15 +++ .../app/shared/components/ui/card/card.html | 1 + .../app/shared/components/ui/card/card.scss | 10 ++ .../shared/components/ui/card/card.spec.ts | 22 ++++ .../src/app/shared/components/ui/card/card.ts | 9 ++ apps/frontend/src/styles.scss | 9 +- apps/frontend/src/styles/_forms.scss | 37 +++++++ apps/frontend/src/styles/_tokens.scss | 39 +++++++ .../32-design-systeme-frontend.md | 76 +++++++++++++ 35 files changed, 724 insertions(+), 288 deletions(-) create mode 100644 apps/backend/app/static/logo-icon.png create mode 100644 apps/frontend/public/logo.png create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.html create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.scss create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.html create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.scss create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.html create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.scss create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.html create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.scss create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.ts create mode 100644 apps/frontend/src/styles/_forms.scss create mode 100644 apps/frontend/src/styles/_tokens.scss create mode 100644 docs/architecture/32-design-systeme-frontend.md diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..5a8ec3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -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: diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bdc53f943206607cea054067c3888aa9056d83 GIT binary patch literal 36468 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 kzNePZU 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -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. diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e41b24ad30754b294062a27d7f16819fc51210 GIT binary patch literal 7152 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 -

-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + - - - 12 à 128 caractères + + + 12 à 128 caractères - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..48051a5 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -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 { - 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-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + line-height: 1.4; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..6fe09d0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -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)', () => { diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..1acf568 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -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', }) diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..89e67fe 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,41 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..46cc393 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -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 { - 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-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..46afce2 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -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)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..ea8f62f 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -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', }) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..533f0e3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,22 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +53,8 @@

Alertes actives

    @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} +
  • + {{ alert.severity }} {{ alert.message }}
  • } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 75976e1..53c5364 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -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); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index c6a6a56..28bc37e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -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 = { + 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']), diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.html b/apps/frontend/src/app/shared/components/ui/alert/alert.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.scss b/apps/frontend/src/app/shared/components/ui/alert/alert.scss new file mode 100644 index 0000000..667f779 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts new file mode 100644 index 0000000..cea6bd7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts @@ -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: `C'est fait`, +}) +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"); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.ts new file mode 100644 index 0000000..45e030f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.ts @@ -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('danger'); + + @HostBinding('class') + get hostClass(): string { + return `ev-alert ev-alert--${this.severity()}`; + } + + @HostBinding('attr.role') + readonly role = 'alert'; +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.html b/apps/frontend/src/app/shared/components/ui/badge/badge.html new file mode 100644 index 0000000..9dd0cd5 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.scss b/apps/frontend/src/app/shared/components/ui/badge/badge.scss new file mode 100644 index 0000000..212281f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts new file mode 100644 index 0000000..09e1a25 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts @@ -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: `critique`, +}) +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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.ts new file mode 100644 index 0000000..e9cc0ca --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.ts @@ -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('neutral'); +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.html b/apps/frontend/src/app/shared/components/ui/button/button.html new file mode 100644 index 0000000..08ab099 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/button/button.scss b/apps/frontend/src/app/shared/components/ui/button/button.scss new file mode 100644 index 0000000..0f33524 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -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; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.spec.ts b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts new file mode 100644 index 0000000..8362cdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts @@ -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: `Valider`, +}) +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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/button/button.ts b/apps/frontend/src/app/shared/components/ui/button/button.ts new file mode 100644 index 0000000..c9d6b11 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -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('primary'); + type = input<'button' | 'submit'>('button'); + disabled = input(false); +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.html b/apps/frontend/src/app/shared/components/ui/card/card.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/card/card.scss b/apps/frontend/src/app/shared/components/ui/card/card.scss new file mode 100644 index 0000000..09307fb --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.scss @@ -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; +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.spec.ts b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts new file mode 100644 index 0000000..cea71d9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts @@ -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: `

    Contenu

    `, +}) +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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/card/card.ts b/apps/frontend/src/app/shared/components/ui/card/card.ts new file mode 100644 index 0000000..50c496c --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-card', + standalone: true, + templateUrl: './card.html', + styleUrl: './card.scss', +}) +export class Card {} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 90d4ee0..0d45801 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -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); +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..fc35ad4 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -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; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..4b85e61 --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -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; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..d2324e9 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -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 `