diff --git a/CHANGELOG.md b/CHANGELOG.md
index 901b533..381cbc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
## Changelog
+### v0.127.1 — escrow wizard polish: CTA visibility + Hungarian preflight + typed-back highlight + blur-on-verify (2026-07-13)
+
+Four findings from Viktor's first supervised wizard passes (drill + demo). Presentation-only —
+no endpoint, agent call, or state change; §10 red-proofs N/A.
+
+- **Escrowed-card CTA** (`backups_remote.html`): "Új helyreállítási kód készítése" is a real
+ `btn btn-sm btn-outline` secondary button (was an inline link inside the muted hint — nearly
+ invisible). Outline, NOT primary: escrowed is healthy, the CTA is available-not-urgent (the
+ stale variant stays primary). Render-tested.
+- **Hungarian preflight details** (`backups_escrow.html` `pfDetail`): the agent's operator-English
+ `detail` strings no longer leak into the customer UI. OK rows keep only VALUE details (storage
+ id, age path; `staged_secret` → "előkészítve"); boolean-OK rows (dr_tier/hub_upload/sudo_grant)
+ render no detail; not-OK rows get the Hungarian explanation + the raw agent detail as a muted
+ diagnostic span; unknown ids fall back to the raw detail (never blank a failure);
+ `staged_secret` keeps its informational dot. Render test pins the not-staged copy + asserts the
+ three English literals never appear in the page source.
+- **Typed-back highlight**: the code renders as span-per-word (createElement + textContent +
+ createTextNode ONLY — R still never flows through innerHTML; no parse context = no injection
+ surface); the two verification words get `var(--warn)` + 600 weight so they're findable on
+ paper. `verifyIdx` is now chosen BEFORE rendering. `finishWizard`'s `textContent=''` clears the
+ spans (child-node replacement — verified).
+- **Blur-on-verify** (Part 4, optional — implemented; trivial to strike): first focus on either
+ verification input blurs the code (`filter: blur(6px)`, inline-style toggle — no style.css
+ change, no cache-bust) + a `btn-ghost` "Megjelenítés"/"Elrejtés" toggle. The typed-back now
+ exercises the WRITTEN copy, not screen transcription. R stays in the JS closure.
+
+JS behavior (spans/blur) is review-covered; the visual leg awaits Viktor's next login. All four
+UI gates green.
+
### v0.127.0 — customer-facing escrow ceremony wizard + stale-blob re-check (2026-07-13) — MinAgent: 0.88.0 (wizard only; everything else unchanged)
The missing friend-alpha piece: the recovery-code ceremony moves from operator-SSH to a
diff --git a/controller/internal/web/escrow_wizard_test.go b/controller/internal/web/escrow_wizard_test.go
index 948fb92..3323b58 100644
--- a/controller/internal/web/escrow_wizard_test.go
+++ b/controller/internal/web/escrow_wizard_test.go
@@ -325,6 +325,10 @@ func TestEscrowTemplates_Render(t *testing.T) {
"Ez a kód többé nem jeleníthető meg.",
"nem ezen a szerveren",
"biztonsági okból újra nem kérhető le",
+ // v0.127.1 polish: the Hungarian preflight detail map + the blur-on-verify toggle.
+ "nincs előkészített jelszó — a varázsló indításkor automatikusan előkészíti",
+ "a rendszerjogosultság hiányzik",
+ "Megjelenítés",
} {
if !strings.Contains(html, want) {
t.Errorf("wizard missing %q", want)
@@ -333,6 +337,13 @@ func TestEscrowTemplates_Render(t *testing.T) {
if strings.Contains(html, "érvényét veszti") {
t.Error("fresh wizard must not show the re-ceremony supersede warning")
}
+ // v0.127.1: the agent's operator-English detail strings must never be hardcoded into the
+ // page — they may only arrive at runtime, inside the muted failure-diagnostic span.
+ for _, raw := range []string{"DR tier applied", "hub upload target configured", "sudo grant listed"} {
+ if strings.Contains(html, raw) {
+ t.Errorf("raw English agent detail %q leaked into the wizard page source", raw)
+ }
+ }
})
t.Run("wizard re-ceremony variant", func(t *testing.T) {
d := base()
@@ -360,6 +371,11 @@ func TestEscrowTemplates_Render(t *testing.T) {
if !strings.Contains(html, "Új helyreállítási kód készítése") {
t.Error("escrowed card must offer the secondary re-ceremony link")
}
+ // v0.127.1: the CTA is a real secondary BUTTON (outline — available-not-urgent), no longer
+ // an inline link buried in the muted hint.
+ if !strings.Contains(html, `class="btn btn-sm btn-outline">Új helyreállítási kód készítése`) {
+ t.Error("escrowed-card CTA must render as a btn-outline button")
+ }
if strings.Contains(html, "Letét megerősítése") {
t.Error("the deprecated manual-confirm button must be GONE from the card")
}
diff --git a/controller/internal/web/templates/backups_escrow.html b/controller/internal/web/templates/backups_escrow.html
index 72872f2..2e7bbad 100644
--- a/controller/internal/web/templates/backups_escrow.html
+++ b/controller/internal/web/templates/backups_escrow.html
@@ -71,12 +71,13 @@
Ez a kód többé nem jeleníthető meg.
+
6. Ellenőrzés
-
-
+
+
A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.
@@ -112,6 +113,27 @@ var PF_LABELS = {
sudo_grant: 'Rendszerjogosultság engedélyezve'
};
+// Hungarian DETAIL mapping (v0.127.1): the agent's `detail` strings are operator-English by
+// design — presentation is the controller's job. OK rows keep only VALUE-bearing details
+// (storage id / binary path); boolean-OK rows render no detail at all. Not-OK rows get the
+// Hungarian explanation; unknown/future ids fall back to the raw detail (never blank a failure).
+var PF_FAIL_DETAILS = {
+ pbs_storage_id: 'nincs beállítva mentési tároló',
+ age_binary: 'a titkosító eszköz (age) nem található',
+ dr_tier: 'a DR-szint nincs alkalmazva ezen a gépen',
+ hub_upload: 'a központi feltöltés nincs beállítva',
+ sudo_grant: 'a rendszerjogosultság hiányzik — az ügynök frissítése szükséges',
+ staged_secret: 'nincs előkészített jelszó — a varázsló indításkor automatikusan előkészíti, ha a távoli mentés be van állítva'
+};
+function pfDetail(it){
+ if(it.ok){
+ if(it.id==='pbs_storage_id' || it.id==='age_binary'){ return it.detail||''; } // the VALUE
+ if(it.id==='staged_secret'){ return 'előkészítve'; }
+ return ''; // dr_tier / hub_upload / sudo_grant: the check mark + label is complete
+ }
+ return PF_FAIL_DETAILS[it.id] || (it.detail||'');
+}
+
async function loadPreflight(){
try{
var r = await fetch('/api/escrow/preflight'); var j = await r.json();
@@ -125,9 +147,13 @@ async function loadPreflight(){
var mark = it.ok ? '✓'
: (informational ? '•'
: '✗');
- html+='
';
+ var det = pfDetail(it);
+ var line = mark+' '+esc(PF_LABELS[it.id]||it.id);
+ if(det){ line += ' — '+esc(det)+''; }
+ // On failure keep the agent's raw detail as a muted diagnostic (English acceptable there) —
+ // unless the fallback above already IS the raw detail (unknown id).
+ if(!it.ok && it.detail && det!==it.detail){ line += ' ('+esc(it.detail)+')'; }
+ html+='
'+line+'
';
});
if(!d.agent_supported){
html+='
✗ A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.
';
@@ -202,18 +228,49 @@ async function claimCode(){
if(r.status===410){ document.getElementById('reveal-card').style.display='none'; document.getElementById('void-card').style.display='block'; return; }
if(!j.ok || !j.data || !j.data.recovery_code){ errEl.textContent=j.error||'A kód lekérése nem sikerült.'; errEl.style.display='block'; btn.disabled=false; return; }
claimedR=j.data.recovery_code;
- document.getElementById('code-block').textContent=claimedR; // textContent — never innerHTML
- document.getElementById('reveal-gate').style.display='none';
- document.getElementById('reveal-shown').style.display='block';
- // Typed-back: two random distinct 1-based word positions of the dash-separated code.
- var n=claimedR.split('-').length;
+ // Typed-back positions FIRST (v0.127.1: the rendering below highlights them): two random
+ // distinct 1-based word positions of the dash-separated code.
+ var words=claimedR.split('-');
+ var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
+ // Span-per-word DOM build — createElement + textContent + createTextNode ONLY. R never flows
+ // through innerHTML/insertAdjacentHTML: with no HTML parse context there is no injection
+ // surface, the same guarantee the old single textContent assignment gave. The two words the
+ // customer must type back get the warn highlight so they can be found on paper too.
+ var block=document.getElementById('code-block');
+ block.textContent='';
+ words.forEach(function(w,i){
+ if(i>0){ block.appendChild(document.createTextNode('-')); }
+ var sp=document.createElement('span');
+ sp.textContent=w;
+ if(i+1===verifyIdx[0] || i+1===verifyIdx[1]){ sp.style.color='var(--warn)'; sp.style.fontWeight='600'; }
+ block.appendChild(sp);
+ });
+ document.getElementById('reveal-gate').style.display='none';
+ document.getElementById('reveal-shown').style.display='block';
document.getElementById('verify-label').textContent='Megerősítés: írja be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; }
}
+// Blur-on-verify (v0.127.1, Part 4): the typed-back should exercise the WRITTEN copy, not screen
+// transcription — the first focus on a verification input blurs the code and offers a local
+// show/hide toggle. R stays in the JS closure throughout; the blur is pure presentation.
+var codeBlurred=false, blurArmed=false;
+function armCodeBlur(){
+ if(blurArmed){ return; }
+ blurArmed=true;
+ setCodeBlur(true);
+ document.getElementById('code-blur-toggle').style.display='inline-block';
+}
+function setCodeBlur(on){
+ codeBlurred=on;
+ document.getElementById('code-block').style.filter=on?'blur(6px)':'';
+ document.getElementById('code-blur-toggle').textContent=on?'Megjelenítés':'Elrejtés';
+}
+function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
+
function verifyWords(){
var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
@@ -224,7 +281,8 @@ function verifyWords(){
}
function finishWizard(){
- // Drop every copy of the code this page holds, then leave.
+ // Drop every copy of the code this page holds, then leave. (Assigning textContent replaces ALL
+ // child nodes with one empty text node — the per-word spans are removed with it, verified.)
claimedR='';
document.getElementById('code-block').textContent='';
window.location='/backups/remote';
diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html
index 4d8b67f..384de4b 100644
--- a/controller/internal/web/templates/backups_remote.html
+++ b/controller/internal/web/templates/backups_remote.html
@@ -77,7 +77,12 @@
{{end}}
+ {{/* v0.127.1: the re-ceremony CTA is a real secondary button (outline, NOT primary — escrowed
+ is a healthy state; available-not-urgent, unlike the stale variant above). */}}
+