diff --git a/README.md b/README.md
index 39e1110..6042b6e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Dockwatch v9.5
+# Dockwatch v9.5.1
> Go module: `git.send.nrw/sendnrw/dockwatch`
@@ -6,6 +6,15 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc
The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`.
+## v9.5.1 UI / Compose editor fixes
+
+- Compose scalar edits no longer re-render the complete visual tree after every keystroke, so text/port fields keep focus while typing. Structural changes (add/remove/type changes) still re-parse the YAML AST.
+- Compose designer controls now use the application theme instead of browser-default white form controls in dark mode.
+- Stack container cards format Compose publisher JSON into readable port mappings and wrap long image/command values safely.
+- Docker inventory tables now have horizontal overflow handling, sensible column widths, compact label summaries, and a sticky action column so buttons are no longer squeezed off-screen.
+- Static assets are cache-busted to `v9.5.1`.
+
+
## v9.5 firewall-provider layer
- Host Security firewall management now uses a provider model: **Auto detect / UFW / firewalld / native nftables**
@@ -535,3 +544,9 @@ v9.3.2 fixes Docker/CI builds that stopped at `go: updates to go.mod needed; to
### Build-context note (v9.3.3)
Runtime directories in `.dockerignore` and `.gitignore` are root-anchored (`/stacks/`, `/data/`, `/bin/`, `/dist/`). This is intentional: unanchored patterns such as `stacks/` also match the source package `internal/stacks/` and can make Go try to resolve the project's own internal package as a remote module during `go mod tidy`.
+
+### v9.5.2 UI polish
+
+- Firewall rule editor uses a wide responsive modal and keeps long CIDRs, comments and provider output inside the dialog.
+- Security details and notices wrap long unbroken values safely.
+- Light mode now has consistent navigation, buttons, inputs, tables, tabs, code/editor surfaces, notices, modals and Host Security cards instead of inheriting dark-only hard-coded colors.
diff --git a/web/app.js b/web/app.js
index 16d8ff6..92f6478 100644
--- a/web/app.js
+++ b/web/app.js
@@ -62,7 +62,7 @@ async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#compose
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
-function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'
`}
function typeSwitcher(path,t){return ``}
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
-function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v)};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})}
+function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})}
function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
function composePatchPathKey(path){return JSON.stringify(path.map(String))}
-function queueComposePatch(path,value,del=false){const item={path,value,delete:del},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
-async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();await parseComposeVisual()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
+function queueComposePatch(path,value,del=false,opt={}){const item={path,value,delete:del,refresh:opt.refresh!==false},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
+function markComposeSynced(){const status=$('#composeSyncState');if(status){status.className='green';status.textContent=' YAML ↔ Designer synchronized'}renderComposeSections()}
+async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})}
function envTab(st){return ``}
function secretsTab(st){return `
Secret values are written with mode 0600. Use Compose secrets: with file: ./secrets/name. Existing values are visible only to operators who can edit the stack.
${(st.secrets||[]).map(secretRow).join('')}
`}
@@ -87,7 +88,9 @@ function secretRow(s={}){return `
${title} · ${help} Managed files are validated together with the stack.
${files.map(f=>managedFileRow(kind,f)).join('')}
`}
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return ``}
function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)}
-function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
Run the analysis to review writable bind mounts for every created service.
`}
@@ -142,7 +145,10 @@ function resourceTitle(k){return({containers:'Containers',images:'Images',volume
function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''}
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?``:''}
${firewallRuntimeHTML(preview)}
Dockwatch-managed port rules
${rules.map(firewallRuleRow).join('')}
Lockout protection: Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.
${!installed&&selected!=='auto'?``:''}
`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
+function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`
Host firewall · ${esc(selected)}
Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?``:''}
${firewallRuntimeHTML(preview)}
Dockwatch-managed port rules
${rules.map(firewallRuleRow).join('')}
Lockout protection: Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.
${!installed&&selected!=='auto'?``:''}
`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallRuleRow(r={}){return ``}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
@@ -260,6 +266,6 @@ function auditWatchRow(w={}){return `