diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1c7da..2f982ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ ## Changelog +### v0.163.1 — Launcher polish: monogram reveal-on-failure + placeholder on every icon surface (2026-07-24) + +No agent coupling; MinAgent unchanged. Two live findings from the v0.163.0 operator browser pass on 9201. + +- **Monogram bled through every tile.** The launcher rendered `.launch-mono` unconditionally UNDER + the logo ``; app logos are white monochrome SVGs with transparent backgrounds, so the big + white letter showed through the glyph gaps on EVERY tile. The monogram is now hidden by default + (`.launch-mono { display: none }`) and revealed ONLY when the img chain fails — the final `onerror` + step adds `.launch-tile--noimg` to the tile, which flips the monogram back on. Applies to both the + operational `` and the stopped `
` branch. +- **Placeholder reached only the canonical row.** The `/static/app-placeholder.svg` default landed in + `app_list_row` only; four more sanctioned app-logo `onerror` chains still dead-ended in + hidden/none for logo-less apps (observed: Docmost with no icon on Biztonsági mentés → + Alkalmazások). Every app-logo surface now follows one grammar — **SVG → PNG → placeholder** (infra + rows → infra icon): `backups_apps.html` (the allowlisted aligned row), `stacks.html` (the + `data-fallback` is now always present: infra → `infra-logo.svg`, else `app-placeholder.svg`), + `app_info.html` (hero logo only — **screenshots deliberately still vanish on error**), + `deploy.html` (keeps its `.LogoURL`/`.LogoPNGURL` data source). No handler/funcmap changes. + + ### v0.163.0 — Indítópult (app launcher page) + universal app placeholder icon (2026-07-24) No agent coupling; MinAgent unchanged. Adds a customer-facing **Indítópult** launcher grid and a diff --git a/CONTEXT.md b/CONTEXT.md index 7ae6e98..231a136 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,16 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-24 (v0.163.0 — Indítópult launcher + universal app placeholder) +Last updated: 2026-07-24 (v0.163.1 — launcher polish: monogram reveal-on-failure + placeholder everywhere) + +> **2026-07-24 — v0.163.1 (launcher polish).** Two v0.163.0 live findings fixed. RULE recorded: +> **every app-logo surface ends in a visible placeholder** (`SVG → PNG → /static/app-placeholder.svg`, +> infra rows → `infra-logo.svg`) — the four sibling `onerror` chains (`backups_apps`, `stacks`, +> `app_info` hero, `deploy`) now match `app_row.html`; `app_info` screenshots deliberately still +> vanish on error. And the **launcher monogram is launcher-only AND failure-only**: hidden by default, +> revealed when the tile's img chain fails (`onerror` adds `.launch-tile--noimg`) — it was bleeding +> through every transparent white glyph. Template/CSS only; no handler/funcmap change. 5 tests + 2 +> red-proofs. [[launcher-v0163-2026-07-24]] > **2026-07-24 — v0.163.0 (Indítópult app launcher + universal placeholder icon).** New > customer-facing `/launcher` page: the FIRST sidebar item (above Vezérlőpult), a grid of large diff --git a/controller/internal/web/launcher_polish_test.go b/controller/internal/web/launcher_polish_test.go new file mode 100644 index 0000000..d1d6b5c --- /dev/null +++ b/controller/internal/web/launcher_polish_test.go @@ -0,0 +1,149 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// v0.163.1 launcher polish. Two live findings: (A) the launcher monogram bled through every +// transparent white logo because it rendered unconditionally UNDER the img; it must be revealed +// ONLY when the img chain fails. (B) the /static/app-placeholder.svg default landed only in the +// canonical app_list_row — four sibling icon chains still dead-ended in hidden for logo-less apps. + +// canonical3Step is the SVG→PNG→data-fallback→hidden onerror grammar every app-logo surface must use. +const canonical3Step = `else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}` + +// Group A — the launcher monogram is failure-only. +// COMPANION red-proof (REPORT): drop `classList.add('launch-tile--noimg')` from the else branch → +// the onerror-content assertion FAILS. +func TestLauncher_MonogramRevealOnFailure(t *testing.T) { + data := map[string]interface{}{ + "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", + "Apps": []LauncherApp{ + {Name: "mealie", DisplayName: "Mealie", Slug: "mealie", State: stacks.StateRunning, Subdomain: "recept"}, + {Name: "jellyfin", DisplayName: "Jellyfin", Slug: "jellyfin", State: stacks.StateStopped, Subdomain: "media"}, + }, + } + html := renderBackupPage(t, "launcher", data) + + // The reveal class must never sit in a class attribute in the initial markup — it is added by + // onerror at runtime only. Its ONLY occurrences are the two onerror `classList.add(...)` calls; + // a count above 2 would mean it leaked into a class="" attribute. + if got := strings.Count(html, "launch-tile--noimg"); got != 2 { + t.Errorf("launch-tile--noimg must appear only in the two onerror hooks (2), got %d — it must not be a static class", got) + } + // Both branches (operational + stopped
) 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). -->
- + {{.DisplayName}}
{{if .DriveDisconnected}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index fc49f37..53cb2be 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -26,7 +26,8 @@ {{if .FlashSuccess}}
{{.FlashSuccess}}
{{end}} {{if .FlashError}}
{{.FlashError}}
{{end}}
- +

{{.Meta.DisplayName}}

{{if .Meta.Description}}

{{.Meta.Description}}

{{end}} diff --git a/controller/internal/web/templates/launcher.html b/controller/internal/web/templates/launcher.html index 1f087e5..cf84581 100644 --- a/controller/internal/web/templates/launcher.html +++ b/controller/internal/web/templates/launcher.html @@ -14,7 +14,7 @@ {{initial .DisplayName}} + onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}"> {{.DisplayName}} {{if ne (stateStr .State) "running"}}{{stateLabel .State}}{{end}} @@ -24,7 +24,7 @@ {{initial .DisplayName}} + onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}"> {{.DisplayName}} {{stateLabel .State}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index f79ae7f..9c2320a 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -22,7 +22,7 @@
-

{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}

diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 4b5f3ad..16d7d73 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1146,8 +1146,15 @@ a.stat-card:hover { transform: translateY(-2px); border-color: var(--blue); } +/* The monogram is the logo-less fallback ONLY: hidden by default, revealed when the tile's + chain fails (onerror adds .launch-tile--noimg). Otherwise a transparent white glyph would show + the letter bleeding through its gaps on every tile (v0.163.1 live finding). */ .launch-mono { + display: none; position: absolute; + inset: 0; + align-items: center; + justify-content: center; color: #fff; font-family: var(--font-ui); font-size: 2.4rem; @@ -1155,6 +1162,7 @@ a.stat-card:hover { line-height: 1; user-select: none; } +.launch-tile--noimg .launch-mono { display: flex; } .launch-logo { position: relative; width: 58%;