Indítópult launcher page + universal app placeholder icon (v0.163.0)

New /launcher page: a grid of large tappable tiles, one per openable deployed
app (subdomain presence is the single openability criterion, shared with the
Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles
(deterministic slug color or .felhom.yml brand_color), white glyph/monogram
fallback, target=_blank links for operational apps, greyed unclickable tiles for
stopped ones. First sidebar item; / stays the Vezérlőpult.

Universal app placeholder: new AppPlaceholderSVG served at
/static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a
logo-less app shows a placeholder instead of visibility:hidden. Brand mark is
never an app placeholder.

New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions +
4 red-proofs. No agent coupling; MinAgent unchanged.
This commit is contained in:
2026-07-24 09:16:28 +02:00
parent a71cc58327
commit 987e915bf2
15 changed files with 632 additions and 26 deletions
+42
View File
@@ -3,15 +3,53 @@ package web
import (
"encoding/json"
"fmt"
"hash/fnv"
"html/template"
"regexp"
"strings"
"sync"
"time"
"unicode"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// hexColorRe matches a valid #rgb / #rrggbb CSS hex color. A brand_color that does not match is
// treated as absent (the deterministic slug color is used instead) — see tileColor. This is the
// braces to html/template's belt: we never pass an unvalidated brand string into a style attribute,
// even though the escaper would neutralize an injection anyway.
var hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// tileColor returns the launcher tile background for an app. A valid #rgb/#rrggbb brand override is
// returned verbatim; otherwise a deterministic HSL is derived from the slug via FNV-1a so the same
// app always gets the same color. Saturation/lightness are fixed (tuned for the dark theme with a
// white glyph on top); only the hue varies per app.
//
// The result is template.CSS because BOTH branches are trusted-by-construction: the brand is
// validated against hexColorRe here in Go, and the hsl() is fully computed by us. That is the point
// of the Go-side validation — html/template's CSS filter mangles a legitimate hsl() from a func
// pipeline to ZgotmplZ, so the escaper cannot be the belt here; the regex is. Returning an
// UNVALIDATED brand as template.CSS would inject it verbatim (which the Group C red-proof exercises).
func tileColor(slug, brand string) template.CSS {
if hexColorRe.MatchString(brand) {
return template.CSS(brand)
}
h := fnv.New32a()
_, _ = h.Write([]byte(slug))
hue := h.Sum32() % 360
return template.CSS(fmt.Sprintf("hsl(%d, 45%%, 38%%)", hue))
}
// initial returns the monogram for an app: the first rune of name, upper-cased. Rune-based (not
// byte-based) so a multibyte accented initial ("Óra" → "Ó") is not split. Empty name → "?".
func initial(name string) string {
for _, r := range name {
return string(unicode.ToUpper(r))
}
return "?"
}
var (
webTimezone *time.Location
webTimezoneOnce sync.Once
@@ -423,6 +461,10 @@ func (s *Server) templateFuncMap() template.FuncMap {
// infraMeta resolves a protected infra stack's curated Hungarian identity
// (inframeta.go); nil for regular apps — templates branch on it.
"infraMeta": infraMetaFor,
// tileColor / initial back the Indítópult (launcher) tiles: a deterministic slug-derived
// color (or a validated brand_color override) and a multibyte-safe monogram fallback.
"tileColor": tileColor,
"initial": initial,
// pageMatch returns true if currentPage is in the pages slice.
// Used to filter page-specific alerts in layout.html.
"pageMatch": func(pages []string, currentPage string) bool {