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/current with 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 before cursor only. Search must be client-side over loaded rows.

Premises

  1. Default to Tonight. The visitor case dominates. History is recoverable behind one chip click.
  2. 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.
  3. 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.
  4. 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:

DecisionChosenAlternative rejected
D5a — Default viewDefault = Tonight + chipsShow everything; add filter chips on top
D5b — Load More buttonRemove; infinite scroll within scopeKeep but only show when a “wants more” filter is active
D5c — Streaming services per rowSingle “open in…” menu (later refined to split-button)Keep Spotify+Apple; add Tidal; add Tidal+YouTube; long-press menu
D5d — Desktop widthWiden to ~720pxKeep 560px portrait layout everywhere
D5e — Menu refinementSplit-button: Spotify quick + caret for the restPure ellipsis menu; long-press menu

Layout

  • max-width: 720px desktop / 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 tonight while 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.

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:

  1. Outside-click → close any open menu (document.querySelectorAll('details.track-menu[open]')m.open = false for any m that doesn’t contain the click target).
  2. 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:

PreviousNew
Track rows accumulated in DOM via appendTrack() / prependTrack()Tracks accumulated in tracksById Map, DOM rebuilt by rerender()
groupsByNight Map held DOM-element refsGroups computed fresh per rerender()
isAlreadyShown(id) queried the DOMingestTracks() 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/current with 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 before src is 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 in rerender() becomes a fallback — switch to server-side search when the active query is non-empty and the active scope is all.
  • The chip-row uses overflow-x: auto; scrollbar-width: none so 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_SERVICES and add an entry to STREAMING. The popover stacks vertically.