From d56edadf0e011ba4601675c5e62e4500b25d182e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 15:27:39 -0400 Subject: [PATCH] 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. --- ...026-05-03-m7-363-branding-polish-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-m7-363-branding-polish-design.md diff --git a/docs/superpowers/specs/2026-05-03-m7-363-branding-polish-design.md b/docs/superpowers/specs/2026-05-03-m7-363-branding-polish-design.md new file mode 100644 index 00000000..cfeb057c --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-m7-363-branding-polish-design.md @@ -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. +- `` 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 `