Reviewed by /plan-design-review on 2026-05-21 Branch: dghauri0/setlist-redesign Repo: ihnyc/avi-pub-landing Status: SHIPPED (PR #65)
Problem Statement
/setlist/ was technically correct but UX-overwhelming. The page dumped every song ever identified by the Pi song-ID pipeline, grouped by night, in a single 560px-wide column with a Load more button at the bottom. With hundreds of rows accumulating since the pipeline shipped, the page asked every visitor to scroll a wall of songs to find anything.
The most common visitor case is a resident pulled up the page on their phone next to the bar asking “what’s playing right now / what just played?” — for that case, the existing design forced them to scan past last Tuesday before they got to anything useful. There was also no way to filter or search, and the Load more button is the worst possible UX for accessing history.
A separate but adjacent issue: each row had two brand icons (Spotify + Apple Music) as direct links and a text source pill at the end (Spotify / Aux / DJ) describing where the audio came from. The “Spotify” text pill collided visually with the Spotify brand icon — same word, different concept.
Constraints
- Single self-contained
public/setlist/index.html(inline<style>+<script>, no build step). - Must preserve all live-data plumbing: cursor pagination via
/api/now-playing/setlist?limit=&before=, SSE wire to/api/now-playing/currentwith zombie-watchdog reconnect, dedupe-by-id, 5am night-boundary grouping. - Must continue to handle the playing, quiet, and offline states documented in Setlist live state.
- Mobile-first viewing (most visitors are on phones at the pub) but desktop / TV browsers are common enough to deserve a non-portrait layout.
- The API has no search endpoint — pagination is by
beforecursor only. Search must be client-side over loaded rows.
Premises
- Default to Tonight. The visitor case dominates. History is recoverable behind one chip click.
- Search filters within the active scope. No new API endpoint required. Tonight + Last night + This week fit comfortably in memory (typically < 500 rows). All-time defers to infinite scroll.
- One quick-tap streaming service is worth more than a tidy menu. Most setlist visitors are Spotify users hunting one song. A split-button (Spotify direct + caret menu for the rest) preserves the 1-tap case while scaling to N services.
- Source provenance is metadata, not content. It tells a curious person where the audio came from but should not compete with the song’s title for attention. Demote text pill → small monochrome icon at the start of the row.
Decisions Locked During /plan-design-review
Four binary forks were resolved interactively. Each decision is recorded here with its alternative so future sessions know what was considered:
| Decision | Chosen | Alternative rejected |
|---|---|---|
| D5a — Default view | Default = Tonight + chips | Show everything; add filter chips on top |
| D5b — Load More button | Remove; infinite scroll within scope | Keep but only show when a “wants more” filter is active |
| D5c — Streaming services per row | Single “open in…” menu (later refined to split-button) | Keep Spotify+Apple; add Tidal; add Tidal+YouTube; long-press menu |
| D5d — Desktop width | Widen to ~720px | Keep 560px portrait layout everywhere |
| D5e — Menu refinement | Split-button: Spotify quick + caret for the rest | Pure ellipsis menu; long-press menu |
Recommended Approach (chosen + shipped)
Layout
max-width: 720pxdesktop / fluid on mobile.- Sticky filter bar — search input + chip row pin to the top of the viewport while scrolling.
backdrop-filter: blur(8px)over the dark surface so content peeking under stays legible. - Page-subtitle live stats —
<N> songs across <M> nights (tonight)while idle;<N> matches for "query" in tonightwhile searching. Numbers in soft-accent color.
Scope chips
Tonight (default, active) / Last night / This week / All time. Driven by an activeScope state variable + a scopeIncludes(scope, nightKey) predicate. Bounded scopes auto-backfill history until scopeSatisfiedByLoadedHistory() returns true (oldest loaded night-key is older than the scope’s earliest allowed night). “All time” reports unsatisfied forever — infinite scroll drives it.
Search
Debounced 150ms client-side filter (title.includes(q) || artist.includes(q), case-insensitive). Matched rows get class="is-match" with a soft accent-tinted background; non-matches get class="is-hidden". Empty queries clear both classes. If a search filters every row out of a night, the whole <section class="night-group"> is hidden (no orphan headings).
Split-button “open in” affordance
[ Spotify icon (1-tap) | ▾ caret → popover ]
Quick service is hard-coded to Spotify (most-recognized US service). The caret opens a <details> popover with Apple Music / Tidal / YouTube Music. Native <details> element means it works without heavy JS; close behavior is two document-level click handlers:
- Outside-click → close any open menu (
document.querySelectorAll('details.track-menu[open]')→m.open = falsefor anymthat doesn’t contain the click target). - Popover-link followed → close the menu containing that link (so the next scroll doesn’t leave a floating popover hovering).
Source icons (replacing text pill)
Three monochrome inline SVGs at ~18px:
program→ speaker cone (Spotify program speakers)wallstereo→ phono-jack tip (Aux input)wallxlr→ turntable + tonearm (Live DJ)
Position: just after track-time, before track-body. Opacity 0.7 so they don’t compete with title text. title= and aria-label= give the long-form label on hover/SR.
Infinite scroll
IntersectionObserver on a 1px sentinel at the bottom of the rendered setlist, rootMargin: 600px. Fires only when activeScope === 'all' (bounded scopes don’t need it). Re-entry guard via isFetching boolean.
Brand SVGs added
public/assets/streaming/tidal.svg— simpleicons.org/tidal (MIT). Triangle-trio mark on white-square background, brand black.public/assets/streaming/youtube-music.svg— simpleicons.org/youtubemusic (MIT). Circle + play-triangle, brand red on white circle.
Both follow the same white-background-shape → brand-color-path pattern as the existing spotify.svg and apple-music.svg so the brand cutouts render correctly on any surface.
State Model (single source of truth)
All loaded tracks live in a Map<string id, Track> called tracksById. The renderer (rerender()) builds the DOM from this map plus active scope + search query every time anything changes. This pattern intentionally diverges from the previous design’s “DOM is the truth” model:
| Previous | New |
|---|---|
Track rows accumulated in DOM via appendTrack() / prependTrack() | Tracks accumulated in tracksById Map, DOM rebuilt by rerender() |
groupsByNight Map held DOM-element refs | Groups computed fresh per rerender() |
isAlreadyShown(id) queried the DOM | ingestTracks() dedupes against the Map |
The shift trades a tiny perf cost (full DOM rebuild on each scope/search change) for predictable filtering semantics. With < 500 rows in a typical scope, the rebuild is imperceptible.
What Was Preserved
- Cursor pagination via
/api/now-playing/setlist?limit=&before=. - SSE wire to
/api/now-playing/currentwith the 90-second zombie watchdog, exponential backoff, ping/track/status/error event handlers. - 5am night-boundary grouping (anything before 5am belongs to the previous night).
Tonight ·/Last night ·night-label prefixes.- The three-state Now Playing slot (playing card / quiet card / offline banner).
- Tizen-safe album-art paint order (
<img>attached to DOM beforesrcis set). - ISRC-based Spotify search (precise) with title+artist fallback.
- XSS-safe
escapeHtml()for any user-influenced strings in subtitle.
Implementation Notes for Future Sessions
- All decisions are in
public/setlist/index.html. The file is intentionally self-contained —<style>+ HTML +<script>in one. Quartz-relevant<details>open/close handlers are at the bottom of the script block. - New brand SVGs live at
public/assets/streaming/{tidal,youtube-music}.svg. - If the API ever gains a
/api/now-playing/setlist/search?q=endpoint, the client-side filter inrerender()becomes a fallback — switch to server-side search when the active query is non-empty and the active scope isall. - The chip-row uses
overflow-x: auto; scrollbar-width: noneso it gracefully degrades to horizontal scroll if more chips are added. Currently four chips fit on every viewport ≥ 360px. - The split-button can grow to include more services without UI changes — append to
MENU_SERVICESand add an entry toSTREAMING. The popover stacks vertically.
Related Work
- Now Playing and setlist system
- Current setlist behavior
- bar-TV split design doc — the broader pub display story this setlist lives next to.
- Non-API Routes —
/setlist/is now indexed there.