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 %} {% 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"> hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Cont in asteptare de activare.</strong> <strong>Cont in asteptare de activare.</strong>
Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin. Configureaza creds RAR si pregateste importul ACUM; trimiterea catre RAR porneste automat dupa activare de catre admin.

View File

@@ -7,7 +7,7 @@
</h2> </h2>
{% if message %} {% 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 %}> {% if error %}role="alert"{% endif %}>
{{ message }} {{ message }}
</div> </div>

View File

@@ -11,7 +11,7 @@
</div> </div>
{% if message %} {% 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 %}> {% if error %}role="alert"{% endif %}>
{{ message }} {{ message }}
</div> </div>
@@ -56,7 +56,7 @@
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) --> <!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
{% if unmapped_ops %} {% 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> <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;"> <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 Aceste operatii din fisier nu au inca un cod RAR. Alege codul (sugestia e

View File

@@ -31,12 +31,12 @@
</div> </div>
{% if message %} {% 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> role="alert">{{ message }}</div>
{% endif %} {% endif %}
<div class="rand-eroare-banner" role="alert" <div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err); 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. Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div> </div>

View File

@@ -6,7 +6,7 @@
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) --> <!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %} {% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn); <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> <strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator. 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> <h3 style="font-size:14px; margin:0 0 8px;">Corecteaza si re-trimite</h3>
{% if corectie_msg %} {% 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> {% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
{% endif %} {% endif %}

View File

@@ -9,12 +9,12 @@
{% endif %} {% endif %}
{% if error %} {% 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> role="alert">{{ error }}</div>
{% endif %} {% endif %}
{% if sheets %} {% 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. Fisierul are mai multe foi de lucru. Alege foaia de mai jos si incarca din nou.
</div> </div>
{% endif %} {% endif %}

View File

@@ -13,9 +13,26 @@
// useTemplateFragments parseaza tot intr-un <template>, pastrand rand + OOB impreuna. // useTemplateFragments parseaza tot intr-un <template>, pastrand rand + OOB impreuna.
htmx.config.useTemplateFragments = true; htmx.config.useTemplateFragments = true;
</script> </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> <style>
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; :root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; } --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; } * { box-sizing:border-box; }
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; 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; } 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; } 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; } 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; } .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; } .banner.hidden { display:none; }
/* Tabelele de date au multe coloane; pe ecrane inguste scroll IN card, nu /* Tabelele de date au multe coloane; pe ecrane inguste scroll IN card, nu
impinge layout-ul paginii (altfel toata pagina scrolleaza orizontal). */ impinge layout-ul paginii (altfel toata pagina scrolleaza orizontal). */
@@ -46,7 +63,7 @@
text-align:center; transition:border-color .15s,background .15s; } text-align:center; transition:border-color .15s,background .15s; }
.drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); } .drop-zone.drag-over { border-color:var(--accent); background:rgba(91,141,239,.05); }
/* Banner varianta warn (nu eroare) */ /* 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 */ /* Bara confirmare sticky */
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line); .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; 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; .cardlink { font-size:13px; padding:7px 10px; border-radius:6px; display:inline-flex;
align-items:center; min-height:36px; white-space:nowrap; } align-items:center; min-height:36px; white-space:nowrap; }
.cardlink:hover { background:var(--line); } .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; } margin:0 0 12px; font-size:13px; }
.maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line); .maprow { display:flex; gap:16px; align-items:center; padding:12px 0; border-bottom:1px solid var(--line);
flex-wrap:wrap; } flex-wrap:wrap; }
@@ -92,8 +109,48 @@
<header> <header>
<h1>Gateway RAR AUTOPASS</h1> <h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span> <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> </header>
<main>{% block content %}{% endblock %}</main> <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> </body>
</html> </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)
)