) keep the monogram span AND the reveal hook.
+ if got := strings.Count(html, "launch-mono"); got != 2 {
+ t.Errorf("expected the monogram span in both tile branches (2), got %d", got)
+ }
+ if got := strings.Count(html, "classList.add('launch-tile--noimg')"); got != 2 {
+ t.Errorf("both tile onerror chains must reveal the monogram on final failure, got %d", got)
+ }
+}
+
+// Group B — every app-logo surface ends in the placeholder (or infra glyph), never hidden.
+
+// TestBackupsApps_IconPlaceholder — the allowlisted aligned row now matches the canonical chain.
+// COMPANION red-proof (REPORT): revert backups_apps.html to the 2-step chain → this FAILS.
+func TestBackupsApps_IconPlaceholder(t *testing.T) {
+ data := map[string]interface{}{
+ "Page": "backups-apps", "Title": "Biztonsági mentés — Alkalmazások",
+ "Backup": &backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "docmost", DisplayName: "Docmost"}}},
+ "AppBackupRows": []AppBackupRow{{StackName: "docmost", DisplayName: "Docmost", Slug: "docmost", Status: "green", Tier3State: "off"}},
+ "DBSectionState": "dumps",
+ }
+ html := renderBackupPage(t, "backups_apps", data)
+ if !strings.Contains(html, `data-fallback="/static/app-placeholder.svg"`) {
+ t.Error("backups_apps icon must default to the app placeholder")
+ }
+ if !strings.Contains(html, canonical3Step) {
+ t.Error("backups_apps icon must use the canonical 3-step onerror chain")
+ }
+}
+
+// TestStacks_IconPlaceholderAndInfra — regular apps default to the placeholder; infra keeps the
+// infra glyph.
+func TestStacks_IconPlaceholderAndInfra(t *testing.T) {
+ data := map[string]interface{}{
+ "Page": "stacks", "Title": "Alkalmazások",
+ "Stacks": []stacks.Stack{
+ {Name: "docmost", Deployed: true, State: stacks.StateRunning,
+ Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}},
+ {Name: "filebrowser", Protected: true, State: stacks.StateRunning,
+ Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "Filebrowser"}},
+ },
+ "MissingStorage": map[string]string{},
+ "NetworkWarnings": map[string]string{},
+ "NetworkStubs": map[string]string{},
+ "StorageLabels": map[string]string{},
+ "Subdomains": map[string]string{"filebrowser": "files"},
+ "RunningCount": 2, "StoppedCount": 0, "TotalCount": 2,
+ "SystemInfo": system.SystemInfo{},
+ "BackupEnabled": false,
+ "Domain": "demo-felhom.eu",
+ }
+ html := renderBackupPage(t, "stacks", data)
+ if !strings.Contains(html, `data-fallback="/static/app-placeholder.svg"`) {
+ t.Error("a regular app card must default to the app placeholder")
+ }
+ if !strings.Contains(html, `data-fallback="/static/infra-logo.svg"`) {
+ t.Error("an infra app card must keep the infra glyph fallback")
+ }
+ if !strings.Contains(html, canonical3Step) {
+ t.Error("stacks card icon must use the canonical 3-step onerror chain")
+ }
+}
+
+// TestAppInfo_HeroPlaceholder_ScreenshotsUntouched — hero logo aligned; screenshots still vanish.
+func TestAppInfo_HeroPlaceholder_ScreenshotsUntouched(t *testing.T) {
+ st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning,
+ Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
+ data := map[string]interface{}{
+ "Page": "stacks", "Title": st.Meta.DisplayName,
+ "Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo,
+ "HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
+ "Domain": "demo-felhom.eu",
+ }
+ html := renderBackupPage(t, "app_info", data)
+ if !strings.Contains(html, `data-fallback="/static/app-placeholder.svg"`) {
+ t.Error("app_info hero logo must default to the app placeholder")
+ }
+ if !strings.Contains(html, canonical3Step) {
+ t.Error("app_info hero logo must use the canonical 3-step chain")
+ }
+ // Screenshots must be byte-identical to before — they still vanish on error, no placeholder.
+ if !strings.Contains(html, `class="app-screenshot"`) || !strings.Contains(html, `onerror="this.style.display='none'"`) {
+ t.Error("app_info screenshot imgs must keep their original vanish-on-error onerror")
+ }
+ if strings.Contains(html, `class="app-screenshot" `) && strings.Contains(html, `app-screenshot" data-fallback`) {
+ t.Error("app_info screenshots must NOT gain a placeholder fallback")
+ }
+}
+
+// TestDeploy_LogoPlaceholder — deploy logo aligned, keeping its .LogoURL/.LogoPNGURL data source.
+func TestDeploy_LogoPlaceholder(t *testing.T) {
+ st := stacks.Stack{Name: "docmost", State: stacks.StateNotDeployed,
+ Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
+ data := map[string]interface{}{
+ "Page": "deploy", "Title": "Docmost — Telepítés",
+ "Stack": &st, "Meta": st.Meta, "AlreadyDeployed": false,
+ "LogoURL": "/static/assets/docmost-logo.svg", "LogoPNGURL": "/static/assets/docmost-logo.png",
+ "Domain": "demo-felhom.eu",
+ }
+ html := renderBackupPage(t, "deploy", data)
+ if !strings.Contains(html, `data-fallback="/static/app-placeholder.svg"`) {
+ t.Error("deploy logo must default to the app placeholder")
+ }
+ if !strings.Contains(html, canonical3Step) {
+ t.Error("deploy logo must use the canonical 3-step chain")
+ }
+ // Data source preserved: the deploy page keeps .LogoPNGURL (not a funcmap call). The URL is
+ // JS-escaped inside the onerror string (\/static\/…), so assert the slash-free filename.
+ if !strings.Contains(html, "docmost-logo.png") {
+ t.Error("deploy logo must keep its .LogoPNGURL data source in the chain")
+ }
+}
diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html
index 2270384..1afe4b2 100644
--- a/controller/internal/web/templates/app_info.html
+++ b/controller/internal/web/templates/app_info.html
@@ -37,8 +37,8 @@

+ alt="{{.Meta.DisplayName}}" data-fallback="/static/app-placeholder.svg"
+ onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
{{if .AppInfo.Tagline}}
{{.AppInfo.Tagline}}
diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html
index 297c21e..90cc93a 100644
--- a/controller/internal/web/templates/backups_apps.html
+++ b/controller/internal/web/templates/backups_apps.html
@@ -139,10 +139,12 @@
+ copy in scripts/app_row_dedup_gate.py, NOT a partial render (it owns the toggle).
+ The icon chain matches app_row.html: SVG -> PNG -> /static/app-placeholder.svg, so a
+ logo-less app shows the placeholder here too instead of a hidden dead-end (v0.163.1). -->