docs(m7-363): branding polish design spec

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.
This commit is contained in:
2026-05-03 15:27:39 -04:00
parent 7d82be864d
commit d56edadf0e
@@ -0,0 +1,253 @@
# 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/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
- <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:
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 `<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.
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 `<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):
```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 `<svelte:head>` block:
```svelte
<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
<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):
```ts
$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` (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; `<!-- TODO: screenshot -->` 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 `<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: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 = "</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 stub `window.__MINSTREL__`.
- `pageTitle()` (no section) returns `"Minstrel"` only.
- `appName()` returns the global value when set, falls back to `"Minstrel"` when `window.__MINSTREL__` undefined.
- `Shell.svelte` renders `appName()` value in its header (RTL query, no DOM scraping).
- Theme-color `$effect`: jsdom test that toggling `theme.resolved` between `'dark'` and `'light'` updates the `meta[name="theme-color"]:not([media])` element's `content` attribute.
**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 becomes `Minstrel · {Page}` instead of bare `Minstrel`, 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``BrandingConfig` struct + load defaults.
- `cmd/minstrel/main.go` — thread `BrandingCfg` through `server.New`.
- `internal/server/server.go``New` signature gains `BrandingConfig`; field stored on `Server`.
- `config.example.yaml` — document `branding:` 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.svelte` listed 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.png` whenever 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.