diff --git a/AGENTS.md b/AGENTS.md
index f4ad4e2..23007a2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,9 +20,9 @@ sursa de adevăr tehnică pentru agenți.
python3 -m http.server 8000
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
-npx playwright test tests/smoke.mjs # suita completă: 28/28
+npx playwright test tests/smoke.mjs # suita completă: 29/29
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
-npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 14
+npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 15
```
## Durable Rules (repo-wide)
diff --git a/DESIGN.md b/DESIGN.md
index 91c4afb..8c3720e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -231,7 +231,10 @@ Mesajul creatorului
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
-> Implementare în T10/PR2.
+> **LIVRAT** (2026-06-13). Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără).
+> Implementare: `#chrome-timer` în bara chrome; `startTimer/tickTimer/stopTimer`; deadline absolut
+> în `sessionStorage` (`_DEADLINE_KEY`). Sub 1 min → `.low` (auriu); expirat → `.expired` (auriu, opac).
+> Test smoke „timer calm" (format, gold, freeze, resume păstrează ceasul).
- Pornește **exact** la click „Începe aventura" (intro necronometrat)
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)
diff --git a/TODOS.md b/TODOS.md
index 6234007..014958a 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -18,7 +18,7 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2
- [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26.
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
-Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 LIVRAT — vezi §dedicată mai jos.)
+Rămas din Etapa 2: muzică timer (T10) + Adventure Mode v0. (D7 + Timer Calm LIVRATE — vezi §§ mai jos.)
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
@@ -96,7 +96,17 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială)
## Post-PR1 (după ship-ul campaniei)
-### Muzică accelerată la timer (PR2 / T10)
+### [x] Timer Calm (§Design pct.10 / T10) — LIVRAT (2026-06-13)
+Ceas M:SS în bara chrome a campaniei. Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără;
+`cleanState` coerce la întreg 0..120). Pornește la „Începe aventura" (intro necronometrat); deadline
+ABSOLUT în `sessionStorage` (`_DEADLINE_KEY`) → resume-ul (reload mid-campanie) NU resetează ceasul.
+Sub 1 minut → auriu (`.low`); la expirare îngheață pe `0:00` + marcaj discret (`.expired`, auriu opac),
+jocul curge nestingherit (zero penalizare, stelele rămân). Fără roșu pulsant (public copii) → reduced-motion
+safe by default. `exemplu-campanie.html` regenerat (rămâne fără timer — opt-in, ca vocea). Verificat:
+smoke 29/29 (test nou „timer calm": format M:SS, prag auriu, freeze la expirare, jocul continuă, resume
+păstrează ceasul). Commit: (acest commit). Următorul: muzică T10 (accelerare sub 1 min — depinde de timer).
+
+### Muzică accelerată la timer (PR2 / T10) — depinde de Timer Calm (LIVRAT)
- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut.
- Ownership: părintele deține AudioContext; camerele nu știu de muzică.
- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve).
diff --git a/escape-builder.html b/escape-builder.html
index 2e9ecd0..2c819aa 100644
--- a/escape-builder.html
+++ b/escape-builder.html
@@ -135,6 +135,9 @@
+
+
+
Pornesti la "Incepe aventura". Sub 1 minut devine auriu. La expirare ingheata pe 0:00 — jocul continua, fara penalizare.
Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.
@@ -176,6 +179,7 @@ const defaultState = () => ({
style: 'classic',
charName: 'Alex',
voice: false,
+ timerMin: 0,
story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.',
finalMessage: 'Felicitari! Ai gasit comoara!',
puzzles: [
@@ -407,6 +411,7 @@ $('#btnReload').addEventListener('click', refreshPreview);
function cleanState() {
const s = JSON.parse(JSON.stringify(state));
+ s.timerMin = Math.max(0, Math.min(120, parseInt(s.timerMin, 10) || 0)); /* T10: minute întregi 0..120 */
s.puzzles.forEach(p => {
delete p._closed;
/* D13: letter normalizat la 1 caracter alfanumeric (bug: un < strică scena SVG din point) */
@@ -1621,6 +1626,14 @@ body {
}
#chrome-title { font-size: 15px; font-weight: 700; }
#chrome .sp { flex: 1; }
+/* Timer Calm (§Design pct.10) — neutru; auriu sub 1 min; înghețat la expirare (fără roșu pulsant) */
+#chrome-timer {
+ font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px;
+ color: var(--c-ink); letter-spacing: .02em; min-width: 3.1em; text-align: right;
+}
+#chrome-timer[hidden] { display: none; }
+#chrome-timer.low { color: var(--c-gold); }
+#chrome-timer.expired { color: var(--c-gold); opacity: .55; }
#btn-voice {
width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer;
border-radius: 8px; background: rgba(255,255,255,.12); color: #fff;
@@ -1788,6 +1801,7 @@ body {
@media (max-width: 599px) {
#chrome { height: 40px; min-height: 40px; }
#chrome-title { font-size: 13px; }
+ #chrome-timer { font-size: 13px; }
#dots span { width: 8px; height: 8px; }
}
@@ -1797,6 +1811,7 @@ body {
${esc(cfg.title)}
+
0:00
@@ -1883,7 +1898,38 @@ function safeSet(v){ try{ sessionStorage.setItem(_RESUME_KEY, JSON.stringify(v))
function saveProgress(){
safeSet({ idx: activeIdx, totalStars: totalStars, collected: collected.slice(), skipped: skipped });
}
-function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); }catch(e){} }
+function clearProgress(){ try{ sessionStorage.removeItem(_RESUME_KEY); sessionStorage.removeItem(_DEADLINE_KEY); }catch(e){} }
+
+/* ----- Timer Calm (§Design pct.10) — ceas M:SS în chrome.
+ Pornește la „Începe aventura"; deadline ABSOLUT în sessionStorage → resume-ul
+ (reload mid-campanie) NU resetează ceasul. Sub 1 min → auriu. La expirare îngheață
+ pe 0:00 + marcaj discret, jocul curge nestingherit (zero penalizare). ----- */
+var TIMER_SEC = (+MASTER.timerMin || 0) * 60;
+var _DEADLINE_KEY = _RESUME_KEY + '-dl';
+var timerEl = document.getElementById('chrome-timer');
+var _deadline = 0, _timerInt = null, _timerExpired = false;
+function _fmt(s){ var m = Math.floor(s/60), ss = s % 60; return m + ':' + (ss < 10 ? '0' : '') + ss; }
+function tickTimer(){
+ if(!_deadline){ return; }
+ var rem = Math.round((_deadline - Date.now()) / 1000);
+ if(rem <= 0){
+ rem = 0;
+ if(!_timerExpired){ _timerExpired = true; timerEl.classList.add('expired'); timerEl.title = 'Timpul a expirat — jocul continua'; }
+ if(_timerInt){ clearInterval(_timerInt); _timerInt = null; }
+ }
+ timerEl.textContent = _fmt(rem);
+ if(rem <= 60) timerEl.classList.add('low');
+}
+function stopTimer(){ if(_timerInt){ clearInterval(_timerInt); _timerInt = null; } }
+function startTimer(){
+ if(TIMER_SEC <= 0) return;
+ timerEl.hidden = false;
+ var existing = 0; try{ existing = +sessionStorage.getItem(_DEADLINE_KEY) || 0; }catch(e){}
+ if(existing > 0){ _deadline = existing; } /* resume → păstrează ceasul */
+ else { _deadline = Date.now() + TIMER_SEC * 1000; try{ sessionStorage.setItem(_DEADLINE_KEY, String(_deadline)); }catch(e){} }
+ tickTimer();
+ if(!_timerInt && !_timerExpired) _timerInt = setInterval(tickTimer, 1000);
+}
var frameEl = document.getElementById('room-frame');
var introEl = document.getElementById('intro');
@@ -2112,6 +2158,7 @@ function showSkipBanner(idx, code, reason){
/* ----- Final ----- */
function showFinale(){
+ stopTimer(); /* jocul s-a încheiat — oprește ceasul */
hideAll(); finaleEl.classList.add('show');
var wEl = document.getElementById('fin-word'); wEl.innerHTML = '';
collected.forEach(function(l,j){
@@ -2280,6 +2327,7 @@ document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nSty
document.getElementById('btn-start').onclick = function(){
unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */
clearProgress(); owResetPlayer(); showOverworld(0);
+ startTimer(); /* ceasul pornește exact la „Începe aventura" (intro necronometrat) */
voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */
};
@@ -2321,6 +2369,7 @@ buildDots();
showFinale(); return;
}
owResetPlayer(); showOverworld(resumeIdx);
+ startTimer(); /* resume → reia ceasul de la deadline-ul absolut salvat */
})();
<\/script>