# TASK.md — Current Task: App Detail/Info Pages > Read CLAUDE.md first for project context, workspace layout, and build instructions. > This file describes the current task to implement. ## Overview Add a dedicated app information page to the felhom-controller dashboard. This page shows detailed info about each app (use cases, setup guide, prerequisites) and allows configuring **optional settings** (like API keys for metadata providers) both before and after deployment. The first app to get this treatment is **RoMM** (retro game ROM manager), which has optional metadata provider API keys (IGDB, SteamGridDB, ScreenScraper, MobyGames). ## Architecture context - All HTML/CSS is embedded as Go string constants in `internal/web/templates.go` - All UI text is in **Hungarian** - Routes are defined in `internal/web/server.go` `ServeHTTP()` method (manual path matching) - API routes in `internal/api/router.go` `ServeHTTP()` method - App metadata lives in `.felhom.yml` files parsed by `internal/stacks/metadata.go` - Per-app deployed config is saved in `app.yaml` (managed by `internal/stacks/deploy.go`) - Template functions registered in `server.go` `initTemplates()` ## Changes needed This task touches **4 Go files** in the controller + **2 files** in the app-catalog repo. --- ### 1. `controller/internal/stacks/metadata.go` — Add new structs Add these new fields to the existing `Metadata` struct, and add the new supporting structs: ```go // Extend the existing Metadata struct with these new fields: type Metadata struct { // ... all existing fields stay unchanged ... DisplayName string `yaml:"display_name" json:"display_name"` Description string `yaml:"description" json:"description"` Category string `yaml:"category" json:"category"` Subdomain string `yaml:"subdomain" json:"subdomain"` Slug string `yaml:"slug" json:"slug"` Resources ResourceHints `yaml:"resources" json:"resources"` DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"` // NEW fields — add these: AppInfo AppInfo `yaml:"app_info" json:"app_info"` OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` } // NEW: Detailed app information for the info page type AppInfo struct { Tagline string `yaml:"tagline" json:"tagline"` UseCases []string `yaml:"use_cases" json:"use_cases"` FirstSteps []string `yaml:"first_steps" json:"first_steps"` Prerequisites []string `yaml:"prerequisites" json:"prerequisites"` DefaultCreds string `yaml:"default_creds" json:"default_creds"` DocsURL string `yaml:"docs_url" json:"docs_url"` } // NEW: Optional config group (e.g., "Metadata providers") type OptionalConfigGroup struct { Group string `yaml:"group" json:"group"` Description string `yaml:"description" json:"description"` Fields []OptionalConfigField `yaml:"fields" json:"fields"` } // NEW: Individual optional config field type OptionalConfigField struct { EnvVar string `yaml:"env_var" json:"env_var"` Label string `yaml:"label" json:"label"` Type string `yaml:"type" json:"type"` // "text" or "secret_input" HelpURL string `yaml:"help_url" json:"help_url"` HelpText string `yaml:"help_text" json:"help_text"` } ``` Add helper methods: ```go // HasAppInfo returns true if the metadata has any app info content. func (m *Metadata) HasAppInfo() bool { return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0 } // HasOptionalConfig returns true if the metadata has any optional config groups. func (m *Metadata) HasOptionalConfig() bool { return len(m.OptionalConfig) > 0 } ``` The existing `LoadMetadata()` function uses `yaml.Unmarshal` and will automatically populate the new fields if they exist in the YAML. No changes needed to `LoadMetadata()`. --- ### 2. `controller/internal/stacks/deploy.go` — Add optional config update method Add a new method for updating optional env vars in `app.yaml`. This is safe to call on deployed apps — it only touches env vars listed in `optional_config`, never locked fields. ```go // UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed. // Only updates env vars that are listed in the metadata's optional_config sections. func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error { m.mu.Lock() defer m.mu.Unlock() stack, ok := m.stacks[stackName] if !ok { return fmt.Errorf("stack not found: %s", stackName) } // Build a set of allowed env vars from optional_config allowed := make(map[string]bool) for _, group := range stack.Meta.OptionalConfig { for _, field := range group.Fields { allowed[field.EnvVar] = true } } if len(allowed) == 0 { return fmt.Errorf("no optional config fields defined for %s", stackName) } // Load existing app.yaml (or create empty one) appCfgPath := filepath.Join(m.cfg.Paths.StacksDir, stackName, "app.yaml") var appCfg AppConfig if data, err := os.ReadFile(appCfgPath); err == nil { _ = yaml.Unmarshal(data, &appCfg) } if appCfg.Env == nil { appCfg.Env = make(map[string]string) } // Update only allowed env vars changed := false for key, val := range values { if !allowed[key] { m.logger.Printf("[WARN] Ignoring non-optional env var: %s", key) continue } if appCfg.Env[key] != val { appCfg.Env[key] = val changed = true m.logger.Printf("[INFO] Updated optional config %s for %s", key, stackName) } } if !changed { return nil } // Save app.yaml data, err := yaml.Marshal(&appCfg) if err != nil { return fmt.Errorf("marshal app.yaml: %w", err) } if err := os.WriteFile(appCfgPath, data, 0600); err != nil { return fmt.Errorf("write app.yaml: %w", err) } m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName) // If deployed, write .env and restart to pick up new env vars if stack.Deployed { // Write .env file — reuse the same pattern used by DeployStack // Look at how DeployStack writes the .env file and follow the same approach if err := m.writeEnvFile(stackName, appCfg.Env); err != nil { return fmt.Errorf("write .env: %w", err) } m.logger.Printf("[INFO] Restarting %s to apply new optional config", stackName) // Need to call the internal restart logic (without re-acquiring the lock) // Check if there's an unlocked restart method; if not, create one by factoring // the restart logic out of RestartStack into a restartStackLocked helper if err := m.restartStackLocked(stackName); err != nil { return fmt.Errorf("restart after config update: %w", err) } } return nil } ``` **IMPORTANT implementation notes:** - Check how `DeployStack` currently writes `.env` files — there's likely a helper that iterates `appCfg.Env` and writes `KEY=VALUE` lines. Reuse that exact logic. - The existing `RestartStack()` acquires the mutex lock. Since `UpdateOptionalConfig` already holds the lock, you need an internal unlocked version. Either: - Factor the restart logic out of `RestartStack` into `restartStackLocked` (recommended), or - Temporarily release and re-acquire the lock (less clean, avoid if possible) - Look at `manager.go` for the restart implementation to understand what needs to be factored out. Also add a public method to load app config (if not already public): ```go // LoadAppConfig reads app.yaml from a stack directory. Returns nil if not found. func (m *Manager) LoadAppConfig(stackName string) (*AppConfig, error) { appCfgPath := filepath.Join(m.cfg.Paths.StacksDir, stackName, "app.yaml") data, err := os.ReadFile(appCfgPath) if err != nil { return nil, err } var cfg AppConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } return &cfg, nil } ``` --- ### 3. `controller/internal/api/router.go` — Add optional config API endpoint Add a new route case in the `ServeHTTP` switch block: ```go // POST /api/stacks/{name}/optional-config case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost: r.updateOptionalConfig(w, req, extractName(path, "/optional-config")) ``` And the handler function: ```go func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) { r.logger.Printf("[API] Optional config update requested for stack: %s", name) var body struct { Values map[string]string `json:"values"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) return } if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil { r.logger.Printf("[API] Optional config update failed for %s: %v", name, err) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"}) } ``` --- ### 4. `controller/internal/web/server.go` — Add info page route and handler #### 4a. The route already exists — update it There's already a case in `ServeHTTP` for `/apps/{slug}`. Currently it redirects to the deploy page. Replace the `appDetailHandler` method with a real page handler: ```go func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { var found *stacks.StackInfo for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { found = &stack break } } if found == nil { http.NotFound(w, r) return } // Load current optional config values from app.yaml currentValues := make(map[string]string) if appCfg, err := s.stackMgr.LoadAppConfig(found.Name); err == nil && appCfg != nil { for k, v := range appCfg.Env { currentValues[k] = v } } data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo data["OptionalConfig"] = found.Meta.OptionalConfig data["CurrentValues"] = currentValues data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() s.render(w, "app_info", data) } ``` #### 4b. Add template functions In `initTemplates()`, add to the `funcMap`: ```go "screenshotURL": func(slug string, index int) string { return s.cfg.AppScreenshotURL(slug, index) }, "seq": func(n int) []int { result := make([]int, n) for i := range result { result[i] = i + 1 } return result }, ``` --- ### 5. `controller/internal/web/templates.go` — Add info page template + CSS #### 5a. Update `allTemplates` const ```go const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + appInfoTmpl ``` #### 5b. Add `appInfoTmpl` const Design goals: clean layout matching existing dark theme, Hungarian UI, sections for hero header, app info cards, screenshots, and optional config form with AJAX save. ```go const appInfoTmpl = ` {{define "app_info"}} {{template "layout_start" .}}
{{if .AppInfo.Tagline}}

{{.AppInfo.Tagline}}

{{else}}

{{.Meta.Description}}

{{end}}
~{{.Meta.Resources.MemRequest}} RAM {{.Meta.Category}} {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{else}}Csak x86{{end}}
{{if .HasAppInfo}}
{{if .AppInfo.UseCases}}

🎯 Mire használható?

{{end}} {{if .AppInfo.FirstSteps}}

🚀 Első lépések

    {{range .AppInfo.FirstSteps}}
  1. {{.}}
  2. {{end}}
{{end}} {{if .AppInfo.Prerequisites}}

📋 Előfeltételek

{{end}} {{if .AppInfo.DefaultCreds}}

🔑 Alapértelmezett belépés

{{.AppInfo.DefaultCreds}}

⚠️ Az első bejelentkezés után azonnal változtasd meg!

{{end}} {{if .AppInfo.DocsURL}}

📖 Dokumentáció

Hivatalos dokumentáció ↗

{{end}}
{{end}} {{if .HasOptionalConfig}}

⚙️ Opcionális beállítások

{{range .OptionalConfig}}

{{.Group}}

{{if .Description}}

{{.Description}}

{{end}}
{{range .Fields}}
{{if .HelpText}}

{{.HelpText}}

{{end}} {{if .HelpURL}}

Regisztrációs útmutató ↗

{{end}}
{{end}}
{{end}}
{{end}} {{template "layout_end" .}} {{end}} ` ``` #### 5c. Add CSS to `cssContent` Append these styles at the end of `cssContent` (before the closing backtick): ```css /* --- App Info Page --- */ .app-info-hero { display: flex; align-items: center; gap: 1.5rem; padding: 1.5rem; background: var(--bg-card); border-radius: var(--radius); border: 1px solid var(--border-color); margin-bottom: 1.5rem; } .app-info-logo { width: 80px; height: 80px; border-radius: 12px; object-fit: contain; background: var(--bg-secondary); padding: 10px; flex-shrink: 0; } .app-info-tagline { font-size: 1.1rem; color: var(--text-primary); margin: 0 0 .75rem 0; } .app-screenshots { display: flex; gap: 1rem; margin-bottom: 1.5rem; overflow-x: auto; padding-bottom: .5rem; } .app-screenshot { max-height: 220px; border-radius: var(--radius); border: 1px solid var(--border-color); object-fit: cover; } .app-info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .app-info-card { background: var(--bg-card); border-radius: var(--radius); padding: 1.25rem; border: 1px solid var(--border-color); } .app-info-card h3 { margin: 0 0 .75rem 0; font-size: .95rem; color: var(--text-primary); } .app-info-list { margin: 0; padding-left: 1.25rem; color: var(--text-secondary); font-size: .9rem; line-height: 1.6; } .app-info-creds { font-family: monospace; font-size: 1rem; color: var(--accent-light); background: var(--bg-secondary); padding: .5rem .75rem; border-radius: 4px; display: inline-block; margin: 0 0 .5rem 0; } .app-info-creds-warn { color: var(--orange); font-size: .85rem; margin: 0; } .app-info-link { color: var(--accent-light); text-decoration: none; } .app-info-link:hover { text-decoration: underline; } /* Optional config section */ .app-optional-config { background: var(--bg-card); border-radius: var(--radius); padding: 1.5rem; border: 1px solid var(--border-color); margin-bottom: 1.5rem; } .app-optional-config h3 { margin: 0 0 1rem 0; font-size: 1.05rem; } .config-group { margin-bottom: 1.5rem; } .config-group h4 { margin: 0 0 .5rem 0; font-size: .95rem; color: var(--text-primary); } .config-group-desc { color: var(--text-secondary); font-size: .85rem; margin: 0 0 1rem 0; } .config-fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } .config-field { display: flex; flex-direction: column; gap: .25rem; } .config-field label { font-size: .85rem; font-weight: 500; color: var(--text-primary); } .config-field-help { font-size: .8rem; color: var(--text-secondary); margin: 0; line-height: 1.4; } .config-field-help a { color: var(--accent-light); text-decoration: none; } .config-field-help a:hover { text-decoration: underline; } .config-input { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; padding: .5rem .75rem; color: var(--text-primary); font-size: .9rem; font-family: monospace; width: 100%; box-sizing: border-box; } .config-input:focus { outline: none; border-color: var(--accent-blue); } .config-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; } .config-save-status { font-size: .9rem; transition: opacity .3s; } .config-save-ok { color: var(--green); } .config-save-err { color: var(--red); } .meta-badge-warn { background: rgba(255, 152, 0, 0.1) !important; color: var(--orange) !important; } .meta-badge-ok { background: rgba(76, 175, 80, 0.1) !important; color: var(--green) !important; } ``` --- ### 6. Update navigation links in `templates.go` #### 6a. Stack cards on Alkalmazások page → link to info page In `stacksTmpl`, the cards currently have `data-href="/stacks/{{.Name}}/deploy"`. Change to: ```html {{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}} ``` #### 6b. Stack cards on Dashboard → same change Check `dashboardTmpl` for any `data-href` attributes pointing to `/stacks/.../deploy` and update them similarly to link to `/apps/{{.Meta.Slug}}` instead. #### 6c. Deploy page → add "Részletek" link In `deployTmpl`, add a link back to the info page near the top header area: ```html ℹ️ Részletek ``` --- ### 7. RoMM `.felhom.yml` — Update in app-catalog repo File: `E:\git\app-catalog-felhom.eu\templates\romm\.felhom.yml` Replace the entire file with: ```yaml # ============================================================================= # .felhom.yml — App metadata for felhom-controller # ============================================================================= # --- Display info (shown on dashboard) --- display_name: "RomM" description: "Retró játékgyűjtemény kezelő" category: "media" subdomain: "arcade" slug: "romm" # --- Resource hints (displayed on deploy screen) --- resources: mem_request: "300M" mem_limit: "1024M" pi_compatible: false needs_hdd: true # --- Deploy fields (first deployment only) --- deploy_fields: - env_var: DOMAIN label: "Domain" type: domain description: "A szerver domain neve" locked_after_deploy: true - env_var: DB_PASSWORD label: "Adatbázis jelszó" type: secret generate: "password:24" locked_after_deploy: true - env_var: MYSQL_ROOT_PASSWORD label: "MariaDB root jelszó" type: secret generate: "password:24" locked_after_deploy: true - env_var: ROMM_AUTH_SECRET_KEY label: "Hitelesítési titkosítási kulcs" type: secret generate: "hex:32" locked_after_deploy: true - env_var: HDD_PATH label: "Adattárolási útvonal" type: path required: true placeholder: "/mnt/hdd_1" description: "A külső merevlemez elérési útja, ahol a ROM-ok és borítóképek tárolódnak" locked_after_deploy: true # --- App info (info page content) --- app_info: tagline: "Retró játékgyűjtemény kezelő, böngésző és lejátszó" default_creds: "admin / admin" docs_url: "https://github.com/rommapp/romm/wiki" use_cases: - "Retró játékgyűjtemény rendszerezése és böngészése webes felületen" - "Játékok metaadatainak automatikus letöltése — borítók, leírások, értékelések" - "Platformonkénti szűrés és keresés a teljes gyűjteményben" - "ROM fájlok webes feltöltése és letöltése" - "Többfelhasználós hozzáférés a háztartás tagjai számára" first_steps: - "Nyisd meg az arcade.DOMAIN címet a böngészőben" - "Jelentkezz be az alapértelmezett admin / admin fiókkal" - "Változtasd meg azonnal a jelszót a Settings menüben" - "Töltsd fel a ROM fájlokat a library mappába (platform/játéknév struktúrával)" - "Indíts egy Scan-t a bal oldali menüben a ROM-ok beolvasásához" - "Opcionális: állíts be metaadat-szolgáltatókat a borítóképek és leírások automatikus letöltéséhez (lásd lent)" prerequisites: - "Külső HDD szükséges a ROM fájlok és borítóképek tárolásához" - "Legalább 1 GB szabad RAM ajánlott (MariaDB + Redis + RomM)" - "ROM fájlok platform mappákba rendezve (pl. library/gba/, library/snes/)" # --- Optional config (configurable before or after deployment) --- optional_config: - group: "Metaadat-szolgáltatók" description: "Játékok borítóinak, leírásainak és értékeléseinek automatikus letöltéséhez. Legalább az IGDB beállítása ajánlott. Mindegyik ingyenesen használható regisztráció után." fields: - env_var: IGDB_CLIENT_ID label: "IGDB Client ID" type: text help_url: "https://api-docs.igdb.com/#getting-started" help_text: "1) Regisztrálj / jelentkezz be a Twitch fejlesztői portálon (dev.twitch.tv). 2) Hozz létre egy új alkalmazást (bármilyen névvel). 3) Másold be a Client ID-t." - env_var: IGDB_CLIENT_SECRET label: "IGDB Client Secret" type: secret_input help_text: "A Twitch alkalmazásod Client Secret-je (a „New Secret" gombbal generálhatod)." - env_var: STEAMGRIDDB_API_KEY label: "SteamGridDB API Key" type: text help_url: "https://www.steamgriddb.com/profile/preferences/api" help_text: "Regisztrálj a SteamGridDB oldalon, majd a Preferences → API fül alatt kattints a „Generate API key" gombra." - env_var: SCREENSCRAPER_USER label: "ScreenScraper felhasználónév" type: text help_url: "https://www.screenscraper.fr/" help_text: "Regisztrálj a screenscraper.fr oldalon. A felhasználóneved lesz az API felhasználónév." - env_var: SCREENSCRAPER_PASSWORD label: "ScreenScraper jelszó" type: secret_input help_text: "A screenscraper.fr fiókod jelszava." - env_var: MOBYGAMES_API_KEY label: "MobyGames API Key" type: text help_url: "https://www.mobygames.com/info/api/" help_text: "Regisztrálj a MobyGames oldalon, majd az API oldalon igényelj kulcsot. Részletes játékinformációkat és krediteket biztosít." ``` --- ### 8. RoMM docker-compose.yml template — Add missing env vars File: `E:\git\app-catalog-felhom.eu\templates\romm\docker-compose.yml` In the romm service's `environment:` section, after the existing STEAMGRIDDB line, add: ```yaml - SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-} - SCREENSCRAPER_PASSWORD=${SCREENSCRAPER_PASSWORD:-} - MOBYGAMES_API_KEY=${MOBYGAMES_API_KEY:-} ``` --- ## Implementation order 1. `metadata.go` — add structs + helper methods (safe, no side effects) 2. `deploy.go` — add `UpdateOptionalConfig` + `LoadAppConfig` + factor out restart logic 3. `router.go` — add `/optional-config` API route + handler 4. `templates.go` — add `appInfoTmpl`, CSS, update `allTemplates` 5. `server.go` — replace `appDetailHandler`, add template functions 6. `templates.go` — update navigation links (stack cards → `/apps/{slug}`, deploy page → "Részletek") 7. **Commit + push** the controller changes 8. **Build + push + deploy** the new controller image (see CLAUDE.md for exact commands) 9. **Verify** the controller starts correctly and the info page renders 10. Update RoMM `.felhom.yml` in app-catalog repo 11. Update RoMM `docker-compose.yml` in app-catalog repo 12. **Commit + push** the app-catalog changes 13. Trigger catalog sync on demo-felhom (or wait 15m) 14. **Verify** the RoMM info page shows all sections --- ## Testing checklist - [ ] `/apps/romm` shows the info page with all sections - [ ] Optional config fields display with current values (empty for fresh deployment) - [ ] Saving optional config updates `app.yaml` and restarts the stack (if deployed) - [ ] Saving optional config on a non-deployed app saves to `app.yaml` without error - [ ] Screenshots section hidden gracefully if no assets exist - [ ] Stack cards on Alkalmazások page link to `/apps/{slug}` - [ ] Deploy page has "Részletek" link back to info page - [ ] Apps without `app_info` show minimal info page (header + badges) - [ ] Protected stacks (traefik, cloudflared, felhom-controller) not affected - [ ] Build succeeds on build server - [ ] Controller starts and runs without template parse errors - [ ] New version shows in `docker ps` --- ## Step 15: Update documentation (MANDATORY) After all code changes are done and verified, update these three files: ### 15a. `CONTEXT.md` Add a new "What was just completed" section at the top of the history (push down the current "What was just completed" to "Previously completed"). Include: - App info/detail pages feature added - New `.felhom.yml` fields: `app_info`, `optional_config` - New route: `GET /apps/{slug}` renders info page (was redirect to deploy) - New API: `POST /api/stacks/{name}/optional-config` - RoMM metadata updated with full app_info + 6 metadata provider optional config fields - RoMM docker-compose.yml updated with ScreenScraper + MobyGames env vars - Navigation: stack cards now link to info page, deploy page has "Részletek" link - New controller version: v0.2.11 (or whatever was deployed) Update the "What's next" section — remove items that are done, add any new priorities. ### 15b. `controller/README.md` Update to reflect: - New `app_info` and `optional_config` sections in `.felhom.yml` format - New info page route (`/apps/{slug}`) - New API endpoint (`POST /api/stacks/{name}/optional-config`) - Add to "What works" list: "App detail/info pages with optional config" ### 15c. `CLAUDE.md` Add to the "Key patterns" section: - App info pages at `/apps/{slug}` — detail view with use cases, setup guide, optional config - Optional config saves to `app.yaml` and restarts deployed apps - `optional_config` fields in `.felhom.yml` define post-deploy configurable env vars Add to "Important lessons learned" if any new lessons emerge during implementation. --- ## Debugging tips - If template fails to parse, the controller crashes on startup with a `template.Must` panic. The log output will show the exact parse error (usually missing closing brackets or undefined functions). - If `app.yaml` already has deployed config, `UpdateOptionalConfig` merges new values without touching locked fields. - The `onerror` on screenshot images hides them if assets don't exist — graceful degradation. - `config-input` uses `font-family: monospace` intentionally — API keys are easier to verify. - The `{{index $.CurrentValues .EnvVar}}` in the template will return empty string for missing keys (Go template `index` on map returns zero value) — no nil panic.