Per-route titles, MINSTREL_APP_NAME via Go template injection of the embedded index.html, OG/Twitter meta + theme-color follow-through for the dark/light toggle, and an operator-first README rewrite. PWA / service worker / install icons explicitly out of scope (Flutter #356 + Tauri post-v1 cover mobile and desktop). OG image ships as a generated placeholder; hand-designed artwork drops in later.
14 KiB
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.
<meta name="theme-color">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/instanceruntime 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
- <title>{{ .AppName }}</title> ← default; per-route overrides
- <meta property="og:title" content="{{ .AppName }}">
- <meta property="og:description" content="{{ .Description }}">
- <meta property="og:image" content="/brand/og-image.png">
- <meta name="twitter:card" content="summary_large_image">
- <meta name="theme-color" media="(prefers-color-scheme: dark)"
content="#14171A">
- <meta name="theme-color" media="(prefers-color-scheme: light)"
content="#F8F5EE">
- <script>window.__MINSTREL__ = { appName: "...", description: "..." };</script>
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:
- 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.
- Once the server is templating HTML for the crawlers, exposing the same values via an inline
<script>window.__MINSTREL__ = ...</script>block is free for the SPA — no fetch round-trip, no loading state, no flash of "Minstrel" before the operator's chosen name renders. - 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:
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:
AppNameempty →"Minstrel"Descriptionempty →"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.htmlfrom the embedded SPA filesystem and parses it ashtml/template.Template. ServeHTTPrenders the template into abytes.Bufferagainst a small data struct ({ AppName, Description, OGImageURL }) and writes the body. Cache-control isno-cacheso 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 <script> block. An operator who sets AppName="</script><img onerror=...>" gets their string safely escaped in both contexts.
SPA wiring
$lib/branding.ts (new, ~10 lines):
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 <svelte:head> block:
<svelte:head><title>{pageTitle('Search')}</title></svelte:head>
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:head><title>{pageTitle(artist.data ? `Artist · ${artist.data.name}` : 'Artist')}</title></svelte:head>
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 <svelte:head>; missing pages fall back to the document-level <title>{{ .AppName }}</title> 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 <meta name="theme-color"> 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 <meta name="theme-color"> element's content attribute reactively against theme.resolved (the existing rune from #362):
$effect(() => {
const meta = document.querySelector<HTMLMetaElement>(
'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(orsharpif simpler) to rasterise a tiny inline SVG. - SVG: Fraunces wordmark "Minstrel" in
parchment(#E8E4D8), centred onobsidian(#14171A) background, accent forest-teal#4A6B5Cunderline 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):
- What is Minstrel — one paragraph, tagline;
<!-- TODO: screenshot -->placeholder for a real screenshot to be added later. - 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).
- Quickstart — copy-pasteable
docker compose upblock; minimalcompose.yamlandconfig.yamlsnippets; pointer toconfig.example.yamlfor the full surface. - Configuration — most-asked env vars at a glance:
SMARTMUSIC_STORAGE_DATA_DIR,SMARTMUSIC_BRANDING_APP_NAME, scanner settings, Lidarr / ListenBrainz pointers. - Updating —
:mainrolling vs:v1.0.xpinned; brief note on database migrations being automatic. - Specs — links to the existing
docs/smart-music-{server,client}-spec.md. - Development — current dev workflow / dual-process content, condensed and moved to the bottom (contributor audience).
- 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 <svelte:head> |
Falls back to document-level <title>{{ .AppName }}</title> — 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<title>Custom</title>present,og:titlecontainsCustom, inlinewindow.__MINSTREL__block present and parseable. - Render with zero-value config; assert defaults
"Minstrel"and the default tagline applied. - Render with
AppName = "</script><img onerror=alert(1)>"; assert the rendered body contains escaped output (no live</script>close inside the inline<script>block; no unescaped HTML in meta attributes).
SPA (Vitest):
pageTitle('Search')returns"Minstrel · Search"with stubwindow.__MINSTREL__.pageTitle()(no section) returns"Minstrel"only.appName()returns the global value when set, falls back to"Minstrel"whenwindow.__MINSTREL__undefined.Shell.svelterendersappName()value in its header (RTL query, no DOM scraping).- Theme-color
$effect: jsdom test that togglingtheme.resolvedbetween'dark'and'light'updates themeta[name="theme-color"]:not([media])element'scontentattribute.
README: not testable; manual review.
Migration / rollout
- No DB migration required.
- Config change is backwards-compatible: omitting the
branding:block applies defaults equivalent to current behaviour. - Existing operators upgrading from a pre-#363 build see no behaviour change unless they explicitly set
branding.app_name. Default tab title becomesMinstrel · {Page}instead of bareMinstrel, which is a visible improvement, not a regression.
File list
New:
internal/server/spa.go(templated index handler).internal/server/spa_test.go(template + escaping tests).web/src/lib/branding.ts(helper).web/src/lib/branding.test.ts(helper tests).web/static/brand/og-image.png(placeholder, committed).tools/gen-og-placeholder.mjs(one-off rasteriser; not run in CI).
Edited:
internal/config/config.go—BrandingConfigstruct + load defaults.cmd/minstrel/main.go— threadBrandingCfgthroughserver.New.internal/server/server.go—Newsignature gainsBrandingConfig; field stored onServer.config.example.yaml— documentbranding:section.web/src/app.html— replace literal<title>with{{ .AppName }}template token; add OG / theme-color meta with template tokens; add inline<script>window.__MINSTREL__ = ...block.web/src/routes/+layout.svelte— theme-color$effect.- Every
web/src/routes/**/+page.sveltelisted under "Routes to wire" — add<svelte:head>{pageTitle(...)}</svelte:head>. web/src/lib/components/Shell.svelte— header text →{appName()}.README.md— full operator-first rewrite.
Open items deferred to follow-up
- Hand-designed OG / share-image artwork (drop into
web/static/brand/og-image.pngwhenever ready). - Real screenshot for the README intro (
<!-- TODO: screenshot -->placeholder). - Localised page titles (English-only in v1).
- Per-page OG images.
These are tracked as comments in the relevant files, not separate Fable tasks.