Files
deepdrft/product-notes/phase-11-public-site-enhancements.md
T
daniel-c-harvey e9f4411fdf docs(plan): revise Phase 11 — ordinal, full stack retirement, shared cards, release-share, queue
Fold Daniel's 2026-06-15 decisions into PLAN.md §11 and the product note:
4→7 commitments, six waves. Headline: the track ordinal already shipped
in Phase 8, so commitment 5 is verify-and-consume, not a new migration.
Queue half of §1.3 absorbed; preload stays deferred.
2026-06-15 23:30:28 -04:00

64 KiB
Raw Blame History

Phase 11 — Public Site Enhancements

Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel resolved the open questions and expanded scope). Plan only — no code edits made by this doc.

Cross-references: PLAN.md §11 (the concise phase entry), PLAN.md §1.3 (preload/queue — now absorbed into this phase), product-notes/phase-9-release-medium-types.md (the medium model — ReleaseMedium enum, the per-medium detail-page strategy, the ReleaseDetailScaffold contract), COMPLETED.md §9.8 (Wave 8 remediation — the /archive release-cardinal browser, the inline nav, the TracksView demotion), memory One source, multiple views and Design for adaptability up front.


0. Why this phase exists, and the state it inherits

Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface. Wave 8 then remediated the public side: /archive became a release-cardinal searchable browser, the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal /tracks gallery was demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14).

Phase 11 is the next coherent pass over the public listening surface. Daniel's hands-on use surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the scope to seven. They share one spine: make the release the cardinal unit of the public site, make every navigation an addressable, shareable URL, and make the album a first-class playable object (ordered, queue-able, shareable). The seven:

  1. Cuts detail page (/cuts/{id}) — structural; the phase's center of gravity.
  2. Player-bar release-title → release detail via a medium→route resolver — structural.
  3. Retire the whole track-cardinal stack and normalize release-card rendering into shared components — reduction + normalization (the heart of 11.C).
  4. Archive filters in the URL — addressability polish.
  5. Explicit track-ordinal column on the track model, editable from the CMS — new scope; data-model + migration; gates correct /cuts/{id} ordering.
  6. Release-level Sharenew scope; a Cut/Session/Mix header Share shares the release URL.
  7. Play-queue systemnew scope; absorbs the deferred PLAN.md §1.3. The Cuts "play album" affordance is its first consumer.

This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse pages already share a ReleaseGallery card component; the detail pages already share ReleaseDetailScaffold; the Archive already has all filter state). The structural new work is the Cuts detail page and the queue model (the one real architecture decision). Everything else is closing asymmetries and consolidating rendering that drifted into per-surface copies.

Headline correction on commitment 5 (read against live source, 2026-06-15). Daniel asked for "an explicit ordinal column, editable from the CMS, with a Daniel-gated migration." That column already exists and shipped in Phase 8. TrackEntity.TrackNumber is an explicit 1-based, non-null ordinal (default 1) — not insertion-order — with its migration (20260611005700) already applied, its DTO mirror, its API write path (validated > 0), and CMS reorder (BatchEdit assigns ordinal from list position on submit). The read path already sorts on it (ReleaseRepository.OrderBy(t => t.TrackNumber)). So commitment 5 carries no new column, no new migration, and no Daniel-gated apply step. What remains is verify-and-consume: confirm TrackDto.TrackNumber is populated on the public read path the Cut page uses, and ensure CutDetailViewModel orders by it. The reorder-UX question Daniel asked me to surface is also already answered in the CMS — BatchEdit reorders by list position, not a numeric field. This reframing is the most important single change in this revision; §3a carries the detail. If hands-on use reveals a gap (e.g. the public track read does not project TrackNumber), that is a small wiring fix, not the schema project the brief anticipated.

What already exists (verified against live source, 2026-06-15)

Surface State File
/sessions/{id} detail Exists, mature. Hero-dominant overlay; bridged prerender; play wiring. Pages/SessionDetail.razor
/mixes/{id} detail Exists, mature. Full-page WebGL waveform background + scaffold; bridged prerender. Pages/MixDetail.razor
ReleaseDetailScaffold Exists. Invariant trio (back link, masthead, play/share) + Hero/MetaContent slots. Composed by TrackDetail and MixDetail. Controls/ReleaseDetailScaffold.razor
ReleaseDetailBase Exists. Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via releaseId-filtered track page). Pages/ReleaseDetailBase.cs
/cuts gallery Exists as AlbumsView (medium-parameterized card grid). Cards open /tracks?album={title}. Renders its card markup inline (album-card), does NOT use ReleaseGallery. Pages/AlbumsView.razor
/sessions, /mixes galleries Exist as SessionsView/MixesView, both inheriting MediumBrowseBase and both composing the shared ReleaseGallery card grid (with a DetailRoute param). Pages/{Sessions,Mixes}View.razor
ReleaseGallery Exists. The shared release-card grid — cover (+ --fallback), title, artist. Cards link <a href="/{DetailRoute}/{id}">. Consumed only by Sessions/Mixes today; Archive and Cuts re-implement equivalent markup inline. Controls/ReleaseGallery.razor
/archive browser Exists, release-cardinal. Debounced search + medium chips + genre filter. Cards route per-medium via a private DetailHref switch. Renders card markup inline (archive-release-card), does NOT use ReleaseGallery. Filters held in component fields, NOT in the URL. Pages/ArchiveView.razor(.cs)
/track/{EntryKey} detail Exists as TrackDetail — the track-cardinal detail the player-bar title links to. Pages/TrackDetail.razor
/albums Exists as a permanent redirect → /cuts. Pages/AlbumsRedirect.razor
SharePopover Exists, track-keyed. Takes an EntryKey; "Copy link" + "Embed player". No release-level share target. Controls/SharePopover.razor
Play queue / playlist Does not exist. Player is single-slot (StreamingAudioPlayerService holds one CurrentTrack). No notion of "next." PLAN.md §1.3 (preload + queue) is deferred — now absorbed here.
TrackEntity ordinal ALREADY EXISTS — landed in Phase 8. TrackEntity.TrackNumber (int, 1-based, default 1, non-null), column track_number, migration 20260611005700_AddReleaseTypeAndTrackNumber already applied. TrackDto.TrackNumber mirrors it; TrackConverter round-trips it; UpdateTrackMetadataRequest.TrackNumber + TrackController validate (> 0) and persist it; BatchEdit already sets it from reorderable list position on submit; ReleaseRepository.GetTracks already .OrderBy(t => t.TrackNumber). Commitment 5 is not new schema — it is verify-and-consume. TrackEntity.cs:17, TrackConfiguration.cs:37, BatchEdit.razor:192/225

Three framing corrections (the brief's vocabulary vs. the live routes)

The brief's framing is directionally right but uses route names that do not match the code. Naming these up front so the implementer is not misled:

  1. There is no /tracks/{id} route. The track-cardinal detail route is /track/{EntryKey} (TrackDetail), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar title links to /track/{Track.EntryKey} (TrackMetaLabel.razor line 9). When the brief says "/tracks/{id} becomes a pure router," the intent is: the thing a release-title click lands on should resolve medium → the correct dedicated detail page. See §2 for what this actually means against the live routes — the brief's "router" is better realized as a small medium→route resolver than as a literal /tracks/{id} page, because the player bar carries a track, not a release id.

  2. Cuts genuinely have no single-release detail page. This is the real asymmetry. /cuts (AlbumsView) cards and /archive Cut cards both open /tracks?album={title} — the track gallery filtered to the album title. Sessions and Mixes route to dedicated /sessions/{id} / /mixes/{id} pages. The new /cuts/{id} page (§3) closes this gap and is the heart of the phase.

  3. /archive filters are already in component state but not the URL. ArchiveView holds _selectedMedium, _selectedGenre, SearchText as private fields with no [SupplyParameterFromQuery] and no NavigateTo on filter change. Requirement 4 is therefore a URL-binding pass over an existing browser, not a new feature. The pattern to borrow already lives next door: TracksView reads ?album=/?genre=/?q= via [SupplyParameterFromQuery] (TracksView.razor.cs lines 2123).


1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)

The original four (14) plus three Daniel added when he resolved the open questions (57).

  1. Player-bar release-title → release detail, via a medium→route resolver. DECIDED (2026-06-15): the release-title click resolves the release's ReleaseMedium → the correct dedicated detail page (/cuts/{id} | /sessions/{id} | /mixes/{id}). Realized as a resolver helper at click sites (the player bar already carries release id + medium), plus a thin /tracks/{id} redirect page for bare-release-id deep links. (Was OQ1; resolved — see §2.)

  2. New /cuts/{id} page — album view.

    • Header section, left-aligned: release name, artist, genre, release year, plus Play and Share buttons.
    • Cover art large-ish, right, with a theme border around the image.
    • Below: an ordered track list (ordered by the new ordinal column — commitment 5), each row with a play button.
    • Header Play enqueues the whole album in ordinal order (commitment 7) and starts track 1.
  3. Retire the whole track-cardinal stack + normalize release-card rendering. DECIDED (2026-06-15): retire TracksView / TrackDetail / TrackCard / TracksGallery and the /tracks + /track/{EntryKey} routes entirely (not just the ?album branch). And normalize release-card presentation into shared component(s) consumed across /archive, /cuts, /sessions, /mixes (and the Cuts detail track-row where applicable). The shared ReleaseGallery already exists but only Sessions/Mixes use it — Archive and Cuts re-implement equivalent markup inline. This is the normalization heart of 11.C. (Was OQ5; resolved — see §4.)

  4. Archive search params in the URL. Search term, medium filter, genre filter all encoded in /archive?… so filter actions create navigable history anchors (back/forward, shareable links).

  5. Explicit track-ordinal column, editable from the CMS. IN SCOPE (2026-06-15) — but ALREADY SATISFIED by Phase 8. Daniel asked for an explicit ordinal (NOT insertion-order), editable from the CMS. The column (TrackEntity.TrackNumber, 1-based, non-null), its migration (already applied), DTO mirror, API write path, and CMS reorder (BatchEdit) all already exist. No new column, no new migration, no Daniel-gated apply step. Remaining work is verify-and-consume: confirm the public read path projects TrackNumber and that CutDetailViewModel orders by it. (Was OQ4 "do not assume into Phase 11"; Daniel reversed to in-scope — and the read confirms it is already built. See §3a.)

  6. Release-level Share. NEW SCOPE (2026-06-15): a Cut/Session/Mix header Share shares the release URL, not a single track's embed. SharePopover is track-keyed today; add a release-level share target. (Was an adjacent gap; promoted to scope. See §3b.)

  7. Play-queue system. NEW SCOPE (2026-06-15): Daniel — "now is the natural time for that." Absorbs the deferred PLAN.md §1.3 (preload/queue). A queue model the player consumes; the Cuts "play album" affordance (header Play → enqueue the release's tracks in ordinal order) is the first consumer. Carries an unresolved architecture question (queue inside IPlayerService vs. a separate orchestrating service) — framed with a recommendation in §3c; the final call is staff-engineer's at implementation. (Was an adjacent gap; promoted to scope. See §3c.)


2. Requirement 1 reframed — the medium→detail resolver

Recommendation: do not build a literal /tracks/{id} page. Build a medium → detail route resolver and apply it at the two click sites that need it. Here is why, and the three shapes considered.

What the player-bar title actually carries

The player bar holds a TrackDto (TrackMetaLabel.razor). A TrackDto carries a nested Release (with Id and Medium). So at the click site we already know the release id and the medium — there is no need for a round-trip "router page" that fetches the release to discover its medium. The resolver is a pure function:

Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}"

Three shapes

  • (i) Literal /tracks/{id} router page. A routable component that fetches the release by id, reads Medium, and NavigateTos the dedicated page (replace: true so the router never sits in history). Cost: a network round-trip and a flash of an empty page on every release-title click, to rediscover a medium the caller already knew. Justified only if some entry point has only a release id and not the medium (e.g. an external deep link to /tracks/{id}). Keep this as a thin fallback, not the primary path.

  • (ii) Pure resolver helper, applied at click sites (RECOMMENDED). A single ReleaseRoutes.DetailHref(ReleaseDto) (or (long id, ReleaseMedium medium)) helper — one table, one location — that every release-title / release-card click consumes. ArchiveView already has a private DetailHref switch (lines 121126); promote it to the shared helper so the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip, no flash. This is the One source, multiple views discipline applied to routing.

  • (iii) Both. The resolver helper (ii) is the primary path; a thin /tracks/{id} redirect page (i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's literal route. The redirect page consumes the same resolver helper.

Recommend (iii): resolver helper as the spine, plus a thin /tracks/{id} redirect page that reuses it. This satisfies the brief's literal "/tracks/{id} is a pure router" wording and gives the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The redirect page is ~15 lines and shares the resolver, so it is not a second source of truth.

DECIDED (Daniel, 2026-06-15): the player-bar title click now points at the release detail (via the resolver), not the track detail. TrackMetaLabel's <a href> changes from /track/{Track.EntryKey} to ReleaseRoutes.DetailHref(Track.Release). Implementation note: the link today sits on the track name (TrackMetaLabel.razor line 9). With the repoint it resolves to the release page; the artist/genre/year already render from Track.Release. This also removes TrackDetail's last inbound link — with commitment 3 retiring the whole track-cardinal stack, TrackDetail and /track/{EntryKey} are deleted outright (§4).

What "resolver" means for Cut

DetailHref for a Cut today returns /tracks?album={title}. After §3 lands, a Cut resolves to /cuts/{Id} (id-addressed, consistent with Session/Mix). This repoint is the hinge between requirements 1, 2, and 3 — see the dependency note in §6.


3. Requirement 2 — the /cuts/{id} album-detail page

This is the phase's center of gravity: the first multi-track release detail page. Sessions and Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single with an ordered track list.

3.1 Layout (Daniel's spec, literal)

┌─────────────────────────────────────────────────────────────┐
│  ← All cuts                                                   │
│                                                               │
│  ┌─────────────────────────────┐    ┌──────────────────────┐ │
│  │  RELEASE NAME (h3)          │    │                      │ │
│  │  Artist (h6, primary)       │    │   COVER ART          │ │
│  │  Genre · 2025               │    │   (large-ish,        │ │
│  │                             │    │    theme border)     │ │
│  │  [ ▶ Play ]  [ ⤴ Share ]    │    │                      │ │
│  └─────────────────────────────┘    └──────────────────────┘ │
│       ↑ header content LEFT              ↑ cover RIGHT         │
│                                                               │
│  ───────────────────────────────────────────────────────────│
│  1.  ▶   Track One                                    3:42    │
│  2.  ▶   Track Two                                    4:18    │
│  3.  ▶   Track Three                                  2:55    │
│  …                                                            │
└─────────────────────────────────────────────────────────────┘
  • Header left: release name, artist, genre, release year, Play + Share buttons.
  • Cover right: large, theme border around the image (a deepdrft--prefixed border using a palette token — mirror the existing cover treatments but with an explicit framed border, which is the new visual element).
  • Track list below: ordered rows, each with a play button. Row click / row play streams that track.
  • Header Play starts track 1.

3.2 Compose ReleaseDetailScaffold, or not? — the load-bearing design call

Phase 9 §5.3 established ReleaseDetailScaffold as the shared detail scaffold and committed to "refactor TrackDetail onto it; per-medium variance rides slots." The scaffold owns the invariant trio: back link, masthead (title + artist), play/share affordance. MixDetail composes it; SessionDetail deliberately diverges (overlay layout). The question for Cuts:

  • (i) Compose the scaffold. The Cut header is the invariant trio (title, artist, play, share) — almost exactly what the scaffold provides. The cover goes in the Hero slot; the genre/year go in MetaContent; the track list rides a new BodyContent slot (the scaffold has no body slot today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: "named slots are fine where genuinely needed, e.g. BodyContent for the Cut/Album multi-track listing").
  • (ii) Bespoke page (like SessionDetail). Full control over the left/right header split, but duplicates the play/share wiring and the back-link/masthead the scaffold already owns.

Recommend (i): compose the scaffold, add one BodyContent slot. Two caveats that decide whether this is clean or a fight:

  1. The header layout is left-content / right-cover; the scaffold's masthead is a top row. The scaffold today renders masthead-then-Hero vertically (MixDetail stacks cover below the masthead). The Cut layout wants masthead and cover side by side. This is a layout variance, and Phase 9 §5.3 is explicit: "a boolean layout parameter on the scaffold is a design failure — that variance belongs in a slot." So the right answer is not a HeroBesideMeta flag on the scaffold. Two clean options:

    • (a) The Cut page supplies its whole left+right header as the page content and uses the scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead is one arrangement and the Cut wants a different one, so the Cut composes a richer header into a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header.
    • (b) Generalize the scaffold's header region into a Header slot with the current masthead+play row as the default content, so MixDetail/TrackDetail are unchanged (default) and CutDetail supplies a left/right Header. This is the cleaner One source move but touches the scaffold's shared contract.
    • Recommend (b), but flag it: it is a scaffold-contract change that ripples to every composer. If Wave pressure makes (b) risky, (ii) bespoke page is the honest fallback — record it as deliberate divergence (as SessionDetail already is) rather than bending the scaffold with a boolean.
  2. The play/share affordance differs. The scaffold renders a PlayStateIcon (icon toggle) + SharePopover. Daniel's Cut spec says Play and Share buttons (labeled buttons, per the mockup). If the Cut wants text buttons rather than the icon idiom, that is a slot for the affordance row, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording).

3.3 Data path — the track list

The Cut page needs the release and its ordered tracks. Both primitives exist:

  • Release: IReleaseDataService.GetById(id)ReleaseDto (with Title, Artist, Genre, ReleaseDate, ImagePath, Medium, ReleaseType, TrackCount).
  • Tracks: the track-data service already supports a releaseId-filtered track page — ReleaseDetailViewModel.Load uses GetPage(pageNumber: 1, pageSize: 1, releaseId: …) to resolve the single track for Session/Mix. The Cut page issues the same call with a larger page size (cover the whole album — pageSize: 100 matches the gallery convention) to get the ordered list.

Ordering. DECIDED (2026-06-15): order by the new explicit ordinal column (commitment 5, §3a), not insertion order. The Cut track list reads tracks for the release sorted ascending by ordinal. This makes the ordinal column a hard dependency of correct /cuts/{id} ordering — 11.A's track list cannot render in the right order until the column exists and the read sorts on it. See §3a for the column's full cross-stack spec and §6 for the wave dependency (the ordinal work gates 11.A's ordering, so it sequences first).

New CutDetailViewModel vs. extend ReleaseDetailViewModel. ReleaseDetailViewModel resolves one track. The Cut page needs many. Two options:

  • Extend ReleaseDetailViewModel with an optional Tracks collection populated when the medium is Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought.
  • A dedicated CutDetailViewModel (loads release + full track list). Cleaner SRP; the Cut detail is genuinely a different shape (multi-track) from the single-release VM.

Recommend a dedicated CutDetailViewModel + a CutDetail page deriving the same prerender-bridge discipline ReleaseDetailBase encodes (persist release + tracks across the prerender→WASM seam, guard restore on id). If the bridge logic is substantial, consider generalizing ReleaseDetailBase's bridge into a shared base both single- and multi-track details use — but only if it doesn't force a medium conditional into the base. Flag the choice; don't pre-commit the implementer.

3.4 Play wiring

  • Row play: stream the row's track (the TracksView.PlayTrack idiom — toggle if already current, else SelectTrackStreaming). Row play also sets the queue context to the album from that row forward (so the queue continues into the rest of the album after a mid-album row start) — see §3c for the enqueue semantics. If the queue work lands after 11.A's first cut, row play degrades cleanly to single-track streaming (the Design for adaptability up front seam).
  • Header Play: enqueue the whole album in ordinal order, start track 1. DECIDED (2026-06-15): this is the first consumer of the queue system (commitment 7, §3c). Header Play calls the queue's PlayRelease(tracks-in-ordinal-order) rather than a bare SelectTrackStreaming(track1). Because 11.A and the queue (11.F) may land in either order, design header Play as a single handler call that swaps from SelectTrackStreaming(Tracks.First()) to Queue.PlayRelease(Tracks) with no other change to the page — the seam Phase 11's earlier draft already anticipated, now made real by bringing the queue into scope.

3a. Commitment 5 — the track-ordinal column (already built; verify-and-consume)

Recommendation: do not spec a schema project. Verify the existing TrackNumber reaches the public Cut read, and consume it. The brief asked to "spec the implications across the stack" for a new ordinal column. The honest answer from the read is that the stack is already wired end to end; the only open risk is the public read projection.

3a.1 What already exists (each verified, 2026-06-15)

Layer State Evidence
Entity TrackEntity.TrackNumberint, 1-based, non-null, default 1. Explicit ordinal, not insertion-order. TrackEntity.cs:17
EF mapping track_number column, configured. TrackConfiguration.cs:37
Migration 20260611005700_AddReleaseTypeAndTrackNumberalready applied (it is in the snapshot and predates current dev). Migrations/
DTO TrackDto.TrackNumber mirrors it; TrackConverter maps both directions. TrackDto.cs:18, TrackConverter.cs:75/91
API write UpdateTrackMetadataRequest.TrackNumber (int?); TrackController validates > 0 (400 otherwise), persists to the track row. Upload path resolves to 1 when unset. TrackController.cs:263/395-403
CMS edit/reorder BatchEdit loads tracks sortColumn: "TrackNumber", holds them as a reorderable row list (BatchRowModel.TrackNumber), and assigns each row its ordinal from list position on submit. BatchEdit.razor:192/225
Read sort (release tracks) ReleaseRepository.GetTracks already .OrderBy(t => t.TrackNumber). TrackManager sort switch includes "TrackNumber". ReleaseRepository.cs:117, TrackManager.cs:122

3a.2 The reorder-UX question Daniel asked me to surface — already answered

Daniel asked: drag-reorder vs. numeric field, recommend the simplest shippable. It is already shipped, and it is neither a bare numeric field nor drag-and-drop — it is list-position ordinal assignment: BatchEdit presents the release's tracks as an ordered row list and writes TrackNumber = position on submit. Editing order = reordering rows. That is the simplest correct model (the ordinal is derived, never hand-typed, so it cannot collide or skip). No change wanted here unless Daniel specifically wants drag affordance polish in the CMS — which is out of Phase 11 (CMS, not the public site) and should be raised separately if desired.

3a.3 The one real verify step — the public read projection

The CMS read (CmsTrackService.GetPagedAsync) sorts and carries TrackNumber. The Cut page uses the public track-data service's releaseId-filtered page. The single concrete task for 11.A is to confirm that public read both sorts by TrackNumber and projects it onto TrackDto, so CutDetailViewModel renders rows in saved order and can label the track number. If the public read already orders by TrackNumber (likely — it shares TrackManager's sort switch), 11.A's ordering is free. If it does not, the fix is a one-line sort argument, not a migration. This is why the "ordinal gates /cuts/{id} ordering" dependency in §6 collapses to a verification, not a wave.

Net effect on the waves: commitment 5 does not become its own wave. It folds into 11.A as a verification checklist item ("public read projects + sorts TrackNumber"). The brief's worry about a Daniel-gated migration does not apply — the migration already ran.


3b. Commitment 6 — release-level Share

Recommendation: add a release-keyed mode to SharePopover, sharing the resolved release URL; keep the track-keyed mode for any surface that still shares a single track. This is an additive extension of an existing control, not a new control.

3b.1 What SharePopover does today

SharePopover takes an EntryKey (a track) and offers "Copy link" + "Embed player." Both targets are track-scoped. A Cut/Session/Mix header has no way to share the release page — only a track's embed. After §2 every release has a canonical detail URL (ReleaseRoutes.DetailHref), so a release-level share target now has a well-defined thing to copy.

3b.2 Three shapes

  • (i) New ReleaseSharePopover control. Clean separation, but duplicates the popover chrome, copy-to-clipboard plumbing, and styling. Two controls to keep in visual sync.
  • (ii) SharePopover gains a release mode (RECOMMENDED). Add an optional release target (ReleaseDto or (long id, ReleaseMedium medium)) alongside the existing EntryKey. When the release target is set, "Copy link" copies ReleaseRoutes.DetailHref(release) (absolute URL); the "Embed player" affordance is hidden or repurposed (a release page is not a single-track embed — see the open sub-question below). One control, two modes, one source of clipboard/chrome logic — the One source, multiple views discipline applied to share.
  • (iii) Make SharePopover polymorphic over a share-target abstraction (IShareTarget with Title, Url, EmbedMarkup?). Most general; over-built for two cases today. Note as the shape to reach for only if a third share target appears (e.g. a playlist/queue share once §3c lands).

Recommend (ii) now, with (iii) noted as the refactor target if a third target appears. The Cut header's Share button (the labeled-button-vs-icon question in §3.2.2 applies here too) opens the popover in release mode.

3b.3 Open sub-question (surface, recommend)

  • Does release-share keep an "Embed player" option? A release page is multi-track (Cut) or a single hero track (Session/Mix). For Cuts there is no single embeddable track, so "Embed player" should be hidden in release mode (copy-link only). For Session/Mix the release is effectively one track, so embed could still make sense — but to keep the contract simple, recommend release mode is copy-link-only across all media and the per-track embed stays available only where a track is the share subject. Flag for Daniel; trivial either way.

3c. Commitment 7 — the play-queue system (absorbs PLAN.md §1.3)

This is the phase's one real architecture decision and Daniel has explicitly asked for a strong steer while leaving the final call to staff-engineer. The brief's framing — "queue inside IPlayerService vs. a separate orchestrating service" — is the right axis. Recommendation first, then the two shapes, then the seams to the existing player and the §1.3 preload relationship.

Recommendation: a separate IQueueService that orchestrates above the single-slot StreamingAudioPlayerService. The player stays a single-slot device; the queue owns "what plays next" and drives the player via its existing select-and-stream API. This is the cleaner separation and it matches the deferred §1.3's own framing ("the player is a single-slot device that a future PlaylistService orchestrates above").

3c.1 The two shapes

  • (i) Queue inside IPlayerService (single-slot player gains a queue). The player holds CurrentTrack and a Queue<TrackDto> and a position pointer; Next/Previous/enqueue are player methods. Pro: one service, no coordination; the player already owns the end-of-stream event that would advance the queue. Con: the player's responsibility balloons from "stream one track" to "manage a playlist," which is the SRP smell — and it forecloses the case where the queue outlives a player instance or where a non-audio surface (a visible up-next list) wants the queue state without coupling to the streaming device.

  • (ii) Separate IQueueService orchestrating above the player (RECOMMENDED). QueueService owns the ordered list, the current index, and PlayRelease / Enqueue / Next / Previous / Clear. It drives playback by calling the player's existing SelectTrackStreaming(track). It subscribes to the player's end-of-stream / track-ended signal to auto-advance. Pro: the player stays single-purpose (the load-bearing streaming seam in CLAUDE.md is untouched); the queue is independently testable and independently observable (the player bar and a future up-next panel both read QueueService state); it is the natural home for the §1.3 preload trigger (the queue knows the next track, so it owns the "prefetch next at threshold" decision). Con: a coordination seam — the queue must observe the player's track-ended event reliably (the player must expose one; verify the current OnTrackEnded/equivalent hook exists or spec adding it).

3c.2 The player seam (what the queue needs from the player)

The queue orchestrator needs three things from StreamingAudioPlayerService, all of which are either present or a small addition:

  1. A way to start a trackSelectTrackStreaming(track) exists (the Cut row-play and TracksView already call it). The queue calls the same path; no new player surface.
  2. A track-ended signal — the queue auto-advances on natural end-of-stream. Verify the player raises a track-ended/playback-complete event today; if it only exposes position/state, the one genuine player addition is a TrackEnded event (or the queue polls "state == ended"). Spec this as the single new player-side hook.
  3. State for the player bar — "is there a next?", "is there a previous?" so the bar can enable skip-forward / skip-back. The bar reads QueueService.HasNext / HasPrevious.

3c.3 Queue data model (minimal, shippable)

QueueService
  IReadOnlyList<TrackDto> Items          // ordered; the release's tracks for "play album"
  int CurrentIndex                        // -1 when empty
  TrackDto? Current => Items[CurrentIndex]
  bool HasNext / HasPrevious
  PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)   // Cuts header Play / row Play
  Enqueue(TrackDto) / EnqueueRange(IEnumerable<TrackDto>)
  Next() / Previous()                     // advance index, drive player.SelectTrackStreaming
  Clear()
  event Action QueueChanged               // player bar re-renders skip affordances

PlayRelease(tracks) is the Cuts "play album" entry point (commitment 2's header Play): pass the release's tracks in ordinal order (already sorted, §3a), startIndex: 0. Row play is PlayRelease(tracks, startIndex: clickedRow) — start mid-album, queue continues to the end (§3.4).

3c.4 Player-bar UI

The player bar today shows the current track + transport. The queue adds skip-forward and skip-back controls (enabled per HasNext/HasPrevious), wired to QueueService.Next/Previous. Optional, deferred-within-phase: a visible up-next list (the queue's Items past CurrentIndex). Recommend skip controls in scope; the up-next panel as a [speculative] follow-on so the queue work ships without waiting on a new UI surface.

3c.5 Relationship to §1.3 preload — IN or OUT?

PLAN.md §1.3 is two things bundled: (a) a queue model ("a notion of next track") and (b) preload/prefetch (begin the next track's bytes during the current track's tail). Commitment 7 brings (a) the queue model fully into Phase 11. Recommend (b) preload stays OUT of Phase 11 — ship the queue (correct next-track semantics, skip controls, play-album) first; preload is a perceived-latency optimization on top of a working queue, and it is the prerequisite for crossfade (§1.4) and gapless (§1.5), which are their own later work. Bringing preload in now couples the queue ship to a staged-decoder change in the streaming path (the most load-bearing seam in the system).

Design the seam, defer the feature (memory Design for adaptability up front): QueueService knows the next track, so it is the natural owner of a future "prefetch next at threshold" trigger. Spec the queue so that adding preload later is adding a subscriber to the queue's "next is known" state, not restructuring the queue. Note in PLAN.md §1.3 that the queue half is absorbed into Phase 11; the preload half remains deferred there (and gates 1.4/1.5 as before).


4. Requirement 3 — full stack retirement + shared release-card normalization

DECIDED (2026-06-15): retire the whole track-cardinal stack (not just the ?album branch), and normalize release-card rendering into shared component(s) consumed across /archive, /cuts, /sessions, /mixes. This is two moves — a reduction (delete dead surface) and a normalization (collapse duplicated card markup into one component). The normalization is the heart of 11.C; the reduction is its precondition cleanup.

4.1 The reduction — retire the track-cardinal stack (DECIDED, no longer a Daniel question)

Daniel confirmed the full retirement. The surface that goes:

Surface Route Why it is now dead
TracksView /tracks (+ ?album/?genre/?q) Archive subsumes browse/search; /cuts/{id} subsumes album view; nav-demoted since Phase 9 §8.I.
TrackDetail /track/{EntryKey} Loses its last inbound link once §2 repoints the player-bar title to the release.
TrackCard Only consumer is TracksGallery, which only TracksView uses.
TracksGallery Only consumer is TracksView.
GalleryViewMode Only the TracksView filter-mode enum.

Ordering of the retirement (the dependency chain):

  1. §3 — /cuts/{id} exists.
  2. §2 — ReleaseRoutes resolves Cut → /cuts/{id}; the resolver repoints the player-bar title to the release (removing TrackDetail's inbound link) and repoints AlbumsView + Archive Cut cards off /tracks?album.
  3. §4.1 — with no inbound links remaining, delete the whole stack above.

So the retirement is gated on §2 and §3 landing first — you cannot delete TrackDetail while the player bar still links to it, and you cannot delete the ?album branch while AlbumsView/Archive still point at it. This sequencing is honored in §6.

One inbound-link audit before deletion (verify, don't assume): grep for every href/NavigateTo targeting /tracks and /track/. The known ones are the player-bar title (TrackMetaLabel), AlbumsView.OpenAlbum, and ArchiveView.DetailHref — all repointed by §2. The /albums → /cuts redirect (AlbumsRedirect) stays. The CMS /tracks/{id}/edit family is a different route tree (DeepDrftManager) and is untouched — do not confuse the public /tracks gallery with the CMS track-edit routes.

4.2 The normalization — one shared release-card component (the heart of 11.C)

Recommendation: make ReleaseGallery the single release-card grid, and give its card a per-card href resolver so it can serve Archive's per-medium routing. Repoint /archive and /cuts onto it; delete the inline archive-release-card and album-card markup. This is the One source, multiple views discipline (memory) applied to card rendering.

4.2.1 What's redundant today (audited against live source, 2026-06-15)

Surface Card rendering Verdict
/sessions, /mixes (SessionsView/MixesView) Compose ReleaseGallery with a fixed DetailRoute. Canonical. Already shared.
/archive (ArchiveView) Inline archive-release-card markup — cover (+--fallback), title, artist. Byte-for-byte the same structure as ReleaseGallery's release-card, only the CSS class prefix and the per-card DetailHref differ. Redundant copy. Fold into ReleaseGallery.
/cuts (AlbumsView) Inline album-card markup, card click → OpenAlbum/tracks?album. Redundant copy + wrong target. Fold into ReleaseGallery, repoint to /cuts/{id}.

Three near-identical card implementations across four browse surfaces. The structure is identical (cover with --fallback, body with truncated title + artist); the only real divergence is how a card computes its href: Sessions/Mixes use a fixed route segment; Archive resolves per-medium.

4.2.2 The shared component contract

ReleaseGallery already owns the grid, skeleton-loading, empty-state, and card markup. Generalize its href computation so one component serves both the fixed-route case and the per-medium case:

  • Today: [Parameter] string DetailRoute → card links /{DetailRoute}/{id}.
  • Add: an optional per-card href resolver, [Parameter] Func<ReleaseDto, string>? HrefResolver. When supplied, the card links HrefResolver(release); otherwise it falls back to the DetailRoute-based href (Sessions/Mixes unchanged). Archive passes HrefResolver="@ReleaseRoutes.DetailHref" (the §2 resolver) — so each Archive card routes by its own medium through the same one table.
  • Optionally fold the medium-route default into the resolver itself: since ReleaseRoutes already knows medium→route, Sessions/Mixes could also drop DetailRoute and just pass the resolver. But keeping DetailRoute as the simple default avoids churning two working pages — recommend adding HrefResolver as the new path, leaving DetailRoute as the back-compat default, and migrating Sessions/Mixes only if it's free.

After this: ReleaseGallery is the one card grid. archive-release-card and album-card markup (and their CSS) are deleted; ArchiveView keeps only its search/filter chrome above the grid (the chrome is genuinely Archive-specific — it does not belong in the shared card component).

CSS consolidation: the inline copies carry parallel CSS (archive-release-*, album-card-*). When the markup folds into ReleaseGallery, those rules collapse into the release-card-* rules. Verify the visual treatments match before deleting (cover sizing, fallback styling, truncation) — if Archive/Cuts cards were tuned differently, fold the differences into ReleaseGallery via a modifier class, not by keeping the duplicate.

4.2.3 The Cuts-detail track row — same component, or different?

The brief asks whether the shared card extends to "the Cuts detail track list where applicable." The Cut detail's track rows (§3.1 — 1. ▶ Track One … 3:42) are a different shape from a release card (a horizontal row with ordinal + play + duration, not a cover-forward card). Recommend a separate small TrackRow component for the Cut track list rather than forcing it into ReleaseGallery — they share nothing structurally. If a TrackRow is extracted, it is the natural shared row for any future track-list surface, but it is not the release-card component. Flag this so the normalization doesn't over-reach into forcing two unlike things into one component.

4.3 Residual redundancy (opportunistic, surface don't sprawl)

  1. ArchiveView.MediumLabel — a medium→label lookup. Check whether it duplicates a CMS MediumTypeLabels or the Archive's own medium-chip labels; if the public side has two medium-label lookups, consolidate to one. Medium confidence; opportunistic.
  2. GenresView (/genres) — already nav-demoted (§8.I); Archive has genre filtering. Likely retire-able, but out of this phase's stated scope — flag as adjacent, low urgency. Not in Phase 11 unless Daniel pulls it in.

Land the full stack retirement (§4.1) and the card normalization (§4.2) — both decided. Treat 4.3 as tidy-ups: do them if they fall out of the normalization for free, surface /genres as adjacent.


5. Requirement 4 — Archive filters in the URL

This is a URL-binding pass over the existing ArchiveView, borrowing the TracksView pattern verbatim. No new browser, no new data path — the filter state already drives LoadReleases; the change is making that state enter and leave via the query string.

5.1 Target URL scheme

/archive?q={search}&medium={cut|session|mix}&genre={genre}
  • All three params optional; omitting one means "no filter on that axis" (matches the current null-means-all semantics).
  • medium uses the same lowercase enum token the data service already speaks (Medium.ToString().ToLowerInvariant()), parsed back with Enum.TryParse(ignoreCase:true) + Enum.IsDefined — the exact posture BatchUpload (§8.E) and the API already use.
  • Plain /archive (no params) = the unfiltered first page, which is the bridged/prerendered state.

5.2 The binding mechanics (borrow TracksView)

TracksView.razor.cs is the template:

  • In (URL → state): add [SupplyParameterFromQuery] for q, medium, genre. Seed SearchText / _selectedMedium / _selectedGenre from them in OnInitializedAsync before the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge restore is skipped when a filter is active — ArchiveView already has HasActiveFilter gating exactly this).
  • Out (state → URL): each of OnSearchInput / OnMediumSelected / OnGenreSelected calls Navigation.NavigateTo($"/archive?{composed query}") instead of (or before) calling LoadReleases directly. The query-param change drives the re-fetch.

5.3 The one real subtlety — same-route query change does not re-run OnInitialized

Blazor reuses the component on a same-route query-string change and fires OnParametersSet, not OnInitializedAsync (the TracksView.ClearFilter comment, lines 117120, documents exactly this trap). So the filter→fetch reaction must live where it sees the change:

  • Option A (history-driven): filter handlers only NavigateTo the new URL; move the state-seeding + LoadReleases into OnParametersSet/OnParametersSetAsync keyed off the query params. Cleanest — the URL is the single source of truth; back/forward "just works" because each nav re-runs the same seed-and-fetch. Recommended.
  • Option B (dual-write): handlers both NavigateTo and LoadReleases directly. Simpler diff but the URL and the fetch are two writes that can drift, and back/forward needs separate handling.

Recommend Option A. It makes the URL the source of truth (which is the whole point of the requirement) and gets shareable links + back/forward correctness as a structural consequence rather than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces (400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the OnParametersSet reaction should be idempotent on identical param sets — mirror the _loadedEntryKey guard idiom).

5.4 Persistence interaction

The bridged unfiltered first page (PersistKey = "archive-releases") must keep restoring only when no filter is active — ArchiveView already gates persist + restore on HasActiveFilter. The URL-binding pass must preserve that gate: a /archive?medium=mix direct load must fetch, not restore the unfiltered bridge. The existing HasActiveFilter check already expresses this; the seed-from-URL step just has to run before the restore decision (as §5.2 specifies).


6. Wave decomposition

Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored, and the genuinely independent tracks (Archive URL; the queue model) can run in parallel. Seven commitments, six waves. The queue (11.F) is the one work item that can start cold on day one and is the gate for the Cuts "play album" affordance.

   ┌──────────────────────────┐   ┌──────────────────────────┐   ┌──────────────────────────┐
   │ 11.A  /cuts/{id} page     │   │ 11.D  Archive filters→URL │   │ 11.F  Queue model          │
   │  + CutDetailViewModel     │   │  (TracksView-pattern)     │   │  (IQueueService above the  │
   │  + cover theme border     │   │  INDEPENDENT              │   │   single-slot player)      │
   │  + ordered track list     │   └──────────────────────────┘   │  + player TrackEnded hook  │
   │  (verify public read      │                                  │  + player-bar skip controls│
   │   projects+sorts          │                                  │  INDEPENDENT (cold start)  │
   │   TrackNumber — §3a)      │                                  └────────────┬─────────────┘
   └──────────┬───────────────┘                                               │
              │                                              (11.A header Play & row Play
              ▼                                               consume 11.F's PlayRelease;
   ┌──────────────────────────┐                              degrade to single-track if 11.F
   │ 11.B  ReleaseRoutes        │◄── needs 11.A (Cut→/cuts/id) later — §3.4 seam)
   │  resolver + repoint        │
   │  (player-bar title→release,│
   │   Archive + AlbumsView      │   ┌──────────────────────────┐
   │   cards) + thin /tracks/id │   │ 11.E  Release-level Share  │
   │  redirect                  │   │  (SharePopover release     │
   └──────────┬───────────────┘   │   mode, copy ReleaseRoutes  │
              │                     │   URL) — needs 11.B resolver│
              │◄────────────────────┤   + a release detail to     │
              ▼                     │   share (11.A/Session/Mix)  │
   ┌──────────────────────────┐   └──────────────────────────┘
   │ 11.C  Retire + normalize   │
   │  • delete track-cardinal   │
   │    stack (Tracks*/Track*)  │
   │  • fold Archive+Cuts cards │
   │    into shared ReleaseGallery│
   │  • consolidate medium-label│
   └──────────────────────────┘
  • 11.A — /cuts/{id} album-detail page. The page, CutDetailViewModel, cover theme border, ordered track list (ordered by TrackNumber — §3a), per-row play, header Play, Share button. Ordinal is a verification, not a dependency: §3a confirmed TrackNumber already exists and the read likely already sorts on it; 11.A's one checklist item is confirm the public read projects + sorts TrackNumber (one-line fix if not). Depends only on existing GetById + releaseId- filtered track page (both exist). Header/row Play call into 11.F when present, else degrade to single-track streaming (§3.4 seam). Load-bearing prerequisite for 11.B's Cut resolution.
  • 11.B — ReleaseRoutes resolver + repoint. Promote ArchiveView.DetailHref to a shared ReleaseRoutes.DetailHref; Cut now resolves to /cuts/{id} (needs 11.A); repoint player-bar title (→ release), Archive cards, AlbumsView cards; add the thin /tracks/{id} redirect page. Depends on 11.A. Verify TrackDto.Release is always populated on the player bar (so the resolver has a medium — §7 gap).
  • 11.C — retire + normalize (the heart). With §2 having removed every inbound link: delete the whole track-cardinal stack (TracksView/TrackDetail/TrackCard/TracksGallery/ GalleryViewMode + /tracks, /track/{EntryKey} routes — §4.1) and fold the Archive + Cuts inline card markup into the shared ReleaseGallery via the new HrefResolver (§4.2); consolidate the medium-label lookup (§4.3). Depends on 11.B. (The Cut track-row is a separate small TrackRow, not ReleaseGallery — §4.2.3.)
  • 11.D — Archive filters in the URL. /archive?q=&medium=&genre=, history-driven (§5). Fully independent — touches only ArchiveView. No dependency; free-floating. Note: 11.D and 11.C both touch ArchiveView — sequence them so they don't collide (do 11.D's URL-binding and 11.C's card-fold in one ArchiveView pass, or land 11.D first and rebase 11.C onto it).
  • 11.E — release-level Share. SharePopover gains a release mode that copies ReleaseRoutes.DetailHref(release) (§3b). Depends on 11.B (needs the resolver for the URL) and on a release detail existing to share (11.A for Cuts; Session/Mix already exist). Wire the Cut header Share (and optionally Session/Mix headers) to open the popover in release mode.
  • 11.F — queue model. IQueueService orchestrating above the single-slot player; the one new player-side hook (TrackEnded); player-bar skip controls (§3c). Independent — can start cold on day one, in parallel with 11.A/11.D. It is the gate for the Cuts "play album" affordance: 11.A's header Play calls QueueService.PlayRelease(tracks) once 11.F lands (degrading to single-track before then). Preload (§1.3b) is OUT of this wave — design the seam, defer the feature (§3c.5).

Dependency shape:

11.A ──► 11.B ──► 11.C
            └────► 11.E   (also needs a release detail to share)
11.D   (free-floating; coordinate with 11.C on ArchiveView)
11.F   (free-floating cold start; 11.A's "play album" consumes it)

Critical path: 11.A → 11.B → 11.C. 11.D, 11.E, 11.F hang off it (11.E after 11.B; 11.D and 11.F fully parallel). The two "can start immediately" items are 11.A and 11.F — kicking both off first shortens the wall-clock to a usable Cut page (page + queue arrive together, so "play album" works on first ship of 11.A rather than as a later retrofit).

Honest dependency notes (the brief asked to keep these straight):

  • Ordinal does not gate a wave. §3a showed TrackNumber already exists end-to-end; the "ordering" dependency is a one-line verification inside 11.A, not sequencing.
  • Queue gates the "play album" affordance, not the Cut page. 11.A ships with a degradable Play (single-track) if 11.F is not yet in; the affordance becomes "enqueue album" the moment 11.F lands, via the §3.4 handler swap. So 11.A and 11.F are parallel, with a clean late-binding seam.
  • Shared cards parallel the stack retirement. Folding Archive/Cuts cards into ReleaseGallery (§4.2) and deleting the track-cardinal stack (§4.1) are both in 11.C and both depend on 11.B's repoint — they are siblings, not sequential.

7. Resolved decisions and remaining open questions

7.1 Resolved by Daniel (2026-06-15) — kept visible as decided, not deleted

  1. Player-bar title target (§2). DECIDED: the title click resolves release detail via the resolver, replacing the /track/{EntryKey} link. (Was OQ1.)
  2. Track ordinal (§3a). DECIDED: explicit ordinal column, editable from the CMS — and the read confirms it already exists (TrackEntity.TrackNumber, migration applied, CMS reorder live). No new schema; verify the public read projects/sorts it. (Was OQ4 "do not assume into Phase 11"; Daniel reversed to in-scope, and it turned out already-built.)
  3. /tracks retirement scope (§4.1). DECIDED: retire the whole track-cardinal stack (TracksView/TrackDetail/TrackCard/TracksGallery/GalleryViewMode + routes), not just the ?album branch. (Was OQ5; the full-cut option chosen.)
  4. Release-level Share (§3b). DECIDED: in scope. SharePopover gains a release-keyed mode. (Was an adjacent gap; promoted.)
  5. Play-queue system (§3c). DECIDED: in scope; absorbs the queue half of PLAN.md §1.3. The Cuts "play album" affordance is its first consumer. (Was an adjacent gap; promoted.) Preload half stays deferred.

7.2 Still open (need Daniel — recommendations given)

  1. /cuts/{id} scaffold strategy (§3.2). Compose ReleaseDetailScaffold with a generalized Header slot (recommended) vs. bespoke page like SessionDetail. Sets whether the scaffold contract changes. Recommend the Header-slot generalization; bespoke as fallback.
  2. Cut header affordance idiom (§3.2.2). Keep the icon PlayStateIcon+SharePopover (consistent with Session/Mix) vs. labeled Play/Share buttons (the literal spec wording). Minor — flag.
  3. Queue architecture (§3c). Queue inside IPlayerService vs. a separate IQueueService orchestrating above the single-slot player. Strong steer: the separate IQueueService (§3c.1). Final call is staff-engineer's at implementation — the spec gives the steer, not the verdict.
  4. Release-share keeps "Embed player"? (§3b.3). Recommend copy-link-only in release mode across all media; per-track embed stays where a track is the subject. Trivial either way.
  5. /genres fate (§4.3). Already nav-demoted; Archive has genre filtering. Retire /genres too? Out of stated scope — flag as adjacent, low urgency. Not in Phase 11 unless Daniel pulls it.

7.3 Small things to get right (not decisions — implementer notes)

  • TrackDto.Release nullability on the player bar (11.B). TrackMetaLabel guards Track.Release? — if a track loads without its release populated, the resolver has no medium to route on. Verify the streaming select path always carries Release. Low risk; a verification line in 11.B.
  • Public read projects/sorts TrackNumber (11.A). The §3a verify step — confirm the public releaseId-filtered track page sorts ascending by TrackNumber and carries it onto TrackDto.
  • Player TrackEnded hook (11.F). The queue auto-advances on natural end-of-stream; verify the player raises a track-ended event or add one (the single new player-side surface — §3c.2).
  • Year-only display (§3.1). The Cut header shows just the year; MixDetail/SessionDetail show "MMMM yyyy". Don't copy the month-year format into the Cut header.

8. Why this is consistent with the system's grain

  • Release-cardinal everywhere. Phase 9 + Wave 8 moved the public site to release-cardinal browse (/archive) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is vestigial — which is why the reduction (§4) is available.
  • One source, multiple views. The medium→route resolver (§2) is one table consumed by the player bar, Archive, and Cuts cards — not three DetailHref switches. The shared ReleaseGallery (§4.2) becomes the one release-card grid across all four browse surfaces, not three inline copies. The queue (§3c) is one orchestrator the player bar and a future up-next panel both observe. The Cut detail reuses the scaffold's invariant trio and the existing GetById + releaseId-filtered track page — no new data path. (Memory: One source, multiple views.)
  • Design the seam, defer the feature. Header Play binds to a single handler that swaps from single-track to QueueService.PlayRelease with no page change (§3.4); preload subscribes to the queue's "next is known" state rather than restructuring it (§3c.5). (Memory: Design for adaptability up front.)
  • URL as source of truth (§5). Borrowing the TracksView [SupplyParameterFromQuery] pattern makes the Archive's filters addressable, which is the same shareable-link discipline the embed player and /tracks?album deep links already established.
  • Extension, not modification. The resolver and the ReleaseDetailScaffold Header/BodyContent slots are additive; a future medium's detail page composes the same scaffold and the resolver gains one entry — the Phase 9 Open/Closed discipline, unchanged.

9. Verified facts (read against live source 2026-06-15)

  • No /tracks/{id} route exists. Track-cardinal detail is /track/{EntryKey} (TrackDetail.razor line 1). Player-bar title links to /track/{Track.EntryKey} (TrackMetaLabel.razor line 9).
  • /sessions/{id} and /mixes/{id} exist and are mature (SessionDetail.razor, MixDetail.razor, both id-addressed, both inherit ReleaseDetailBase's prerender bridge; MixDetail composes ReleaseDetailScaffold, SessionDetail deliberately diverges).
  • Cuts have no single-release detail page. /cuts is AlbumsView (medium-parameterized card grid); cards open /tracks?album={title} (AlbumsView.razor.cs line 62). /albums/cuts redirect exists (AlbumsRedirect.razor).
  • /archive is release-cardinal, filters held in component fields not the URL. ArchiveView has _selectedMedium, _selectedGenre, SearchText as private state; no [SupplyParameterFromQuery], no NavigateTo on filter change (ArchiveView.razor.cs). It has a private DetailHref switch (lines 121126) routing Session→/sessions/{id}, Mix→/mixes/{id}, Cut→/tracks?album={title}.
  • TracksView already reads ?album=/?genre=/?q= from the URL via [SupplyParameterFromQuery] (TracksView.razor.cs lines 2123) — the pattern requirement 4 borrows. It documents the same-route-query-change trap (OnParametersSet not OnInitialized, lines 117120).
  • ReleaseDetailScaffold owns the invariant trio (back link, masthead, play/share) + Hero and MetaContent slots; no body/track-list slot today (ReleaseDetailScaffold.razor).
  • Data primitives for the Cut page both exist: IReleaseDataService.GetById(id) returns a full ReleaseDto; the track-data service supports releaseId-filtered paging (ReleaseDetailViewModel.Load uses GetPage(…, releaseId: …)). ReleaseDto carries TrackCount but the track list needs the filtered track page (the DTO has no nested track collection).
  • TrackEntity ALREADY HAS an explicit track-number ordinal. TrackEntity.TrackNumber (int, 1-based, non-null, default 1 — TrackEntity.cs:17); column track_number (TrackConfiguration.cs:37); migration 20260611005700_AddReleaseTypeAndTrackNumber already applied; TrackDto.TrackNumber mirrors it (TrackDto.cs:18); TrackConverter round-trips it; UpdateTrackMetadataRequest.TrackNumber
    • TrackController validate (> 0) and persist; BatchEdit assigns ordinal from reorderable list position on submit (BatchEdit.razor:225); ReleaseRepository.GetTracks already .OrderBy(t => t.TrackNumber) (ReleaseRepository.cs:117). Commitment 5 is verify-and-consume, not new schema. (This corrects the prior draft's "data-model gap" claim — the gap does not exist.)
  • Release-card markup is triplicated. ReleaseGallery (release-card) is the canonical card grid, used only by Sessions/Mixes. ArchiveView (archive-release-card) and AlbumsView (album-card) re-implement the same cover/title/artist structure inline — the only real divergence is per-card href computation (Archive resolves per-medium, the others use a fixed route).
  • Pages.cs MenuPages = ARCHIVE (→ /archive) with Cuts/Sessions/Mixes children; /tracks and /genres are absent from nav, routes reachable (§8.I).
  • SharePopover is track-keyed (takes EntryKey) — sharing a release is a new target.