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:
@@ -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.
|
||||
Reference in New Issue
Block a user