feat(web): light/dark mode cu comutator persistat + anti-FOUC (PRD 5.3)

Tema light ca bloc [data-theme="light"] peste variabilele :root (dark
nemodificat la octet). Comutator soare/luna in header pe toate paginile,
default OS-aware (prefers-color-scheme, fallback dark), persistenta in
localStorage doar la comutare explicita, script anti-FOUC in <head>
pre-paint. Suprafetele de stare hardcodate convertite la color-mix in
base.html + 7 fragmente _*.html (light lizibil, contrast WCAG AA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-22 19:39:12 +00:00
parent ae7960294f
commit b48501d8e4
11 changed files with 515 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
{% if not account_active %}
<div class="card banner" style="border-color:var(--warn); background:#201c0f;"
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card));"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.

View File

@@ -7,7 +7,7 @@
</h2>
{% if message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}>
{{ message }}
</div>

View File

@@ -11,7 +11,7 @@
</div>
{% if message %}
<div class="flash" style="{% if error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:12px;"
<div class="flash" style="{% if error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:12px;"
{% if error %}role="alert"{% endif %}>
{{ message }}
</div>
@@ -56,7 +56,7 @@
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:#241a1a; margin-bottom:14px;">
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e

View File

@@ -31,12 +31,12 @@
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:10px;"
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:10px;"
role="alert">{{ message }}</div>
{% endif %}
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:#241a1a; border-radius:6px; font-size:13px;">
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>

View File

@@ -6,7 +6,7 @@
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:#201c0f; border-radius:6px; font-size:13px;">
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
<strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator.

View File

@@ -49,7 +49,7 @@
<h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:#241a1a;{% endif %} margin-bottom:10px;"
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin-bottom:10px;"
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %}

View File

@@ -9,12 +9,12 @@
{% endif %}
{% if error %}
<div class="flash" style="border-color:var(--err); background:#241a1a; margin-bottom:12px;"
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ error }}</div>
{% endif %}
{% if sheets %}
<div class="flash" style="border-color:var(--warn); background:#201c0f; margin-bottom:12px;">
<div class="flash" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:12px;">
Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
</div>
{% endif %}

View File

@@ -13,9 +13,26 @@
// useTemplateFragments parseaza tot intr-un <template>, pastrand rand + OOB impreuna.
htmx.config.useTemplateFragments = true;
</script>
<script>
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
(function() {
try {
var t = localStorage.getItem('theme');
if (!t) {
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', t);
} catch(e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
* { box-sizing:border-box; }
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
@@ -24,7 +41,7 @@
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
main { padding:24px; max-width:1100px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin-bottom:16px; }
.banner { border-left:3px solid var(--err); background:#241a1a; }
.banner { border-left:3px solid var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); }
.banner.hidden { display:none; }
/* Tabelele de date au multe coloane; pe ecrane inguste scroll IN card, nu
impinge layout-ul paginii (altfel toata pagina scrolleaza orizontal). */
@@ -46,7 +63,7 @@
text-align:center; transition:border-color .15s,background .15s; }
.drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); }
/* Banner varianta warn (nu eroare) */
.banner.warn { border-left-color:var(--warn); background:#201c0f; }
.banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); }
/* Bara confirmare sticky */
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
padding:12px 16px; display:flex; align-items:flex-start; gap:16px;
@@ -59,7 +76,7 @@
.cardlink { font-size:13px; padding:7px 10px; border-radius:6px; display:inline-flex;
align-items:center; min-height:36px; white-space:nowrap; }
.cardlink:hover { background:var(--line); }
.flash { background:#16241c; border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
.flash { background:color-mix(in srgb, var(--ok) 12%, var(--card)); border-left:3px solid var(--ok); padding:8px 12px; border-radius:6px;
margin:0 0 12px; font-size:13px; }
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
flex-wrap:wrap; }
@@ -92,8 +109,48 @@
<header>
<h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span>
<span class="muted" style="margin-left:auto; font-size:13px;">v{{ version }}</span>
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
<button id="tema-toggle"
aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema"
style="background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer; border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px; line-height:1; display:inline-flex; align-items:center; justify-content:center;">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span>
</div>
</header>
<main>{% block content %}{% endblock %}</main>
<script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
(function() {
var btn = document.getElementById('tema-toggle');
if (!btn) return;
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
function _syncIcon(t) {
if (t === 'light') {
btn.innerHTML = '&#9790;';
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
btn.title = 'Comuta tema (intunecat)';
} else {
btn.innerHTML = '&#9728;';
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
btn.title = 'Comuta tema (luminos)';
}
}
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
function _setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('theme', t); } catch(e) {}
_syncIcon(t);
}
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
btn.addEventListener('click', function() {
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
_setTheme(cur === 'dark' ? 'light' : 'dark');
});
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,256 @@
# PRD 5.3 — Light/Dark mode (comutator tema persistat)
**Stare**: inchis
> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
## 1. Obiectiv
Dashboard-ul web e azi **doar dark** (paleta fixa in `:root`). Adaugam o tema **light** si un
**comutator in header** care persista alegerea utilizatorului. Service-urile care vin din Visual
FoxPro / soft propriu lucreaza des in birouri luminoase si pe monitoare unde dark-mode obositor sau
greu de citit la videoproiector — un toggle light/dark e o cerinta de ergonomie de baza (Etapa 5).
CSS-ul **e deja pe variabile** (`--bg`, `--card`, `--ink`, `--muted`, `--line`, `--ok`, `--warn`,
`--err`, `--accent` in `base.html`). Tema light = un bloc `[data-theme="light"]` care **suprascrie
aceleasi variabile** cu o paleta deschisa. Efort mic, zero logica de domeniu, zero backend.
**Invariant de design (motivul cheie):** comutarea nu trebuie sa **palpaie** (FOUC — flash of
unstyled / wrong-theme content). Tema se aplica **inainte de primul paint** printr-un script inline
mic in `<head>`, care citeste preferinta din `localStorage` si seteaza `data-theme` pe `<html>`
sincron, inainte ca `<body>` sa randeze. Fara asta, fiecare incarcare de pagina ar clipi dark→light.
## 2. Non-Goals (anti scope-creep)
- **NU backend / cookie / ruta noua.** Persistenta = `localStorage` pur client-side (roadmap zice
"cookie/localStorage" — alegem localStorage: zero suprafata server + anti-FOUC prin scriptul din
`<head>`). Nu se atinge `routes.py`, `auth.py`, sesiunea, baza de date.
- **NU redesign de paleta dark.** Tema dark ramane **identica la octet** cu cea de azi (default
pastrat); adaugam doar varianta light + un toggle. Nicio culoare dark existenta nu se schimba.
- **NU teme multiple / personalizate / culoare de accent reglabila.** Doar doua: `dark` (default)
si `light`.
- **NU atinge worker, masina de stari, idempotenta, mapping, schema, validation.py, API.** Strict
`app/web/templates/base.html` (+ eventual un test de template).
- **NU restilizeaza fragmentele HTMX.** Toate fragmentele (`_*.html`) mostenesc variabilele din
`base.html` — comuta automat cu tema. (Conditie: zero culori hardcodate care sa nu adapteze —
vezi US-001 pentru suprafetele care azi au fundal hardcodat.)
## 3. Stories atomice
> Ambele stories ating **acelasi fisier** (`base.html`) → **secventiale**, un singur worker (sau
> lead direct, livrabila mica — ROADMAP §5.5). NU se paralelizeaza (regula fisier-comun §5.5).
### US-001: Tema light (paleta + suprafete theme-aware)
**Ca** utilizator al dashboard-ului **vreau** o paleta light corecta si lizibila **pentru ca** sa pot
folosi gateway-ul confortabil in birou luminos / la videoproiector, fara contrast slab sau zone care
raman intunecate.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html` (bloc CSS `[data-theme="light"]` + conversia
suprafetelor cu fundal hardcodat la variabile), `tests/test_tema.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_paleta_light_definita` — HTML-ul `GET /login` (sau dashboard) contine un selector
`[data-theme="light"]` care redefineste `--bg`, `--card`, `--ink`, `--muted`, `--line`.
- `test_dark_ramane_default``:root` contine inca paleta dark exacta (`--bg:#0f1115`,
`--card:#181b22`, `--ink:#e6e9ef`) → default neschimbat.
- `test_suprafete_fara_fundal_hardcodat` — fundalurile de stare (banner eroare/warn, flash) NU mai
folosesc literal hex dark fix (`#241a1a`, `#201c0f`, `#16241c`) ci variabile/`color-mix` ce
adapteaza la tema (asertie pe absenta literalilor in `<style>`).
- **Acceptance criteria**:
- [ ] Exista un bloc `[data-theme="light"]` in `<style>` care suprascrie cel putin
`--bg`, `--card`, `--ink`, `--muted`, `--line` cu o paleta deschisa (fundal deschis, text
inchis). Contrastul text/fundal ≥ WCAG AA (4.5:1 pentru `--ink` pe `--bg` si pe `--card`).
- [ ] Paleta **dark** din `:root` ramane neschimbata la octet (default) — comportament identic cu azi
cand nu exista preferinta salvata si OS-ul nu cere light.
- [ ] Suprafetele cu fundal azi hardcodat dark (`.banner`, `.banner.warn`, `.flash`, eventual
`.drop-zone.drag-over`) sunt facute theme-aware (variabile sau `color-mix` peste paleta), astfel
incat in light arata corect (fundal deschis colorat, nu pata intunecata).
- [ ] Culorile semantice (`--ok`/`--warn`/`--err`/`--accent`) raman lizibile pe fundal light
(ajustate daca e nevoie pentru contrast ≥ AA pe text mic).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: browser pe `/` cu `document.documentElement.dataset.theme="light"` setat manual →
fundal deschis, text inchis lizibil, banner/flash/pill citibile, tabele cu linii vizibile, niciun
text "invizibil" (acelasi ton ca fundalul).
### US-003: Suprafete theme-aware si in fragmentele HTMX (fix VERIFY r1)
**Ca** utilizator pe light mode **vreau** ca bannerele de eroare/warn/flash din fragmentele HTMX sa
fie lizibile **pentru ca** azi raman pete intunecate cu text invizibil (defect prins la VERIFY r1).
- **Depinde de**: US-001 (extinde aceeasi conversie theme-aware dincolo de `base.html`)
- **Motiv**: US-001 a convertit la `color-mix` DOAR `base.html`; aceleasi fundaluri hardcodate dark
(`#241a1a` err, `#201c0f` warn) traiesc ca **inline-style** in 7 fragmente `_*.html` (10 aparitii)
— randate in dashboard, deci vizibile in light ca text invizibil. Testul US-001 scana doar `<style>`
din base.html → trecea vacuu.
- **Fisiere**: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`,
`_preview_rand.html`, `_trimitere_detaliu.html`, `_mapcoloane.html` (toate `app/web/templates/`),
`tests/test_tema.py` (extins)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_fragmente_fara_fundal_hardcodat` — scaneaza TOATE fisierele `app/web/templates/_*.html`
(continutul brut) si asigura ca niciunul nu contine literalii `#241a1a`, `#201c0f`, `#16241c`.
- **Acceptance criteria**:
- [ ] Niciun fisier din `app/web/templates/` (inclusiv fragmentele) nu mai contine literalii hex
dark-fix `#241a1a`/`#201c0f`/`#16241c`; fundalurile folosesc `color-mix` peste paleta
(`var(--err)`/`var(--warn)`/`var(--ok)` 12% peste `var(--card)`), exact ca in `base.html`.
- [ ] In light mode bannerele/flash-urile de stare au fundal deschis colorat cu text lizibil
(verificat E2E pe dashboard: banner "Cont in asteptare de activare" nu mai e cutie neagra).
- [ ] In dark mode aspectul ramane practic identic cu azi (color-mix peste `--card` dark dă aproape
aceeasi nuanta).
- [ ] Testul de protectie scaneaza fragmentele, nu doar `base.html` (lacuna r1 inchisa).
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E**: dashboard pe light mode → banner pending-account + orice flash de eroare/warn
lizibile (fundal deschis); comuta la dark → aspect neschimbat.
### US-002: Comutator tema in header + persistenta + anti-FOUC
**Ca** utilizator **vreau** un buton in header care comuta light/dark si imi tine minte alegerea
**pentru ca** sa nu re-comut la fiecare incarcare si sa nu vad un flash de tema gresita.
- **Depinde de**: US-001 (vizual; tehnic ating acelasi fisier → oricum secvential)
- **Fisiere**: `app/web/templates/base.html` (script inline anti-FOUC in `<head>` + buton toggle in
`<header>` + handler de comutare/persistenta), `tests/test_tema.py` (extins, acelasi fisier ca US-001)
- **Test intai (RED)**: `tests/test_tema.py`
- `test_script_antifouc_in_head_inainte_de_style``<head>` contine un `<script>` care citeste
`localStorage` cheia `theme` si seteaza `document.documentElement` `data-theme` **inainte** de
tag-ul `<style>` (pozitie in HTML: index script < index `<style>`).
- `test_buton_toggle_in_header_cu_eticheta` `<header>` contine un control de comutare cu
`aria-label`/`title` descriptiv (ex. "Comuta tema") si un `id`/atribut stabil pentru handler.
- `test_toggle_pe_login_si_dashboard` butonul apare si pe `/login` (neautentificat) si pe dashboard
(ambele extind `base.html`).
- **Acceptance criteria**:
- [ ] Script inline in `<head>`, plasat **inaintea** `<style>`, citeste preferinta:
`localStorage.theme` daca exista, altfel `prefers-color-scheme` (fallback final: `dark`), si
seteaza `data-theme` pe `<html>` sincron **fara FOUC** la incarcare/reload.
- [ ] Buton de comutare in `<header>` (langa `env`/`version`), cu `aria-label` descriptiv, atins
usor (≥ 36px zona de atins), care comuta `data-theme` lightdark **fara reload**.
- [ ] La comutare se scrie `localStorage.theme` alegerea persista peste reload si peste navigari
(deep-link `?tab=`), inclusiv pe paginile neautentificate (login/signup).
- [ ] Butonul reflecta starea curenta (eticheta/iconita arata ce face: "→ light" cand e dark si
invers), accesibil la tastatura (e un `<button>`).
- [ ] Functioneaza pe toate cele 4 pagini top-level (login, signup, dashboard, admin) toate extind
`base.html`, deci o singura implementare le acopera.
- [ ] `python3 -m pytest -q` verde.
- **Verificare E2E** (Playwright MCP / `/browse`): pe `/login` apoi pe dashboard (a) click toggle
paleta comuta instant (fundal/text), (b) reload pagina tema aleasa **persista** (citeste din
localStorage, fara flash de tema veche), (c) comuta inapoi persista invers, (d) zero erori in
consola, (e) fara FOUC vizibil la reload (tema corecta din primul frame).
## 4. Riscuri
- **FOUC** (risc principal) mitigat prin scriptul inline din `<head>` ASAMBLAT INAINTE de `<style>`,
care seteaza `data-theme` sincron, pre-paint. Verificat E2E (reload nu clipeste).
- **Suprafete hardcodate raman dark in light** (banner/flash cu hex fix) mitigat de US-001 (asertie
de test pe absenta literalilor + theme-aware via variabile/`color-mix`).
- **Contrast slab in light** (text gri pe alb, accent palid) mitigat: AC explicit WCAG AA pe
`--ink`/`--muted` peste `--bg`/`--card`; verificare E2E vizuala (text lizibil).
- **Regresie pe dark** (refactor accidental al paletei existente) mitigat: `test_dark_ramane_default`
lock-uieste hex-urile dark exacte in `:root`; Non-Goal explicit "dark identic la octet".
- **localStorage indisponibil** (mod privat strict / dezactivat) script defensiv: `try/catch` in
jurul citirii/scrierii; cade pe default (dark/OS) fara sa arunce. (AC implicit: zero erori consola.)
- **Acelasi fisier in 2 stories** (`base.html`) NU se paralelizeaza; un singur worker secvential
(US-001 apoi US-002). Notat in §6.
## 5. Intrebari deschise
> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
- **Default pentru utilizator nou (fara preferinta salvata)** REZOLVAT: **OS-aware cu fallback dark**
(onoreaza `prefers-color-scheme`; cand OS-ul nu cere light `dark`, look-ul actual). [user 2026-06-22]
- **Persistenta** REZOLVAT: **`localStorage`** (client-only, anti-FOUC prin script in `<head>`, zero
backend; nu atinge `routes.py`/sesiune). [user 2026-06-22]
- **Aspect comutator** REZOLVAT: **iconita soare/luna + `aria-label`** descriptiv, compact in header.
[user 2026-06-22]
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] → [US-002] ← SECVENTIAL (acelasi fisier base.html). Un singur worker (sau lead direct).
```
Livrabila mica, un singur fisier de productie atins (`base.html`): poate rula fara `TeamCreate`
(un worker Sonnet TDD, ambele stories secvential). VERIFY in context curat + writeback raman
obligatorii (ROADMAP §5.5).
## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
> Se completeaza la PLAN inainte de aprobare. CEO + Eng obligatorii; Design — DA (atinge UI).
**CEO (valoare/scope) — PASS.** Cerinta directa de ergonomie din Etapa 5 (decizie utilizator
2026-06-22), efort mic peste o fundatie deja pregatita (CSS pe variabile). Calea cea mai scurta:
reuse `:root` + override `[data-theme]`, zero backend. Inversiune ("ce-l face inutil?"): FOUC la
incarcare (face produsul sa para buggy) neutralizat prin scriptul anti-FOUC din `<head>`; si
suprafetele hardcodate care raman dark in light (arata stricat) neutralizate prin theme-aware in
US-001. Scope minim corect (doua teme, fara personalizare). Niciun scope creep.
**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, un singur fisier de productie, zero
atingere de backend/schema/worker. Testele acopera contractul de template (paleta light prezenta,
dark neschimbat, suprafete theme-aware, script anti-FOUC pozitionat corect, toggle prezent +
accesibil + pe paginile neautentificate); comportamentul vizual + persistenta + anti-FOUC raman pe
E2E browser (corect nu se pot prinde la TestClient). Risc unic real = FOUC, prins doar in browser
E2E explicit cu reload. `localStorage` defensiv (try/catch) acoperit ca AC zero-erori.
**Design — PASS (cu note).** Atinge UI direct. Paleta light trebuie sa respecte contrast WCAG AA
(AC explicit). Comutatorul: iconita soare/luna + `aria-label`, plasat in header langa env/version,
zona de atins 36px (consistent cu `.cardlink` existent). Suprafetele semantice (ok/warn/err) sa
ramana distincte si lizibile pe light, nu doar inversate. Tranzitia de comutare poate fi instant
(fara animatie) ca sa nu para lenta; daca se adauga `transition` pe culori, scurta (≤ 150ms, ca
restul UI-ului). De confirmat vizual la VERIFY E2E.
---
## Raport VERIFY
> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
### Runda 1 (2026-06-22) — FAIL
Verificator independent (context curat, rol qa-only). **VERDICT: FAIL** (un blocker, US-001).
- **Suita PASS.** `python3 -m pytest -q` 583 passed; `tests/test_tema.py` 6 passed.
- **US-002 (toggle + persistenta + anti-FOUC) PASS integral (E2E browser).** Toggle pe
login/signup/dashboard; comutare instant (bg `#f6f7f9``#0f1115`, aria-label + iconita comuta);
persistenta `localStorage.theme` doar la click; reload pastreaza tema fara FOUC; OS-aware fara
scriere (load simplu `localStorage.theme`=null); zero erori consola (doar 404 favicon preexistent).
- **US-001 (paleta light + suprafete theme-aware) FAIL.** Paleta light + dark-neschimbat + `base.html`
color-mix = PASS, DAR suprafetele de stare raman pete intunecate cu **text invizibil** in light:
literalii dark `#241a1a`/`#201c0f` NU au fost convertiti in **fragmentele HTMX** (10 aparitii, 7
fisiere: `_status.html`, `_banner.html`, `_upload.html`, `_preview_import.html`, `_preview_rand.html`,
`_trimitere_detaliu.html`, `_mapcoloane.html`) inline-style, randate in dashboard. Dovada: banner
"Cont in asteptare de activare" = cutie `rgb(32,28,15)` pe fundal `#f6f7f9`. Testul `test_suprafete_
fara_fundal_hardcodat` scana DOAR `<style>` din base.html verde inselator (vacuu).
- **Regresie de aur PASS.** Pur frontend; worker/coada/API/schema neatinse (worker a procesat o
orfana `sent idPrezentare=68801` la pornire pipeline send functional, neafectat de 5.3).
**Remediu (US-003, adaugat in §3):** muta literalii la `color-mix` in cele 7 fragmente + extinde testul
sa scaneze fragmentele. Re-VERIFY cu subagent NOU dupa fix.
### Runda 2 (2026-06-22) — PASS
Verificator independent NOU (context curat, rol qa-only), dupa fix US-003. **VERDICT GLOBAL: PASS.**
- **Suita PASS.** `python3 -m pytest -q` 584 passed; `tests/test_tema.py` 7 passed (incl. noul
`test_fragmente_fara_fundal_hardcodat`).
- **Anti-regresie protectie PASS.** `grep -rn -E '#201c0f|#241a1a|#16241c' app/web/templates/`
GOL. Testul scaneaza fisierele `_*.html` de pe disc (`Path.glob`), nu doar base.html lacuna r1 inchisa.
- **US-001/US-003 (light lizibil) PASS (E2E, dovada cheie).** Cont inactive fortat banner
"Cont in asteptare de activare" din `_status.html` in light: `background` `rgb(246,234,225)` (crema
deschis), text `rgb(26,29,36)` (contrast ~13:1 AA), border amber. Cutia neagra `rgb(32,28,15)` din
r1 a DISPARUT. Screenshot confirma caseta peach cu text negru lizibil. Scan al tuturor suprafetelor
vizibile in light: zero zone genuin intunecate. Dark mode: aspect practic neschimbat.
- **US-002 PASS (re-confirmat).** Toggle instant; `localStorage.theme` doar la click; aria-label se
inverseaza; persista peste reload in ambele sensuri; anti-FOUC (script in `<head>` inainte de `<style>`);
toggle pe `/login` + dashboard; zero erori JS de tema in consola.
- **Regresie de aur PASS.** Diff strict frontend (`base.html` + 7 fragmente + `tests/test_tema.py`);
worker/coada/API/schema/mapping NEATINSE. Flux LIVE RAR catre RAR test NEPROBAT (creds key efemera;
send neatins de 5.3) NEPROBAT, nu FAIL.
- **Nota cleanup:** verificatorul a lasat un cont de test inactive (id=5, verifyr2@test.com) in DB-ul
de test efemer folosit pentru a forta bannerul inofensiv (date de test, mediu test).
### CLOSE — `/code-review` high (2026-06-22)
1 finding real reparat: in paleta light `--ok:#16a34a` (green-600) folosit ca text (`.s-sent`/`.s-ok`,
bife verzi din bara de stare) pe `--card:#ffffff` dadea contrast ~3.3:1 sub WCAG AA 4.5:1 pentru text
mic, incalcand AC-ul US-001. Reparat `--ok:#15803d` (green-700, ~5.0:1, trece AA); paleta dark
neatinsa. Restul semanticelor light trec deja AA (--err 6.2:1, --warn 5.0:1, --accent 5.1:1). Schimbare
de valoare CSS (fara comportament) fara re-VERIFY. Refutate: suport `color-mix` (universal pe browsere
moderne, audienta B2B); duplicarea inline a `color-mix` in fragmente (precede acest diff, candidat de
cleanup viitor macro/clase `.flash.err`/`.flash.warn`). 584 teste pass.

186
tests/test_tema.py Normal file
View File

@@ -0,0 +1,186 @@
"""Teste US-001 + US-002 (PRD 5.3): Light/Dark mode comutator tema.
TDD: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
"""
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tema.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _create_user(email: str = "tema@test.com", password: str = "parolasecreta"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Tema", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in /login"
resp = client.post(
"/login",
data={"email": email, "parola": password, "csrf_token": m.group(1)},
follow_redirects=False,
)
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
# ── US-001: Tema light ─────────────────────────────────────────────────────────
def test_paleta_light_definita(client):
"""HTML de la GET /login contine un selector [data-theme="light"] care redefineste
cel putin --bg, --card, --ink, --muted, --line."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert '[data-theme="light"]' in html, 'Lipseste blocul [data-theme="light"] in HTML'
light_block = re.search(r'\[data-theme=["\']light["\']\]\s*\{([^}]+)\}', html, re.DOTALL)
assert light_block, 'Nu am gasit blocul CSS [data-theme="light"] { ... }'
block = light_block.group(1)
for var in ("--bg", "--card", "--ink", "--muted", "--line"):
assert var in block, f"Variabila {var} lipseste din blocul [data-theme=\"light\"]"
def test_dark_ramane_default(client):
""":root contine inca paleta dark exacta: --bg:#0f1115, --card:#181b22, --ink:#e6e9ef."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--bg:#0f1115" in html, "Paleta dark --bg:#0f1115 a fost modificata sau stearsa"
assert "--card:#181b22" in html, "Paleta dark --card:#181b22 a fost modificata sau stearsa"
assert "--ink:#e6e9ef" in html, "Paleta dark --ink:#e6e9ef a fost modificata sau stearsa"
def test_suprafete_fara_fundal_hardcodat(client):
"""<style> NU mai contine literalii hex dark-fix #241a1a, #201c0f, #16241c
(banner eroare / banner warn / flash facute theme-aware)."""
resp = client.get("/login")
assert resp.status_code == 200
style_match = re.search(r'<style>(.*?)</style>', resp.text, re.DOTALL)
assert style_match, "<style> negasit in HTML"
style = style_match.group(1)
assert "#241a1a" not in style, "Fundalul hardcodat #241a1a (banner eroare) inca in <style>"
assert "#201c0f" not in style, "Fundalul hardcodat #201c0f (banner warn) inca in <style>"
assert "#16241c" not in style, "Fundalul hardcodat #16241c (flash) inca in <style>"
# ── US-002: Comutator tema + anti-FOUC ────────────────────────────────────────
def test_script_antifouc_in_head_inainte_de_style(client):
"""<head> contine un <script> care citeste localStorage (cheia 'theme') si seteaza
data-theme pe document.documentElement, pozitionat INAINTE de <style>."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
head_match = re.search(r'<head>(.*?)</head>', html, re.DOTALL | re.IGNORECASE)
assert head_match, "<head> negasit in HTML"
head = head_match.group(1)
style_pos = head.find('<style>')
assert style_pos >= 0, "<style> negasit in <head>"
head_before_style = head[:style_pos]
assert 'localStorage' in head_before_style, \
"Scriptul anti-FOUC (cu localStorage) trebuie sa fie in <head> INAINTE de <style>"
assert 'theme' in head_before_style, \
"Scriptul anti-FOUC trebuie sa citeasca cheia 'theme' din localStorage"
assert ('data-theme' in head_before_style or 'dataset.theme' in head_before_style), \
"Scriptul anti-FOUC trebuie sa seteze data-theme pe documentElement"
def test_buton_toggle_in_header_cu_eticheta(client):
"""<header> contine un <button> de comutare cu aria-label descriptiv (contine 'tema')."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
header_match = re.search(r'<header>(.*?)</header>', html, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit in HTML"
header = header_match.group(1)
labels = re.findall(r'<button[^>]*aria-label=["\']([^"\']+)["\']', header, re.IGNORECASE)
assert labels, "<button> cu aria-label negasit in <header>"
assert any('tema' in lbl.lower() for lbl in labels), \
f"Niciun <button> in <header> cu aria-label care contine 'tema'. Gasit: {labels}"
def test_toggle_pe_login_si_dashboard(client):
"""Butonul toggle apare atat pe /login (neautentificat) cat si pe dashboard (autentificat)."""
# Pe /login
resp = client.get("/login")
assert resp.status_code == 200
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit pe /login"
assert re.search(
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
header_match.group(1),
re.IGNORECASE,
), "Butonul toggle lipseste pe /login"
# Pe dashboard (autentificat)
_create_user("tema_dash@test.com", "parolasecreta")
_login(client, "tema_dash@test.com", "parolasecreta")
resp = client.get("/")
assert resp.status_code == 200
header_match = re.search(r'<header>(.*?)</header>', resp.text, re.DOTALL | re.IGNORECASE)
assert header_match, "<header> negasit pe dashboard"
assert re.search(
r'<button[^>]*aria-label=["\'][^"\']*tema[^"\']*["\']',
header_match.group(1),
re.IGNORECASE,
), "Butonul toggle lipseste pe dashboard"
# ── US-003: Fragmente HTMX fara fundal hardcodat ──────────────────────────────
def test_fragmente_fara_fundal_hardcodat():
"""Niciun fisier _*.html din app/web/templates/ nu contine literalii hex dark-fix
#241a1a, #201c0f, #16241c (suprafete banner eroare / warn / flash)."""
templates_dir = Path(__file__).parent.parent / "app" / "web" / "templates"
fragmente = sorted(templates_dir.glob("_*.html"))
assert fragmente, f"Nu am gasit fragmente _*.html in {templates_dir}"
vinovate = []
for f in fragmente:
continut = f.read_text(encoding="utf-8")
for literal in ("#241a1a", "#201c0f", "#16241c"):
if literal in continut:
vinovate.append(f"{f.name}: {literal}")
assert not vinovate, (
"Fragmente cu fundal hardcodat dark (nu adapteaza la tema light):\n"
+ "\n".join(vinovate)
)