# M7 #363 — Branding polish (app title, OG meta, install instructions) **Status:** Design **Milestone:** M7 (v1.0 web-first sweep) **Issue:** Fable #363 **Scope:** Web SPA + Go static-serve handler + repo README. No Flutter changes; no PWA / service worker / installable-app work (deliberately deferred — Flutter #356 is the canonical mobile path, Tauri is the canonical desktop path). ## Goal Final-mile branding polish before the v1.0 tag: - Per-route page titles in the browser tab. - Per-instance app-name override (`SMARTMUSIC_BRANDING_APP_NAME`) that surfaces in the SPA shell header, page titles, OG meta, and theme color hints. - OG / Twitter share-preview metadata so a URL pasted into Slack / Discord / iMessage renders correctly. - `` that follows the dark/light theme chosen by the user (and the prefers-color-scheme media query for first paint). - Operator-first README rewrite — discovery surface for someone landing on the Forgejo repo cold. ## Out of scope - PWA manifest, "Install Minstrel" app prompt, Apple touch icon, Android maskable icon — Flutter (#356) covers mobile, Tauri (post-v1) covers desktop. Adding a PWA layer in between is unneeded work. - Service worker / offline shell. - Hand-designed OG / share image — a programmatically-rendered placeholder ships with this slice; the operator-quality artwork is a separate one-off task. - Per-page OG images (each route shares the same single image). - Localised page titles (English-only in v1). - A general-purpose `/api/instance` runtime config endpoint — `window.__MINSTREL__` exposes the values the SPA needs synchronously without an extra request. ## Architecture ### Branding flows through Go template injection, not a runtime fetch ``` config.yaml / env: branding.{app_name, description} │ ▼ config.Load Server.BrandingCfg{ AppName, Description } │ ▼ on every GET / (SPA index) internal/server/spa.ServeIndex │ parses embedded index.html as html/template once at startup │ renders into a bytes.Buffer, writes response ▼ HTTP body: rendered index.html with - {{ .AppName }} ← default; per-route overrides - - - - - - - ``` Other static SPA assets (`/_app/*`, `/favicon.png`, `/brand/og-image.png`) continue to be served as raw bytes through the existing `http.FileServer` — only the index.html path is templated. Why template injection instead of a public `/api/instance` endpoint: 1. Share-preview crawlers (Slack, Discord, iMessage, Twitter, OpenGraph aggregators) do not run JavaScript. OG meta has to be present in the served HTML, not injected after hydration. 2. Once the server is templating HTML for the crawlers, exposing the same values via an inline `` block is free for the SPA — no fetch round-trip, no loading state, no flash of "Minstrel" before the operator's chosen name renders. 3. The current set of "instance config" is two strings. A typed config endpoint can be added later without redoing this layer; the inline global naturally extends to hold more keys. ### Config surface `internal/config/config.go` gains a `Branding` struct: ```go type BrandingConfig struct { AppName string `yaml:"app_name" env:"SMARTMUSIC_BRANDING_APP_NAME"` Description string `yaml:"description" env:"SMARTMUSIC_BRANDING_DESCRIPTION"` } ``` `Config` adds `Branding BrandingConfig`. Defaults are applied in `Load`: - `AppName` empty → `"Minstrel"` - `Description` empty → `"Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration."` `config.example.yaml` documents the new section under a `branding:` block. ### Server wiring `server.New` gains a `BrandingConfig` parameter — same pattern just shipped for `DataDir` in `6d1709c`. `cmd/minstrel/main.go` threads the config through. `internal/server/spa.go` (new file, or absorbed into the existing static-serve handler): - At construction time, reads `index.html` from the embedded SPA filesystem and parses it as `html/template.Template`. - `ServeHTTP` renders the template into a `bytes.Buffer` against a small data struct (`{ AppName, Description, OGImageURL }`) and writes the body. Cache-control is `no-cache` so a config change on container restart is reflected immediately. - Other paths (`/_app/*`, `/favicon.png`, `/brand/*`) continue to be served by the existing static handler, untouched. `html/template` context-aware escaping handles XSS automatically: the values land inside an HTML element body for the meta-tag content attributes, and inside a JS string literal in the inline `"` gets their string safely escaped in both contexts. ### SPA wiring **`$lib/branding.ts`** (new, ~10 lines): ```ts declare global { interface Window { __MINSTREL__?: { appName: string; description: string }; } } export const appName = (): string => (typeof window !== 'undefined' && window.__MINSTREL__?.appName) || 'Minstrel'; export const description = (): string => (typeof window !== 'undefined' && window.__MINSTREL__?.description) || 'Self-hosted music server.'; export const pageTitle = (section?: string): string => section ? `${appName()} · ${section}` : appName(); ``` The `typeof window !== 'undefined'` guard is defensive against any future SSR / pre-render contexts; current SPA mode (`ssr=false`) doesn't strictly need it. **Per-route titles.** Each `+page.svelte` declares a `` block: ```svelte {pageTitle('Search')} ``` Format follows Q5 decision (larger-to-smaller): `Minstrel · Search`, `Minstrel · Library · Artists`, `Minstrel · Artist · Radiohead`. Dynamic routes resolve from existing query data, with a fallback while loading: ```svelte {pageTitle(artist.data ? `Artist · ${artist.data.name}` : 'Artist')} ``` Routes to wire (audited during implementation; not exhaustively enumerated here): - Static: `/`, `/search`, `/library`, `/library/artists`, `/library/albums`, `/library/songs`, `/library/hidden`, `/discover`, `/queue`, `/history`, `/playlists`, `/settings`, `/login`, `/admin/*`. - Dynamic: `/artist/[id]`, `/album/[id]`, `/playlists/[id]`. The implementer audits every `+page.svelte` and adds a ``; missing pages fall back to the document-level `{{ .AppName }}` from the templated shell. **Shell header.** `Shell.svelte` currently renders the literal text `Minstrel` in the header. Replace with `{appName()}`. One-line change. **Theme-color follow-through.** The two server-templated `` tags with prefers-color-scheme media queries cover first paint and pre-hydration. After hydration, `+layout.svelte` adds an `$effect` that updates a single live `` element's `content` attribute reactively against `theme.resolved` (the existing rune from #362): ```ts $effect(() => { const meta = document.querySelector( 'meta[name="theme-color"]:not([media])' ) ?? (() => { const m = document.createElement('meta'); m.name = 'theme-color'; document.head.appendChild(m); return m; })(); meta.content = theme.resolved === 'light' ? '#F8F5EE' : '#14171A'; }); ``` The result: on a phone, when the user toggles Dark / Light / System in Settings, the address-bar / status-bar tint follows in real time. ## OG / share image `web/static/brand/og-image.png` — committed PNG, 1200×630, fresh asset. The slice ships a **placeholder** generated once via `tools/gen-og-placeholder.mjs`: - A small Node script (one-off; runs locally, not in CI) using `@resvg/resvg-js` (or `sharp` if simpler) to rasterise a tiny inline SVG. - SVG: Fraunces wordmark "Minstrel" in `parchment` (#E8E4D8), centred on `obsidian` (#14171A) background, accent forest-teal `#4A6B5C` underline rule. - Output committed to `web/static/brand/og-image.png`. Operators / future hand-designed artwork drop a replacement PNG at the same path — no code change required. If you'd rather treat the hand-designed artwork as a hard prerequisite and skip the placeholder generator, drop `tools/gen-og-placeholder.mjs` from this slice and add a single hand-designed PNG instead. ## README rewrite New `README.md` structure (operator-first, top-down): 1. **What is Minstrel** — one paragraph, tagline; `` placeholder for a real screenshot to be added later. 2. **Highlights** — six bullets distilled from `docs/smart-music-server-spec.md`: - OpenSubsonic-compatible (works with existing Subsonic clients). - Server-side smart shuffle (state lives where it belongs). - Dual-like model (general + contextual likes). - Session-aware radio + ListenBrainz similarity. - Lidarr integration for library import + automation. - Web SPA today; Flutter companion in flight (#356). 3. **Quickstart** — copy-pasteable `docker compose up` block; minimal `compose.yaml` and `config.yaml` snippets; pointer to `config.example.yaml` for the full surface. 4. **Configuration** — most-asked env vars at a glance: `SMARTMUSIC_STORAGE_DATA_DIR`, `SMARTMUSIC_BRANDING_APP_NAME`, scanner settings, Lidarr / ListenBrainz pointers. 5. **Updating** — `:main` rolling vs `:v1.0.x` pinned; brief note on database migrations being automatic. 6. **Specs** — links to the existing `docs/smart-music-{server,client}-spec.md`. 7. **Development** — current dev workflow / dual-process content, condensed and moved to the bottom (contributor audience). 8. **License**. Stale content from the current README is replaced rather than appended. The CI / container-image section becomes a sub-section of "Updating" with current Forgejo Actions truth. ## Error handling | Failure mode | Behaviour | |---|---| | `BrandingConfig.AppName == ""` | Falls back to `"Minstrel"` in `config.Load`; SPA `appName()` helper has the same fallback so a missing inline-script bootstrap can't crash titles. | | `BrandingConfig.Description == ""` | Falls back to default tagline as above. | | `branding:` section absent from yaml | Same as above — zero values become defaults. | | Operator sets `AppName` to a string containing HTML / JS | `html/template` escapes safely in both meta-attribute and JS-string contexts. Verified by test (see below). | | `og-image.png` missing on disk | Static file 404; share-preview crawlers tolerate broken images. Placeholder generator above prevents this in practice. | | `+page.svelte` author forgets to add `` | Falls back to document-level `{{ .AppName }}` — visible but not catastrophic. | ## Testing Tests are written but **not run during implementation** — CI verifies on push (per project rule). **Go (`internal/server/spa_test.go`, new):** - Render index.html with `BrandingCfg{AppName: "Custom", Description: "Custom desc."}`; assert `Custom` present, `og:title` contains `Custom`, inline `window.__MINSTREL__` block present and parseable. - Render with zero-value config; assert defaults `"Minstrel"` and the default tagline applied. - Render with `AppName = ""`; assert the rendered body contains escaped output (no live `` close inside the inline `